Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
slapos.package
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Rafael Monnerat
slapos.package
Commits
b9ac2fef
Commit
b9ac2fef
authored
Feb 22, 2014
by
Rafael Monnerat
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
[slapos.package] First working version for debian updates
Still working in progress
parent
18007792
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
153 additions
and
60 deletions
+153
-60
setup.py
setup.py
+15
-5
slapos/package/base_promise.py
slapos/package/base_promise.py
+1
-1
slapos/package/distribution.py
slapos/package/distribution.py
+50
-9
slapos/package/signature.py
slapos/package/signature.py
+32
-5
slapos/package/update.py
slapos/package/update.py
+50
-21
slapos/package/upload_key.py
slapos/package/upload_key.py
+5
-19
No files found.
setup.py
View file @
b9ac2fef
from
setuptools
import
setup
from
setuptools
import
setup
,
find_packages
version
=
'0.0.1.1'
# Still under development
version
=
'0.0.1.3'
name
=
'slapos.package'
name
=
'slapos.package'
long_description
=
open
(
"README.txt"
).
read
()
+
"
\
n
"
+
\
long_description
=
open
(
"README.txt"
).
read
()
+
"
\
n
"
+
\
open
(
"CHANGES.txt"
).
read
()
+
"
\
n
"
open
(
"CHANGES.txt"
).
read
()
+
"
\
n
"
...
@@ -16,7 +17,8 @@ setup(name=name,
...
@@ -16,7 +17,8 @@ setup(name=name,
license
=
'GPLv3'
,
license
=
'GPLv3'
,
url
=
'http://www.slapos.org'
,
url
=
'http://www.slapos.org'
,
author
=
'VIFIB'
,
author
=
'VIFIB'
,
packages
=
[
'slapos.package'
],
namespace_packages
=
[
'slapos'
],
packages
=
find_packages
(),
include_package_data
=
True
,
include_package_data
=
True
,
install_requires
=
[
install_requires
=
[
'slapos.libnetworkcache'
,
'slapos.libnetworkcache'
,
...
@@ -25,8 +27,16 @@ setup(name=name,
...
@@ -25,8 +27,16 @@ setup(name=name,
zip_safe
=
False
,
zip_safe
=
False
,
entry_points
=
{
entry_points
=
{
'console_scripts'
:
[
'console_scripts'
:
[
'slapos-update = slapos.package.update:main'
,
# Those entry points are development version
]
'slappkg-update = slapos.package.update:main'
,
'slappkg-discover = slapos.package.distribution:do_discover'
,
'slappkg-upload-key = slapos.package.upload_key:main'
],
# Not supported yet
#'slapos.cli': [
# 'package upload-key = slapos.package.upload_key:main'
# ]
},
},
test_suite
=
"slapos.package.test"
,
test_suite
=
"slapos.package.test"
,
)
)
slapos/package/base_promise.py
View file @
b9ac2fef
...
@@ -55,7 +55,7 @@ class BasePromise(PackageManager):
...
@@ -55,7 +55,7 @@ class BasePromise(PackageManager):
self
.
log
(
"Calling: %s"
%
' '
.
join
(
cmd_args
))
self
.
log
(
"Calling: %s"
%
' '
.
join
(
cmd_args
))
if
not
dry_run
:
if
not
dry_run
:
p
=
sub
.
Popen
(
cmd_args
,
stdout
=
stdout
,
stderr
=
stderr
)
p
=
sub
process
.
Popen
(
cmd_args
,
stdout
=
stdout
,
stderr
=
stderr
)
output
,
err
=
p
.
communicate
()
output
,
err
=
p
.
communicate
()
return
output
,
err
return
output
,
err
...
...
slapos/package/distribution.py
View file @
b9ac2fef
import
platform
import
platform
import
glob
import
re
import
re
import
os
_distributor_id_file_re
=
re
.
compile
(
"(?:DISTRIB_ID
\
s*=)
\
s*(.*)"
,
re
.
I
)
_distributor_id_file_re
=
re
.
compile
(
"(?:DISTRIB_ID
\
s*=)
\
s*(.*)"
,
re
.
I
)
_release_file_re
=
re
.
compile
(
"(?:DISTRIB_RELEASE
\
s*=)
\
s*(.*)"
,
re
.
I
)
_release_file_re
=
re
.
compile
(
"(?:DISTRIB_RELEASE
\
s*=)
\
s*(.*)"
,
re
.
I
)
...
@@ -30,6 +32,13 @@ def patched_linux_distribution(distname='', version='', id='',
...
@@ -30,6 +32,13 @@ def patched_linux_distribution(distname='', version='', id='',
return
platform
.
linux_distribution
(
distname
,
version
,
id
,
supported_dists
,
full_distribution_name
)
return
platform
.
linux_distribution
(
distname
,
version
,
id
,
supported_dists
,
full_distribution_name
)
class
PackageManager
:
class
PackageManager
:
def
matchSignatureList
(
self
,
signature_list
):
return
self
.
getOSSignature
()
in
signature_list
def
getOSSignature
(
self
):
return
"+++"
.
join
(
patched_linux_distribution
())
def
getDistributionName
(
self
):
def
getDistributionName
(
self
):
return
patched_linux_distribution
()[
0
]
return
patched_linux_distribution
()[
0
]
...
@@ -62,36 +71,60 @@ class PackageManager:
...
@@ -62,36 +71,60 @@ class PackageManager:
""" Add a repository """
""" Add a repository """
return
self
.
_getDistribitionHandler
().
updateRepository
(
self
.
_call
)
return
self
.
_getDistribitionHandler
().
updateRepository
(
self
.
_call
)
def
_installSoftware
(
self
,
name
):
def
_installSoftware
List
(
self
,
name_list
):
""" Upgrade softwares """
""" Upgrade softwares """
return
self
.
_getDistribitionHandler
().
installSoftware
(
self
.
_call
,
name
)
return
self
.
_getDistribitionHandler
().
installSoftware
List
(
self
.
_call
,
name_list
)
def
_updateSoftware
(
self
):
def
_updateSoftware
(
self
):
""" Upgrade softwares """
""" Upgrade softwares """
return
self
.
_getDistribitionHandler
().
updateSoftware
(
self
.
_call
)
return
self
.
_getDistribitionHandler
().
updateSoftware
(
self
.
_call
)
def
updateSystem
(
self
):
def
_
updateSystem
(
self
):
""" Dist-Upgrade of system """
""" Dist-Upgrade of system """
return
self
.
_getDistribitionHandler
().
updateSystem
(
self
.
_call
)
return
self
.
_getDistribitionHandler
().
updateSystem
(
self
.
_call
)
def
update
(
self
,
repository_list
=
[],
package_list
=
[]):
""" Perform upgrade """
self
.
_purgeRepository
()
for
alias
,
url
in
repository_list
:
self
.
_addRepository
(
url
,
alias
)
self
.
_updateRepository
()
if
len
(
package_list
):
self
.
_installSoftwareList
(
package_list
)
# This helper implements API for package handling
# This helper implements API for package handling
class
AptGet
:
class
AptGet
:
source_list_path
=
"/etc/apt/sources.list"
source_list_d_path
=
"/etc/apt/sources.list.d"
def
purgeRepository
(
self
,
caller
):
def
purgeRepository
(
self
,
caller
):
""" Remove all repositories """
""" Remove all repositories """
raise
NotImplemented
# Aggressive removal
os
.
remove
(
self
.
source_list_path
)
open
(
"/etc/apt/sources.list"
,
"w+"
).
write
(
"# Removed all"
)
for
file_path
in
glob
.
glob
(
"%s/*"
%
self
.
source_list_d_path
):
os
.
remove
(
file_path
)
def
addRepository
(
self
,
caller
,
url
,
alias
):
def
addRepository
(
self
,
caller
,
url
,
alias
):
""" Add a repository """
""" Add a repository """
raise
NotImplemented
repos_file
=
open
(
"%s/%s.list"
%
(
self
.
source_list_d_path
,
alias
),
"w"
)
prefix
=
"deb "
if
alias
.
endswith
(
"-src"
):
prefix
=
"deb-src "
repos_file
.
write
(
prefix
+
url
)
repos_file
.
close
()
def
updateRepository
(
self
,
caller
):
def
updateRepository
(
self
,
caller
):
""" Add a repository """
""" Add a repository """
caller
([
'apt-get'
,
'update'
],
stdout
=
None
)
caller
([
'apt-get'
,
'update'
],
stdout
=
None
)
def
installSoftware
(
self
,
caller
,
name
):
def
installSoftware
List
(
self
,
caller
,
name_list
):
""" Instal Software """
""" Instal Software """
self
.
updateRepository
(
caller
)
self
.
updateRepository
(
caller
)
caller
([
"apt-get"
,
"install"
,
"-y"
,
name
],
stdout
=
None
)
command_list
=
[
"apt-get"
,
"install"
,
"-y"
]
command_list
.
extend
(
name_list
)
caller
(
command_list
,
stdout
=
None
)
def
isUpgradable
(
self
,
caller
,
name
):
def
isUpgradable
(
self
,
caller
,
name
):
output
,
err
=
caller
([
"apt-get"
,
"upgrade"
,
"--dry-run"
])
output
,
err
=
caller
([
"apt-get"
,
"upgrade"
,
"--dry-run"
])
...
@@ -132,10 +165,12 @@ class Zypper:
...
@@ -132,10 +165,12 @@ class Zypper:
return
False
return
False
return
True
return
True
def
installSoftware
(
self
,
caller
,
name
):
def
installSoftware
List
(
self
,
caller
,
name_list
):
""" Instal Software """
""" Instal Software """
self
.
updateRepository
(
caller
)
self
.
updateRepository
(
caller
)
caller
([
'zypper'
,
'--gpg-auto-import-keys'
,
'up'
,
'-ly'
,
name
],
stdout
=
None
)
command_list
=
[
'zypper'
,
'--gpg-auto-import-keys'
,
'up'
,
'-ly'
]
command_list
.
extend
(
name_list
)
caller
(
command_list
,
stdout
=
None
)
def
updateSoftware
(
self
,
caller
):
def
updateSoftware
(
self
,
caller
):
""" Upgrade softwares """
""" Upgrade softwares """
...
@@ -145,3 +180,9 @@ class Zypper:
...
@@ -145,3 +180,9 @@ class Zypper:
""" Dist-Upgrade of system """
""" Dist-Upgrade of system """
caller
([
'zypper'
,
'--gpg-auto-import-keys'
,
'dup'
,
'-ly'
],
stdout
=
None
)
caller
([
'zypper'
,
'--gpg-auto-import-keys'
,
'dup'
,
'-ly'
],
stdout
=
None
)
def
do_discover
():
package_manager
=
PackageManager
()
print
package_manager
.
getOSSignature
()
slapos/package/signature.py
View file @
b9ac2fef
...
@@ -73,6 +73,14 @@ class NetworkCache:
...
@@ -73,6 +73,14 @@ class NetworkCache:
else
:
else
:
self
.
directory_key
=
"slapos-upgrade-testing-key"
self
.
directory_key
=
"slapos-upgrade-testing-key"
def
get_yes_no
(
prompt
):
while
True
:
answer
=
raw_input
(
prompt
+
" [y,n]: "
)
if
answer
.
upper
()
in
[
'Y'
,
'YES'
]:
return
True
if
answer
.
upper
()
in
[
'N'
,
'NO'
]:
return
False
class
Signature
:
class
Signature
:
def
__init__
(
self
,
config
,
logger
=
None
):
def
__init__
(
self
,
config
,
logger
=
None
):
...
@@ -92,6 +100,7 @@ class Signature:
...
@@ -92,6 +100,7 @@ class Signature:
for
entry
in
entry_list
:
for
entry
in
entry_list
:
if
entry
[
'timestamp'
]
>
timestamp
:
if
entry
[
'timestamp'
]
>
timestamp
:
best_entry
=
entry
best_entry
=
entry
return
best_entry
return
best_entry
return
helper_download_network_cached_to_file
(
return
helper_download_network_cached_to_file
(
...
@@ -145,7 +154,7 @@ class Signature:
...
@@ -145,7 +154,7 @@ class Signature:
except
Exception
:
except
Exception
:
print
'Unable to upload to cache:
\
n
%s.'
%
traceback
.
format_exc
()
print
'Unable to upload to cache:
\
n
%s.'
%
traceback
.
format_exc
()
def
upload
(
self
,
dry_run
=
0
):
def
upload
(
self
,
dry_run
=
0
,
verbose
=
1
):
upgrade_info
=
ConfigParser
.
RawConfigParser
()
upgrade_info
=
ConfigParser
.
RawConfigParser
()
upgrade_info
.
read
(
self
.
config
.
upgrade_file
)
upgrade_info
.
read
(
self
.
config
.
upgrade_file
)
...
@@ -160,13 +169,17 @@ class Signature:
...
@@ -160,13 +169,17 @@ class Signature:
upgrade_info
.
write
(
file
)
upgrade_info
.
write
(
file
)
file
.
close
()
file
.
close
()
if
verbose
:
print
" You will update this :"
print
open
(
self
.
config
.
upgrade_file
).
read
()
if
dry_run
:
if
dry_run
:
return
return
if
get_yes_no
(
"Do you want to continue? "
):
self
.
_upload
(
self
.
config
.
upgrade_file
)
self
.
_upload
(
self
.
config
.
upgrade_file
)
def
update
(
self
,
reboot
=
None
,
upgrade
=
None
):
def
update
(
self
,
reboot
=
None
,
upgrade
=
None
):
self
.
load
()
if
reboot
is
None
and
upgrade
is
None
:
if
reboot
is
None
and
upgrade
is
None
:
return
return
if
not
self
.
current_state
.
has_section
(
'system'
):
if
not
self
.
current_state
.
has_section
(
'system'
):
...
@@ -182,6 +195,20 @@ class Signature:
...
@@ -182,6 +195,20 @@ class Signature:
self
.
current_state
.
write
(
current_state_file
)
self
.
current_state
.
write
(
current_state_file
)
current_state_file
.
close
()
current_state_file
.
close
()
def
get_signature_dict
(
self
):
""" Convert Next state info into a dict """
map_dict
=
{}
for
key
in
self
.
next_state
.
sections
():
if
key
==
"system"
:
continue
def
clean_list
(
l
):
return
[
x
.
strip
()
for
x
in
l
.
split
(
'
\
n
'
)
if
x
.
strip
()
!=
''
]
map_dict
[
key
]
=
{}
for
entry
in
self
.
next_state
.
options
(
key
):
map_dict
[
key
][
entry
]
=
clean_list
(
self
.
next_state
.
get
(
key
,
entry
))
return
map_dict
def
_read_state
(
self
,
state
,
name
):
def
_read_state
(
self
,
state
,
name
):
""" Extract information from config file """
""" Extract information from config file """
if
not
state
.
has_section
(
'system'
):
if
not
state
.
has_section
(
'system'
):
...
@@ -196,10 +223,10 @@ class Signature:
...
@@ -196,10 +223,10 @@ class Signature:
self
.
current_state
=
ConfigParser
.
RawConfigParser
()
self
.
current_state
=
ConfigParser
.
RawConfigParser
()
self
.
current_state
.
read
(
self
.
config
.
srv_file
)
self
.
current_state
.
read
(
self
.
config
.
srv_file
)
self
.
next_state
=
ConfigParser
.
Raw
ConfigParser
()
self
.
next_state
=
ConfigParser
.
ConfigParser
()
self
.
next_state
.
read
(
self
.
download
())
self
.
next_state
.
read
(
self
.
download
())
self
.
reboot
=
self
.
_read_state
(
self
.
next_state
,
"
upgrade
"
)
self
.
reboot
=
self
.
_read_state
(
self
.
next_state
,
"
reboot
"
)
self
.
upgrade
=
self
.
_read_state
(
self
.
next_state
,
"upgrade"
)
self
.
upgrade
=
self
.
_read_state
(
self
.
next_state
,
"upgrade"
)
self
.
last_reboot
=
self
.
_read_state
(
self
.
current_state
,
"reboot"
)
self
.
last_reboot
=
self
.
_read_state
(
self
.
current_state
,
"reboot"
)
self
.
last_upgrade
=
self
.
_read_state
(
self
.
current_state
,
"upgrade"
)
self
.
last_upgrade
=
self
.
_read_state
(
self
.
current_state
,
"upgrade"
)
slapos/package/update.py
View file @
b9ac2fef
...
@@ -37,7 +37,7 @@ import subprocess as sub
...
@@ -37,7 +37,7 @@ import subprocess as sub
import
sys
import
sys
import
tempfile
import
tempfile
from
signature
import
Signature
from
signature
import
Signature
from
base_promise
import
BasePromise
# create console handler and set level to warning
# create console handler and set level to warning
ch
=
logging
.
StreamHandler
()
ch
=
logging
.
StreamHandler
()
...
@@ -97,19 +97,40 @@ class Upgrader:
...
@@ -97,19 +97,40 @@ class Upgrader:
# add ch to logger
# add ch to logger
self
.
logger
.
addHandler
(
ch
)
self
.
logger
.
addHandler
(
ch
)
def
checkConsistency
(
self
,
*
args
,
**
kw
):
def
fixConsistency
(
self
,
signature
,
upgrade
=
0
,
reboot
=
0
,
boot
=
0
,
**
kw
):
print
"CHECK CONSISTENCY %s"
%
((
args
,
kw
),)
print
upgrade
,
reboot
,
boot
def
run
(
self
):
"""
Will fetch information from web and update and/or reboot
machine if needed
"""
today
=
datetime
.
date
.
today
().
isoformat
()
today
=
datetime
.
date
.
today
().
isoformat
()
if
upgrade
and
boot
:
signature
.
update
(
reboot
=
today
,
upgrade
=
today
)
if
upgrade
:
signature
.
update
(
upgrade
=
today
)
elif
reboot
:
signature
.
update
(
reboot
=
today
)
else
:
raise
ValueError
(
"You need upgrade and/or reboot when invoke fixConsistency!"
)
if
upgrade
:
pkgmanager
=
BasePromise
()
configuration_dict
=
signature
.
get_signature_dict
()
for
entry
in
configuration_dict
:
signature_list
=
configuration_dict
[
entry
].
get
(
"signature-list"
)
if
pkgmanager
.
matchSignatureList
(
signature_list
):
print
"Upgrade FOUND!!!! %s "
%
entry
upgrade_goal
=
configuration_dict
[
entry
]
break
repository_tuple_list
=
[]
for
repository
in
upgrade_goal
[
'repository-list'
]:
alias
,
url
=
repository
.
split
(
"="
)
repository_tuple_list
.
append
((
alias
.
strip
(),
url
.
strip
()))
pkgmanager
.
update
(
repository_tuple_list
,
upgrade_goal
[
'filter-package-list'
])
def
checkConsistency
(
self
,
fixit
=
0
,
**
kw
):
# Get configuration
# Get configuration
signature
=
Signature
(
self
.
config
)
signature
=
Signature
(
self
.
config
)
signature
.
load
()
signature
.
load
()
self
.
logger
.
debug
(
"Expected Reboot early them %s"
%
signature
.
reboot
)
self
.
logger
.
debug
(
"Expected Reboot early them %s"
%
signature
.
reboot
)
...
@@ -117,28 +138,37 @@ class Upgrader:
...
@@ -117,28 +138,37 @@ class Upgrader:
self
.
logger
.
debug
(
"Last reboot : %s"
%
signature
.
last_reboot
)
self
.
logger
.
debug
(
"Last reboot : %s"
%
signature
.
last_reboot
)
self
.
logger
.
debug
(
"Last upgrade : %s"
%
signature
.
last_upgrade
)
self
.
logger
.
debug
(
"Last upgrade : %s"
%
signature
.
last_upgrade
)
if
signature
.
upgrade
>
datetime
.
date
.
today
():
self
.
logger
.
debug
(
"Upgrade will happens on %s"
%
signature
.
upgrade
)
#return
# Check if run for first time
# Check if run for first time
if
signature
.
last_reboot
is
None
:
if
signature
.
last_reboot
is
None
:
if
not
self
.
config
.
dry_run
:
if
fixit
:
signature
.
update
(
reboot
=
today
,
upgrade
=
today
)
# Purge repositories list and add new ones
# Purge repositories list and add new ones
self
.
checkConsistency
(
fixit
=
not
self
.
config
.
dry_run
)
self
.
fixConsistency
(
signature
,
upgrade
=
1
,
boot
=
1
)
else
:
else
:
if
signature
.
last_upgrade
<
signature
.
upgrade
:
if
signature
.
last_upgrade
<
signature
.
upgrade
:
# Purge repositories list and add new ones
# Purge repositories list and add new ones
if
not
self
.
config
.
dry_run
:
if
fixit
:
signature
.
update
(
upgrade
=
today
)
self
.
fixConsistency
(
signature
,
upgrade
=
1
)
self
.
checkConsistency
(
fixit
=
not
self
.
config
.
dry_run
)
else
:
else
:
logger
.
info
(
"Your system is up to date"
)
logger
.
info
(
"Your system is up to date"
)
if
signature
.
last_reboot
<
signature
.
reboot
:
if
signature
.
last_reboot
<
signature
.
reboot
:
if
not
self
.
config
.
dry_run
:
if
not
self
.
config
.
dry_run
:
s
ignature
.
update
(
reboot
=
today
)
s
elf
.
fixConsistency
(
signature
,
reboot
=
1
)
else
:
else
:
self
.
logger
.
debug
(
"Dry run: Rebooting required."
)
self
.
logger
.
debug
(
"Dry run: Rebooting required."
)
def
run
(
self
):
"""
Will fetch information from web and update and/or reboot
machine if needed
"""
self
.
checkConsistency
(
fixit
=
not
self
.
config
.
dry_run
)
def
main
():
def
main
():
"""Update computer and slapos"""
"""Update computer and slapos"""
usage
=
"usage: %s [options] "
%
sys
.
argv
[
0
]
usage
=
"usage: %s [options] "
%
sys
.
argv
[
0
]
...
@@ -147,6 +177,5 @@ def main():
...
@@ -147,6 +177,5 @@ def main():
upgrader
.
run
()
upgrader
.
run
()
sys
.
exit
()
sys
.
exit
()
if
__name__
==
'__main__'
:
if
__name__
==
'__main__'
:
main
()
main
()
slapos/package/upload_key.py
View file @
b9ac2fef
...
@@ -36,6 +36,10 @@ import sys
...
@@ -36,6 +36,10 @@ import sys
from
update
import
Config
from
update
import
Config
from
signature
import
Signature
from
signature
import
Signature
def
do_upgrade
(
config
):
signature
=
Signature
(
config
)
signature
.
upload
(
dry_run
=
config
.
dry_run
)
class
Parser
(
OptionParser
):
class
Parser
(
OptionParser
):
"""
"""
...
@@ -84,28 +88,10 @@ def get_yes_no(prompt):
...
@@ -84,28 +88,10 @@ def get_yes_no(prompt):
if
answer
.
upper
()
in
[
'N'
,
'NO'
]:
if
answer
.
upper
()
in
[
'N'
,
'NO'
]:
return
False
return
False
def
new_upgrade
(
config
):
signature
=
Signature
(
config
)
signature
.
upload
(
dry_run
=
1
)
print
" You will update this :"
print
open
(
config
.
upgrade_file
).
read
()
if
not
get_yes_no
(
"Do you want to continue? "
):
sys
.
exit
(
0
)
if
not
config
.
dry_run
:
print
"Uploading..."
signature
.
upload
()
def
main
():
def
main
():
"""Upload file to update computer and slapos"""
"""Upload file to update computer and slapos"""
usage
=
"usage: [options] "
usage
=
"usage: [options] "
# Parse arguments
# Parse arguments
config
=
Config
(
Parser
(
usage
=
usage
).
check_args
())
config
=
Config
(
Parser
(
usage
=
usage
).
check_args
())
config
.
srv_file
=
"/srv/slapupdate"
do_upgrade
(
config
)
new_upgrade
(
config
)
sys
.
exit
()
sys
.
exit
()
if
__name__
==
'__main__'
:
main
()
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment