Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
slapos.toolbox
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
Nicolas Wavrant
slapos.toolbox
Commits
5f34b3f6
Commit
5f34b3f6
authored
Jul 25, 2013
by
Marco Mariani
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
some pep8 love
parent
17a77af3
Changes
7
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
272 additions
and
133 deletions
+272
-133
slapos/runner/__init__.py
slapos/runner/__init__.py
+8
-4
slapos/runner/decorators.py
slapos/runner/decorators.py
+0
-1
slapos/runner/fileBrowser.py
slapos/runner/fileBrowser.py
+8
-7
slapos/runner/gittools.py
slapos/runner/gittools.py
+11
-4
slapos/runner/runnertest.py
slapos/runner/runnertest.py
+69
-54
slapos/runner/utils.py
slapos/runner/utils.py
+100
-48
slapos/runner/views.py
slapos/runner/views.py
+76
-15
No files found.
slapos/runner/__init__.py
View file @
5f34b3f6
...
...
@@ -21,8 +21,7 @@ class Parser(OptionParser):
"""
Initialize all possible options.
"""
OptionParser
.
__init__
(
self
,
usage
=
usage
,
version
=
version
,
option_list
=
[
option_list
=
[
Option
(
"-l"
,
"--log_file"
,
help
=
"The path to the log file used by the script."
,
type
=
str
),
...
...
@@ -38,7 +37,10 @@ class Parser(OptionParser):
default
=
False
,
action
=
"store_true"
,
help
=
"Debug mode."
),
])
]
OptionParser
.
__init__
(
self
,
usage
=
usage
,
version
=
version
,
option_list
=
option_list
)
def
check_args
(
self
):
"""
...
...
@@ -50,6 +52,7 @@ class Parser(OptionParser):
return
options
,
args
[
0
]
class
Config
:
def
__init__
(
self
):
self
.
configuration_file_path
=
None
...
...
@@ -125,6 +128,7 @@ def run():
sys
.
exit
(
return_code
)
def
serve
(
config
):
from
views
import
app
from
werkzeug.contrib.fixers
import
ProxyFix
...
...
@@ -134,7 +138,7 @@ def serve(config):
app
.
config
.
update
(
software_log
=
config
.
software_root
.
rstrip
(
'/'
)
+
'.log'
,
instance_log
=
config
.
instance_root
.
rstrip
(
'/'
)
+
'.log'
,
workspace
=
workdir
,
workspace
=
workdir
,
software_link
=
software_link
,
instance_profile
=
'instance.cfg'
,
software_profile
=
'software.cfg'
,
...
...
slapos/runner/decorators.py
View file @
5f34b3f6
...
...
@@ -11,4 +11,3 @@ def as_json(f):
def
inner
(
*
args
,
**
kwargs
):
return
Response
(
json
.
dumps
(
f
(
*
args
,
**
kwargs
)),
mimetype
=
'application/json'
)
return
inner
slapos/runner/fileBrowser.py
View file @
5f34b3f6
...
...
@@ -31,14 +31,14 @@ class FileBrowser(object):
html
=
'var gsdirs = [], gsfiles = [];'
dir
=
urllib
.
unquote
(
dir
)
#
'dir' is used below. XXX should not shadow a builtin name
#
XXX-Marco 'dir' and 'all' should not shadow builtin names
realdir
=
realpath
(
self
.
config
,
dir
)
if
not
realdir
:
raise
NameError
(
'Could not load directory %s: Permission denied'
%
dir
)
ldir
=
sorted
(
os
.
listdir
(
realdir
),
key
=
str
.
lower
)
for
f
in
ldir
:
if
f
.
startswith
(
'.'
)
and
not
all
:
#
do not display this file/folder
if
f
.
startswith
(
'.'
)
and
not
all
:
#
do not display this file/folder
continue
ff
=
os
.
path
.
join
(
dir
,
f
)
realfile
=
os
.
path
.
join
(
realdir
,
f
)
...
...
@@ -61,7 +61,6 @@ class FileBrowser(object):
ff + '", "
0
", "' + md5sum + '", "
dir
", "' + mdate + '"));'
return html
def makeDirectory(self, dir, filename):
"""Create a directory"""
realdir = self._realdir(dir)
...
...
@@ -72,7 +71,6 @@ class FileBrowser(object):
else:
return '{result:
\
'
0
\
'
}'
def makeFile(self, dir, filename):
"""Create a file in a directory dir taken"""
realdir = self._realdir(dir)
...
...
@@ -85,17 +83,19 @@ class FileBrowser(object):
def deleteItem(self, dir, files):
"""Delete a list of files or directories"""
# XXX-Marco do not shadow 'dir'
realdir = self._realdir(dir)
lfiles = urllib.unquote(files).split(',,,')
try:
# XXX-Marco do not shadow 'file'
for file in lfiles:
file = os.path.join(realdir, file)
if not os.path.exists(file):
continue
#
silent skip file....
continue
#
silent skip file....
details = file.split('/')
last = details[-1]
if last and last.startswith('.'):
continue
#
cannot delete this file/directory, to prevent security
continue
#
cannot delete this file/directory, to prevent security
if os.path.isdir(file):
shutil.rmtree(file)
else:
...
...
@@ -109,6 +109,7 @@ class FileBrowser(object):
realdir = self._realdir(dir)
lfiles = urllib.unquote(files).split(',,,')
try:
# XXX-Marco do not shadow 'file'
for file in lfiles:
realfile = realpath(self.config, file)
if not realfile:
...
...
slapos/runner/gittools.py
View file @
5f34b3f6
...
...
@@ -29,7 +29,7 @@ def cloneRepo(data):
json
=
""
try
:
if
os
.
path
.
exists
(
workDir
)
and
len
(
os
.
listdir
(
workDir
))
<
2
:
shutil
.
rmtree
(
workDir
)
#
delete useless files
shutil
.
rmtree
(
workDir
)
#
delete useless files
repo
=
Repo
.
clone_from
(
data
[
"repo"
],
workDir
)
config_writer
=
repo
.
config_writer
()
config_writer
.
add_section
(
"user"
)
...
...
@@ -42,6 +42,7 @@ def cloneRepo(data):
json
=
safeResult
(
str
(
e
))
return
jsonify
(
code
=
code
,
result
=
json
)
def
gitStatus
(
project
):
"""Run git status and return status of specified project folder
Args:
...
...
@@ -61,6 +62,7 @@ def gitStatus(project):
json
=
safeResult
(
str
(
e
))
return
jsonify
(
code
=
code
,
result
=
json
,
branch
=
branch
,
dirty
=
isdirty
)
def
switchBranch
(
project
,
name
):
"""Switch a git branch
Args:
...
...
@@ -83,6 +85,7 @@ def switchBranch(project, name):
json
=
safeResult
(
str
(
e
))
return
jsonify
(
code
=
code
,
result
=
json
)
def
addBranch
(
project
,
name
,
onlyCheckout
=
False
):
"""Add new git branch to the repository
Args:
...
...
@@ -105,6 +108,7 @@ def addBranch(project, name, onlyCheckout=False):
json
=
safeResult
(
str
(
e
))
return
jsonify
(
code
=
code
,
result
=
json
)
def
getDiff
(
project
):
"""Get git diff for the specified project directory"""
result
=
""
...
...
@@ -117,6 +121,7 @@ def getDiff(project):
result
=
safeResult
(
str
(
e
))
return
result
def
gitPush
(
project
,
msg
):
"""Commit and Push changes for the specified repository
Args:
...
...
@@ -145,10 +150,11 @@ def gitPush(project, msg):
code
=
1
except
Exception
as
e
:
if
undo_commit
:
git
.
reset
(
"HEAD~"
)
#
undo previous commit
git
.
reset
(
"HEAD~"
)
#
undo previous commit
json
=
safeResult
(
str
(
e
))
return
jsonify
(
code
=
code
,
result
=
json
)
def
gitPull
(
project
):
result
=
""
code
=
0
...
...
@@ -161,6 +167,7 @@ def gitPull(project):
result
=
safeResult
(
str
(
e
))
return
jsonify
(
code
=
code
,
result
=
result
)
def
safeResult
(
result
):
"""Parse string and remove credential of the user"""
regex
=
re
.
compile
(
"(https:
\
/
\
/)([
\
w
\
d
\
._-]+:[
\
w
\
d
\
._-]+)
\
@([
\
S]+
\
s)
"
, re.VERBOSE)
...
...
slapos/runner/runnertest.py
View file @
5f34b3f6
...
...
@@ -18,6 +18,7 @@ from slapos.runner.process import killRunningProcess, isRunning
from
slapos.runner
import
views
import
slapos.slap
#Helpers
def
loadJson
(
response
):
return
json
.
loads
(
response
.
data
)
...
...
@@ -48,6 +49,7 @@ class Config:
if
not
getattr
(
self
,
key
,
None
):
setattr
(
self
,
key
,
configuration_dict
[
key
])
class
SlaprunnerTestCase
(
unittest
.
TestCase
):
def
setUp
(
self
):
...
...
@@ -57,8 +59,8 @@ class SlaprunnerTestCase(unittest.TestCase):
self
.
updateUser
=
[
"newslapuser"
,
"newslappwd"
,
"slaprunner@nexedi.com"
,
"SlapOS web runner"
]
self
.
rcode
=
"41bf2657"
self
.
repo
=
'http://git.erp5.org/repos/slapos.git'
self
.
software
=
"workspace/slapos/software/"
#
relative directory fo SR
self
.
project
=
'slapos'
#
Default project name
self
.
software
=
"workspace/slapos/software/"
#
relative directory fo SR
self
.
project
=
'slapos'
#
Default project name
self
.
template
=
'template.cfg'
self
.
partitionPrefix
=
'slappart'
self
.
slaposBuildout
=
"1.6.0-dev-SlapOS-010"
...
...
@@ -77,7 +79,7 @@ class SlaprunnerTestCase(unittest.TestCase):
views
.
app
.
config
.
update
(
software_log
=
config
.
software_root
.
rstrip
(
'/'
)
+
'.log'
,
instance_log
=
config
.
instance_root
.
rstrip
(
'/'
)
+
'.log'
,
workspace
=
workdir
,
workspace
=
workdir
,
software_link
=
software_link
,
instance_profile
=
'instance.cfg'
,
software_profile
=
'software.cfg'
,
...
...
@@ -117,20 +119,24 @@ class SlaprunnerTestCase(unittest.TestCase):
def
configAccount
(
self
,
username
,
password
,
email
,
name
,
rcode
):
"""Helper for configAccount"""
return
self
.
app
.
post
(
'/configAccount'
,
data
=
dict
(
return
self
.
app
.
post
(
'/configAccount'
,
data
=
dict
(
username
=
username
,
password
=
password
,
email
=
email
,
name
=
name
,
rcode
=
rcode
),
follow_redirects
=
True
)
),
follow_redirects
=
True
)
def
login
(
self
,
username
,
password
):
"""Helper for Login method"""
return
self
.
app
.
post
(
'/doLogin'
,
data
=
dict
(
return
self
.
app
.
post
(
'/doLogin'
,
data
=
dict
(
clogin
=
username
,
cpwd
=
password
),
follow_redirects
=
True
)
),
follow_redirects
=
True
)
def
setAccount
(
self
):
"""Initialize user account and log user in"""
...
...
@@ -146,13 +152,15 @@ class SlaprunnerTestCase(unittest.TestCase):
def
updateAccount
(
self
,
newaccount
,
rcode
):
"""Helper for update user account data"""
return
self
.
app
.
post
(
'/updateAccount'
,
data
=
dict
(
return
self
.
app
.
post
(
'/updateAccount'
,
data
=
dict
(
username
=
newaccount
[
0
],
password
=
newaccount
[
1
],
email
=
newaccount
[
2
],
name
=
newaccount
[
3
],
rcode
=
rcode
),
follow_redirects
=
True
)
),
follow_redirects
=
True
)
def
getCurrentSR
(
self
):
return
getProfilePath
(
self
.
app
.
config
[
'etc_dir'
],
...
...
@@ -189,7 +197,8 @@ class SlaprunnerTestCase(unittest.TestCase):
"""Helper for setup compiled software release dir"""
self
.
setupProjectFolder
(
withSoftware
=
True
)
md5
=
hashlib
.
md5
(
os
.
path
.
join
(
self
.
app
.
config
[
'workspace'
],
"slapos/software/slaprunner-test"
,
self
.
app
.
config
[
'software_profile'
])
"slapos/software/slaprunner-test"
,
self
.
app
.
config
[
'software_profile'
])
).
hexdigest
()
base
=
os
.
path
.
join
(
self
.
app
.
config
[
'software_root'
],
md5
)
template
=
os
.
path
.
join
(
base
,
self
.
template
)
...
...
@@ -208,7 +217,6 @@ class SlaprunnerTestCase(unittest.TestCase):
"""Kill slapproxy process"""
killRunningProcess
(
'slapproxy'
,
recursive
=
True
)
#Begin test case here
def
test_wrong_login
(
self
):
"""Test Login user before create session. This should return error value"""
...
...
@@ -266,12 +274,16 @@ class SlaprunnerTestCase(unittest.TestCase):
"""Start scenario 1 for deploying SR: Clone a project from git repository"""
self
.
setAccount
()
folder
=
'workspace/'
+
self
.
project
data
=
{
"repo"
:
self
.
repo
,
"user"
:
'Slaprunner test'
,
"email"
:
'slaprunner@nexedi.com'
,
"name"
:
folder
}
data
=
{
'repo'
:
self
.
repo
,
'user'
:
'Slaprunner test'
,
'email'
:
'slaprunner@nexedi.com'
,
'name'
:
folder
}
response
=
loadJson
(
self
.
app
.
post
(
'/cloneRepository'
,
data
=
data
,
follow_redirects
=
True
))
self
.
assertEqual
(
response
[
'result'
],
""
)
#Get realpath of create project
#
Get realpath of create project
path_data
=
dict
(
file
=
folder
)
response
=
loadJson
(
self
.
app
.
post
(
'/getPath'
,
data
=
path_data
,
follow_redirects
=
True
))
...
...
@@ -281,10 +293,12 @@ class SlaprunnerTestCase(unittest.TestCase):
config
=
open
(
os
.
path
.
join
(
realFolder
,
'.git/config'
),
'r'
).
read
()
assert
"slaprunner@nexedi.com"
in
config
and
"Slaprunner test"
in
config
#Checkout to slaprunner branch, this supose that branch slaprunner exit
response
=
loadJson
(
self
.
app
.
post
(
'/newBranch'
,
data
=
dict
(
response
=
loadJson
(
self
.
app
.
post
(
'/newBranch'
,
data
=
dict
(
project
=
folder
,
create
=
'0'
,
name
=
'slaprunner'
),
name
=
'slaprunner'
),
follow_redirects
=
True
))
self
.
assertEqual
(
response
[
'result'
],
""
)
self
.
logout
()
...
...
@@ -308,7 +322,7 @@ class SlaprunnerTestCase(unittest.TestCase):
self
.
test_cloneProject
()
#Login
self
.
login
(
self
.
users
[
0
],
self
.
users
[
1
])
software
=
os
.
path
.
join
(
self
.
software
,
'drupal'
)
#
Drupal SR must exist in SR folder
software
=
os
.
path
.
join
(
self
.
software
,
'drupal'
)
#
Drupal SR must exist in SR folder
response
=
loadJson
(
self
.
app
.
post
(
'/setCurrentProject'
,
data
=
dict
(
path
=
software
),
follow_redirects
=
True
))
...
...
@@ -391,8 +405,8 @@ class SlaprunnerTestCase(unittest.TestCase):
self
.
assertNotEqual
(
partitionList
,
[])
#Assume that the requested partition is partition 0
slapParameterDict
=
partitionList
[
0
].
getInstanceParameterDict
()
self
.
assertTrue
(
slapParameterDict
.
has_key
(
'appname'
)
)
self
.
assertTrue
(
slapParameterDict
.
has_key
(
'cacountry'
)
)
self
.
assertTrue
(
'appname'
in
slapParameterDict
)
self
.
assertTrue
(
'cacountry'
in
slapParameterDict
)
self
.
assertEqual
(
slapParameterDict
[
'appname'
],
'slaprunnerTest'
)
self
.
assertEqual
(
slapParameterDict
[
'cacountry'
],
'France'
)
self
.
assertEqual
(
slapParameterDict
[
'slap_software_type'
],
'production'
)
...
...
@@ -439,6 +453,7 @@ class SlaprunnerTestCase(unittest.TestCase):
self
.
stopSlapproxy
()
self
.
logout
()
def
main
():
# Empty parser for now - so that erp5testnode is happy when doing --help
parser
=
argparse
.
ArgumentParser
()
...
...
slapos/runner/utils.py
View file @
5f34b3f6
...
...
@@ -33,10 +33,12 @@ html_escape_table = {
"<"
:
"<"
,
}
def
html_escape
(
text
):
"""Produce entities within text."""
return
""
.
join
(
html_escape_table
.
get
(
c
,
c
)
for
c
in
text
)
def
getSession
(
config
):
"""
Get the session data of current user.
...
...
@@ -53,6 +55,7 @@ def getSession(config):
return
False
return
user
def
saveSession
(
config
,
account
):
"""
Save account information for the current user
...
...
@@ -74,7 +77,7 @@ def saveSession(config, account):
f
=
open
(
user
,
'r'
)
#backup previous data
data
=
f
.
read
()
open
(
user
+
'.back'
,
'w'
).
write
(
data
)
open
(
'%s.back'
%
user
,
'w'
).
write
(
data
)
f
.
close
()
backup
=
True
if
not
account
[
1
]:
...
...
@@ -94,11 +97,12 @@ def saveSession(config, account):
try
:
if
backup
:
os
.
remove
(
user
)
os
.
rename
(
user
+
'.back'
,
user
)
os
.
rename
(
'%s.back'
%
user
,
user
)
except
:
pass
return
str
(
e
)
def
getCurrentSoftwareReleaseProfile
(
config
):
"""
Returns used Software Release profile as a string.
...
...
@@ -111,6 +115,7 @@ def getCurrentSoftwareReleaseProfile(config):
except
:
return
False
def
requestInstance
(
config
,
software_type
=
None
):
"""
Request the main instance of our environment
...
...
@@ -131,7 +136,7 @@ def requestInstance(config, software_type=None):
param_path
=
os
.
path
.
join
(
config
[
'etc_dir'
],
".parameter.xml"
)
xml_result
=
readParameters
(
param_path
)
partition_parameter_kw
=
None
if
type
(
xml_result
)
!=
type
(
''
)
and
xml_result
.
has_key
(
'instance'
)
:
if
type
(
xml_result
)
!=
type
(
''
)
and
'instance'
in
xml_result
:
partition_parameter_kw
=
xml_result
[
'instance'
]
return
slap
.
registerOpenOrder
().
request
(
...
...
@@ -143,6 +148,7 @@ def requestInstance(config, software_type=None):
state
=
None
,
shared
=
False
)
def
updateProxy
(
config
):
"""
Configure Slapos Node computer and partitions.
...
...
@@ -163,22 +169,28 @@ def updateProxy(config):
'netmask'
:
'255.255.255.255'
,
'partition_list'
:
[],
'reference'
:
config
[
'computer_id'
],
'software_root'
:
config
[
'software_root'
]}
'software_root'
:
config
[
'software_root'
]
}
for
i
in
xrange
(
0
,
int
(
config
[
'partition_amount'
])):
partition_reference
=
'%s%s'
%
(
prefix
,
i
)
partition_path
=
os
.
path
.
join
(
config
[
'instance_root'
],
partition_reference
)
if
not
os
.
path
.
exists
(
partition_path
):
os
.
mkdir
(
partition_path
)
os
.
chmod
(
partition_path
,
0750
)
slap_config
[
'partition_list'
].
append
({
'address_list'
:
[{
'addr'
:
config
[
'ipv4_address'
],
'netmask'
:
'255.255.255.255'
},
{
'addr'
:
config
[
'ipv6_address'
],
'netmask'
:
'ffff:ffff:ffff::'
},
slap_config
[
'partition_list'
].
append
({
'address_list'
:
[
{
'addr'
:
config
[
'ipv4_address'
],
'netmask'
:
'255.255.255.255'
},
{
'addr'
:
config
[
'ipv6_address'
],
'netmask'
:
'ffff:ffff:ffff::'
},
],
'path'
:
partition_path
,
'reference'
:
partition_reference
,
'tap'
:
{
'name'
:
partition_reference
},
})
'tap'
:
{
'name'
:
partition_reference
}})
computer
.
updateConfiguration
(
xml_marshaller
.
xml_marshaller
.
dumps
(
slap_config
))
return
True
...
...
@@ -219,6 +231,7 @@ def removeProxyDb(config):
if
os
.
path
.
exists
(
config
[
'database_uri'
]):
os
.
unlink
(
config
[
'database_uri'
])
def
isSoftwareRunning
(
config
=
None
):
"""
Return True if slapgrid-sr is still running and false if slapgrid if not
...
...
@@ -263,13 +276,14 @@ def config_SR_folder(config):
"""Create a symbolik link for each folder in software folder. That allow
user to customize software release folder"""
list
=
[]
# XXX-Marco do not shadow 'list'
config_name
=
'slaprunner.config'
for
path
in
os
.
listdir
(
config
[
'software_link'
]):
cfg_path
=
os
.
path
.
join
(
config
[
'software_link'
],
path
,
config_name
)
if
os
.
path
.
exists
(
cfg_path
):
cfg
=
open
(
cfg_path
,
'r'
).
read
().
split
(
"#"
)
if
len
(
cfg
)
!=
2
:
continue
#
there is a broken config file
continue
#
there is a broken config file
list
.
append
(
cfg
[
1
])
folder_list
=
os
.
listdir
(
config
[
'software_root'
])
if
len
(
folder_list
)
<
1
:
...
...
@@ -280,7 +294,7 @@ def config_SR_folder(config):
name
=
projects
[
len
(
projects
)
-
2
]
for
folder
in
folder_list
:
if
folder
in
list
:
continue
#
this folder is already registered
continue
#
this folder is already registered
else
:
if
not
os
.
path
.
exists
(
os
.
path
.
join
(
config
[
'software_link'
],
name
)):
destination
=
os
.
path
.
join
(
config
[
'software_link'
],
name
)
...
...
@@ -292,9 +306,10 @@ def config_SR_folder(config):
os
.
symlink
(
source
,
destination
)
#write config file
cf
=
open
(
cfg
,
'w'
)
cf
.
write
(
curent_project
+
"#"
+
folder
)
cf
.
write
(
curent_project
+
"#"
+
folder
)
cf
.
close
()
def
loadSoftwareRList
(
config
):
"""Return list (of dict) of Software Release from symbolik SR folder"""
list
=
[]
...
...
@@ -304,10 +319,11 @@ def loadSoftwareRList(config):
if
os
.
path
.
exists
(
cfg_path
):
cfg
=
open
(
cfg_path
,
'r'
).
read
().
split
(
"#"
)
if
len
(
cfg
)
!=
2
:
continue
#
there is a broken config file
continue
#
there is a broken config file
list
.
append
(
dict
(
md5
=
cfg
[
1
],
path
=
cfg
[
0
],
title
=
path
))
return
list
def
isInstanceRunning
(
config
=
None
):
"""
Return True if slapgrid-cp is still running and False otherwise
...
...
@@ -353,6 +369,7 @@ def getProfilePath(projectDir, profile):
projectFolder
=
open
(
os
.
path
.
join
(
projectDir
,
".project"
)).
read
()
return
os
.
path
.
join
(
projectFolder
,
profile
)
def
getSlapStatus
(
config
):
"""Return all Slapos Partitions with associate information"""
slap
=
slapos
.
slap
.
slap
()
...
...
@@ -372,11 +389,13 @@ def getSlapStatus(config):
partition_list
.
append
((
slappart_id
,
[]))
return
partition_list
def
svcStopAll
(
config
):
"""Stop all Instance process on this computer"""
return
Popen
([
config
[
'supervisor'
],
config
[
'configuration_file_path'
],
'shutdown'
]).
communicate
()[
0
]
def
removeInstanceRoot
(
config
):
"""Clean instance directory and stop all its running process"""
if
os
.
path
.
exists
(
config
[
'instance_root'
]):
...
...
@@ -384,11 +403,12 @@ def removeInstanceRoot(config):
for
root
,
dirs
,
_
in
os
.
walk
(
config
[
'instance_root'
]):
for
fname
in
dirs
:
fullPath
=
os
.
path
.
join
(
root
,
fname
)
if
not
os
.
access
(
fullPath
,
os
.
W_OK
)
:
if
not
os
.
access
(
fullPath
,
os
.
W_OK
):
# Some directories may be read-only, preventing to remove files in it
os
.
chmod
(
fullPath
,
0744
)
shutil
.
rmtree
(
config
[
'instance_root'
])
def
getSvcStatus
(
config
):
"""Return all Softwares Instances process Information"""
result
=
Popen
([
config
[
'supervisor'
],
config
[
'configuration_file_path'
],
...
...
@@ -397,10 +417,11 @@ def getSvcStatus(config):
supervisord
=
[]
for
item
in
result
.
split
(
'
\
n
'
):
if
item
.
strip
()
!=
""
:
if
re
.
search
(
regex
,
item
,
re
.
IGNORECASE
)
==
None
:
if
re
.
search
(
regex
,
item
,
re
.
IGNORECASE
)
is
None
:
supervisord
.
append
(
re
.
split
(
'[
\
s,]+
'
, item))
return supervisord
def getSvcTailProcess(config, process):
"""Get log for the specifie process
...
...
@@ -413,6 +434,7 @@ def getSvcTailProcess(config, process):
return Popen([config['
supervisor
'], config['
configuration_file_path
'],
"tail", process]).communicate()[0]
def svcStartStopProcess(config, process, action):
"""Send start or stop process command to supervisord
...
...
@@ -421,10 +443,17 @@ def svcStartStopProcess(config, process, action):
process: process to start or stop.
action: current state which is used to generate the new process state.
"""
cmd = {"RESTART":"restart", "STOPPED":"start", "RUNNING":"stop", "EXITED":"start", "STOP":"stop"}
cmd = {
'
RESTART
': '
restart
',
'
STOPPED
': '
start
',
'
RUNNING
': '
stop
',
'
EXITED
': '
start
',
'
STOP
': '
stop
'
}
return Popen([config['
supervisor
'], config['
configuration_file_path
'],
cmd[action], process]).communicate()[0]
def getFolderContent(config, folder):
"""
Read all file and folder into specified directory
...
...
@@ -449,7 +478,7 @@ def getFolderContent(config, folder):
ldir = []
for f in ldir:
if f.startswith('
.
'):
#
do not displays this file/folder
if f.startswith('
.
'):
#
do not displays this file/folder
continue
ff = os.path.join(d, f)
if os.path.isdir(os.path.join(realdir, f)):
...
...
@@ -463,6 +492,7 @@ def getFolderContent(config, folder):
r.append('
</
ul
>
')
return jsonify(result=''.join(r))
def getFolder(config, folder):
"""
Read list of folder for the specified directory
...
...
@@ -486,7 +516,7 @@ def getFolder(config, folder):
else:
ldir = sorted(os.listdir(realdir), key=str.lower)
for f in ldir:
if f.startswith('
.
'):
#
do not display this file/folder
if f.startswith('
.
'):
#
do not display this file/folder
continue
ff = os.path.join(d, f)
if os.path.isdir(os.path.join(realdir, f)):
...
...
@@ -497,6 +527,7 @@ def getFolder(config, folder):
r.append('
</
ul
>
')
return jsonify(result=''.join(r))
def getProjectList(folder):
"""Return the list of projet (folder) into the workspace
...
...
@@ -512,6 +543,7 @@ def getProjectList(folder):
project.append(elt)
return project
def configNewSR(config, projectpath):
"""Configure a Software Release as current Software Release
...
...
@@ -539,6 +571,7 @@ def configNewSR(config, projectpath):
else:
return False
def newSoftware(folder, config, session):
"""
Create a new Software Release folder with default profiles
...
...
@@ -582,6 +615,7 @@ def newSoftware(folder, config, session):
shutil.rmtree(folderPath)
return jsonify(code=code, result=json)
def checkSoftwareFolder(path, config):
"""Check id `path` is a valid Software Release folder"""
realdir = realpath(config, path)
...
...
@@ -589,6 +623,7 @@ def checkSoftwareFolder(path, config):
return jsonify(result=path)
return jsonify(result="")
def getProjectTitle(config):
"""Generate the name of the current software Release (for slaprunner UI)"""
conf = os.path.join(config['
etc_dir
'], ".project")
...
...
@@ -598,6 +633,7 @@ def getProjectTitle(config):
return '
%
s
(
%
s
)
' % (software, '
/
'.join(project[:-2]))
return "No Profile"
def getSoftwareReleaseName(config):
"""Get the name of the current Software Release"""
sr_profile = os.path.join(config['
etc_dir
'], ".project")
...
...
@@ -607,6 +643,7 @@ def getSoftwareReleaseName(config):
return software.replace('
', '
_
')
return "No_name"
def removeSoftwareByName(config, md5, folderName):
"""Remove all content of the software release specified by md5
...
...
@@ -622,12 +659,13 @@ def removeSoftwareByName(config, md5, folderName):
raise Exception("Cannot remove software Release: No such file or directory")
if not os.path.exists(linkpath):
raise Exception("Cannot remove software Release: No such file or directory %s" %
('
software_root
/
'
+
folderName))
('
software_root
/
'
+
folderName))
svcStopAll(config)
os.unlink(linkpath)
shutil.rmtree(path)
return loadSoftwareRList(config)
def tail(f, lines=20):
"""
Returns the last `lines` lines of file `f`. It is an implementation of tail -f n.
...
...
@@ -655,6 +693,7 @@ def tail(f, lines=20):
block -= 1
return '
\
n
'.join(''.join(data).splitlines()[-lines:])
def readFileFrom(f, lastPosition, limit=20000):
"""
Returns the last lines of file `f`, from position lastPosition.
...
...
@@ -663,22 +702,23 @@ def readFileFrom(f, lastPosition, limit=20000):
"""
BUFSIZ = 1024
f.seek(0, 2)
# XXX-Marco do now shadow '
bytes
'
bytes = f.tell()
block = -1
data = ""
length = bytes
truncated = False
#
True if a part of log data has been truncated
if (lastPosition <= 0 and length > limit) or (length
-
lastPosition > limit):
truncated = False
#
True if a part of log data has been truncated
if (lastPosition <= 0 and length > limit) or (length
-
lastPosition > limit):
lastPosition = length - limit
truncated = True
size = bytes - lastPosition
while bytes > lastPosition:
if abs(block
*
BUFSIZ) <= size:
if abs(block
*
BUFSIZ) <= size:
# Seek back one whole BUFSIZ
f.seek(block * BUFSIZ, 2)
data = f.read(BUFSIZ) + data
else:
margin = abs(block
*
BUFSIZ) - size
margin = abs(block
*
BUFSIZ) - size
if length < BUFSIZ:
f.seek(0, 0)
else:
...
...
@@ -688,7 +728,12 @@ def readFileFrom(f, lastPosition, limit=20000):
bytes -= BUFSIZ
block -= 1
f.close()
return {"content":data, "position":length, "truncated":truncated}
return {
'
content
': data,
'
position
': length,
'
truncated
': truncated
}
def isText(file):
"""Return True if the mimetype of file is Text"""
...
...
@@ -701,6 +746,7 @@ def isText(file):
except:
return False
def md5sum(file):
"""Compute md5sum of `file` and return hexdigest value"""
if os.path.isdir(file):
...
...
@@ -717,6 +763,7 @@ def md5sum(file):
except:
return False
def realpath(config, path, check_exist=True):
"""
Get realpath of path or return False if user is not allowed to access to
...
...
@@ -724,10 +771,15 @@ def realpath(config, path, check_exist=True):
"""
split_path = path.split('
/
')
key = split_path[0]
allow_list = {'
software_root
':config['
software_root
'], '
instance_root
':
config['
instance_root
'], '
workspace
': config['
workspace
'],
'
software_link
':config['
software_link
']}
if allow_list.has_key(key):
allow_list = {
'
software_root
': config['
software_root
'],
'
instance_root
': config['
instance_root
'],
'
workspace
': config['
workspace
'],
'
software_link
': config['
software_link
']
}
if key not in allow_list:
return False
del split_path[0]
path = os.path.join(allow_list[key], *split_path)
if check_exist:
...
...
@@ -737,7 +789,7 @@ def realpath(config, path, check_exist=True):
return False
else:
return path
return False
def readParameters(path):
"""Read Instance parameters stored into a local file.
...
...
@@ -755,7 +807,7 @@ def readParameters(path):
sub_obj = {}
for subnode in elt.childNodes:
if subnode.nodeType != subnode.TEXT_NODE:
sub_obj[str(subnode.getAttribute('
id
'))] = subnode.childNodes[0].data
#
.decode('
utf
-
8
').decode('
utf
-
8
')
sub_obj[str(subnode.getAttribute('
id
'))] = subnode.childNodes[0].data
#
.decode('
utf
-
8
').decode('
utf
-
8
')
obj[str(elt.tagName)] = sub_obj
return obj
except Exception, e:
...
...
slapos/runner/views.py
View file @
5f34b3f6
...
...
@@ -41,9 +41,11 @@ file_request = FileBrowser(app.config)
import
logging
logger
=
logging
.
getLogger
(
'werkzeug'
)
def
login_redirect
(
*
args
,
**
kwargs
):
return
redirect
(
url_for
(
'login'
))
#Access Control: Only static files and login pages are allowed to guest
@
app
.
before_request
def
before_request
():
...
...
@@ -67,15 +69,18 @@ def before_request():
def
home
():
return
render_template
(
'index.html'
)
# general views
@
login_required
()
def
browseWorkspace
():
return
render_template
(
'workspace.html'
)
@
app
.
route
(
"/login"
)
def
login
():
return
render_template
(
'login.html'
)
@
app
.
route
(
"/setAccount"
)
def
setAccount
():
account
=
getSession
(
app
.
config
)
...
...
@@ -83,17 +88,20 @@ def setAccount():
return
render_template
(
'account.html'
)
return
redirect
(
url_for
(
'login'
))
@
login_required
()
def
myAccount
():
account
=
getSession
(
app
.
config
)
return
render_template
(
'account.html'
,
username
=
account
[
0
],
email
=
account
[
2
],
name
=
account
[
3
].
decode
(
'utf-8'
))
@
app
.
route
(
"/dologout"
)
def
dologout
():
_
=
logout
()
return
redirect
(
url_for
(
'login'
))
@
login_required
()
def
configRepo
():
public_key
=
open
(
app
.
config
[
'public_key'
],
'r'
).
read
()
...
...
@@ -102,6 +110,7 @@ def configRepo():
public_key
=
public_key
,
name
=
account
[
3
].
decode
(
'utf-8'
),
email
=
account
[
2
])
@
app
.
route
(
"/doLogin"
,
methods
=
[
'POST'
])
def
doLogin
():
username
=
request
.
form
[
'clogin'
]
...
...
@@ -111,6 +120,7 @@ def doLogin():
return
jsonify
(
code
=
1
,
result
=
""
)
return
jsonify
(
code
=
0
,
result
=
"Login or password is incorrect, please check it!"
)
# software views
@
login_required
()
def
editSoftwareProfile
():
...
...
@@ -120,11 +130,13 @@ def editSoftwareProfile():
return
render_template
(
'updateSoftwareProfile.html'
,
workDir
=
'workspace'
,
profile
=
profile
,
projectList
=
getProjectList
(
app
.
config
[
'workspace'
]))
@
login_required
()
def
inspectSoftware
():
return
render_template
(
'runResult.html'
,
softwareRoot
=
'software_link/'
,
softwares
=
loadSoftwareRList
(
app
.
config
))
#remove content of compiled software release
@
login_required
()
def
removeSoftware
():
...
...
@@ -138,12 +150,14 @@ def removeSoftware():
flash
(
'Software removed'
)
return
redirect
(
url_for
(
'inspectSoftware'
))
@
login_required
()
def
runSoftwareProfile
():
if
runSoftwareWithLock
(
app
.
config
):
return
jsonify
(
result
=
True
)
return
jsonify
(
result
=
True
)
else
:
return
jsonify
(
result
=
False
)
return
jsonify
(
result
=
False
)
@
login_required
()
def
viewSoftwareLog
():
...
...
@@ -154,6 +168,7 @@ def viewSoftwareLog():
return
render_template
(
'viewLog.html'
,
type
=
'software'
,
result
=
result
.
encode
(
"utf-8"
))
# instance views
@
login_required
()
def
editInstanceProfile
():
...
...
@@ -163,6 +178,7 @@ def editInstanceProfile():
return
render_template
(
'updateInstanceProfile.html'
,
workDir
=
'workspace'
,
profile
=
profile
,
projectList
=
getProjectList
(
app
.
config
[
'workspace'
]))
# get status of all computer partitions and process state
@
login_required
()
def
inspectInstance
():
...
...
@@ -178,6 +194,7 @@ def inspectInstance():
slap_status
=
getSlapStatus
(
app
.
config
),
supervisore
=
result
,
partition_amount
=
app
.
config
[
'partition_amount'
])
#Reload instance process ans returns new value to ajax
@
login_required
()
def
supervisordStatus
():
...
...
@@ -200,13 +217,14 @@ def supervisordStatus():
html
+=
"</tr>"
return
jsonify
(
code
=
1
,
result
=
html
)
@
login_required
()
def
removeInstance
():
if
isInstanceRunning
(
app
.
config
):
flash
(
'Instantiation in progress, cannot remove'
)
else
:
removeProxyDb
(
app
.
config
)
svcStopAll
(
app
.
config
)
#
Stop All instance process
svcStopAll
(
app
.
config
)
#
Stop All instance process
removeInstanceRoot
(
app
.
config
)
param_path
=
os
.
path
.
join
(
app
.
config
[
'etc_dir'
],
".parameter.xml"
)
if
os
.
path
.
exists
(
param_path
):
...
...
@@ -214,14 +232,16 @@ def removeInstance():
flash
(
'Instance removed'
)
return
redirect
(
url_for
(
'inspectInstance'
))
@
login_required
()
def
runInstanceProfile
():
if
not
os
.
path
.
exists
(
app
.
config
[
'instance_root'
]):
os
.
mkdir
(
app
.
config
[
'instance_root'
])
if
runInstanceWithLock
(
app
.
config
):
return
jsonify
(
result
=
True
)
return
jsonify
(
result
=
True
)
else
:
return
jsonify
(
result
=
False
)
return
jsonify
(
result
=
False
)
@
login_required
()
def
viewInstanceLog
():
...
...
@@ -232,49 +252,63 @@ def viewInstanceLog():
return
render_template
(
'viewLog.html'
,
type
=
'instance'
,
result
=
result
.
encode
(
"utf-8"
))
@
login_required
()
def
stopAllPartition
():
svcStopAll
(
app
.
config
)
return
redirect
(
url_for
(
'inspectInstance'
))
@
login_required
(
login_redirect
)
def
tailProcess
(
process
):
return
render_template
(
'processTail.html'
,
process_log
=
getSvcTailProcess
(
app
.
config
,
process
),
process
=
process
)
@
login_required
(
login_redirect
)
def
startStopProccess
(
process
,
action
):
svcStartStopProcess
(
app
.
config
,
process
,
action
)
return
redirect
(
url_for
(
'inspectInstance'
))
@
login_required
(
login_redirect
)
def
openProject
(
method
):
return
render_template
(
'projectFolder.html'
,
method
=
method
,
workDir
=
'workspace'
)
@
login_required
()
def
cloneRepository
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'name'
],
False
)
data
=
{
"repo"
:
request
.
form
[
'repo'
],
"user"
:
request
.
form
[
'user'
],
"email"
:
request
.
form
[
'email'
],
"path"
:
path
}
data
=
{
'repo'
:
request
.
form
[
'repo'
],
'user'
:
request
.
form
[
'user'
],
'email'
:
request
.
form
[
'email'
],
'path'
:
path
}
return
cloneRepo
(
data
)
@
login_required
()
def
readFolder
():
return
getFolderContent
(
app
.
config
,
request
.
form
[
'dir'
])
@
login_required
()
def
openFolder
():
return
getFolder
(
app
.
config
,
request
.
form
[
'dir'
])
@
login_required
()
def
createSoftware
():
return
newSoftware
(
request
.
form
[
'folder'
],
app
.
config
,
session
)
@
login_required
()
def
checkFolder
():
return
checkSoftwareFolder
(
request
.
form
[
'path'
],
app
.
config
)
@
login_required
()
def
setCurrentProject
():
if
configNewSR
(
app
.
config
,
request
.
form
[
'path'
]):
...
...
@@ -283,11 +317,13 @@ def setCurrentProject():
else
:
return
jsonify
(
code
=
0
,
result
=
(
"Can not setup this Software Release"
))
@
login_required
()
def
manageProject
():
return
render_template
(
'manageProject.html'
,
workDir
=
'workspace'
,
project
=
getProjectList
(
app
.
config
[
'workspace'
]))
@
login_required
()
def
getProjectStatus
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'project'
])
...
...
@@ -296,6 +332,7 @@ def getProjectStatus():
else
:
return
jsonify
(
code
=
0
,
result
=
"Can not read folder: Permission Denied"
)
#view for current software release files
@
login_required
()
def
editCurrentProject
():
...
...
@@ -306,12 +343,13 @@ def editCurrentProject():
projectList
=
getProjectList
(
app
.
config
[
'workspace'
]))
return
redirect
(
url_for
(
'configRepo'
))
#create file or directory
@
login_required
()
def
createFile
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'file'
],
False
)
if
not
path
:
return
jsonify
(
code
=
0
,
result
=
"Error when creating your "
+
\
return
jsonify
(
code
=
0
,
result
=
"Error when creating your "
+
request
.
form
[
'type'
]
+
": Permission Denied"
)
try
:
if
request
.
form
[
'type'
]
==
"file"
:
...
...
@@ -322,6 +360,7 @@ def createFile():
except
Exception
as
e
:
return
jsonify
(
code
=
0
,
result
=
str
(
e
))
#remove file or directory
@
login_required
()
def
removeFile
():
...
...
@@ -334,6 +373,7 @@ def removeFile():
except
Exception
as
e
:
return
jsonify
(
code
=
0
,
result
=
str
(
e
))
@
login_required
()
def
removeSoftwareDir
():
try
:
...
...
@@ -343,6 +383,7 @@ def removeSoftwareDir():
except
Exception
as
e
:
return
jsonify
(
code
=
0
,
result
=
str
(
e
))
#read file and return content to ajax
@
login_required
()
def
getFileContent
():
...
...
@@ -351,14 +392,15 @@ def getFileContent():
if
not
isText
(
file_path
):
return
jsonify
(
code
=
0
,
result
=
"Can not open a binary file, please select a text file!"
)
if
not
request
.
form
.
has_key
(
'truncate'
):
return
jsonify
(
code
=
1
,
result
=
open
(
file_path
,
'r'
).
read
())
else
:
if
'truncate'
in
request
.
form
:
content
=
tail
(
open
(
file_path
,
'r'
),
int
(
request
.
form
[
'truncate'
]))
return
jsonify
(
code
=
1
,
result
=
content
)
else
:
return
jsonify
(
code
=
1
,
result
=
open
(
file_path
,
'r'
).
read
())
else
:
return
jsonify
(
code
=
0
,
result
=
"Error: No such file!"
)
@
login_required
()
def
saveFileContent
():
file_path
=
realpath
(
app
.
config
,
request
.
form
[
'file'
])
...
...
@@ -368,6 +410,7 @@ def saveFileContent():
else
:
return
jsonify
(
code
=
0
,
result
=
"Error: No such file!"
)
@
login_required
()
def
changeBranch
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'project'
])
...
...
@@ -376,6 +419,7 @@ def changeBranch():
else
:
return
jsonify
(
code
=
0
,
result
=
"Can not read folder: Permission Denied"
)
@
login_required
()
def
newBranch
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'project'
])
...
...
@@ -387,12 +431,14 @@ def newBranch():
else
:
return
jsonify
(
code
=
0
,
result
=
"Can not read folder: Permission Denied"
)
@
login_required
(
login_redirect
)
def
getProjectDiff
(
project
):
path
=
os
.
path
.
join
(
app
.
config
[
'workspace'
],
project
)
return
render_template
(
'projectDiff.html'
,
project
=
project
,
diff
=
getDiff
(
path
))
@
login_required
()
def
pushProjectFiles
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'project'
])
...
...
@@ -401,6 +447,7 @@ def pushProjectFiles():
else
:
return
jsonify
(
code
=
0
,
result
=
"Can not read folder: Permission Denied"
)
@
login_required
()
def
pullProjectFiles
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'project'
])
...
...
@@ -409,6 +456,7 @@ def pullProjectFiles():
else
:
return
jsonify
(
code
=
0
,
result
=
"Can not read folder: Permission Denied"
)
@
login_required
()
def
checkFileType
():
path
=
realpath
(
app
.
config
,
request
.
form
[
'path'
])
...
...
@@ -420,6 +468,7 @@ def checkFileType():
return
jsonify
(
code
=
0
,
result
=
"Can not open a binary file, please select a text file!"
)
@
login_required
()
def
getmd5sum
():
realfile
=
realpath
(
app
.
config
,
request
.
form
[
'file'
])
...
...
@@ -431,13 +480,18 @@ def getmd5sum():
else
:
return
jsonify
(
code
=
0
,
result
=
"Can not get md5sum for this file!"
)
#return information about state of slapgrid process
@
login_required
()
def
slapgridResult
():
software_state
=
isSoftwareRunning
(
app
.
config
)
instance_state
=
isInstanceRunning
(
app
.
config
)
log_result
=
{
"content"
:
""
,
"position"
:
0
,
"truncated"
:
False
}
if
request
.
form
[
'log'
]
==
"software"
or
\
log_result
=
{
'content'
:
''
,
'position'
:
0
,
'truncated'
:
False
}
if
request
.
form
[
'log'
]
==
"software"
or
\
request
.
form
[
'log'
]
==
"instance"
:
log_file
=
request
.
form
[
'log'
]
+
"_log"
if
os
.
path
.
exists
(
app
.
config
[
log_file
]):
...
...
@@ -446,11 +500,13 @@ def slapgridResult():
return
jsonify
(
software
=
software_state
,
instance
=
instance_state
,
result
=
(
instance_state
or
software_state
),
content
=
log_result
)
@
login_required
()
def
stopSlapgrid
():
result
=
killRunningProcess
(
request
.
form
[
'type'
])
return
jsonify
(
result
=
result
)
@
login_required
()
def
getPath
():
files
=
request
.
form
[
'file'
].
split
(
'#'
)
...
...
@@ -469,6 +525,7 @@ def getPath():
else
:
return
jsonify
(
code
=
1
,
result
=
realfile
)
@
login_required
()
def
saveParameterXml
():
"""
...
...
@@ -500,6 +557,7 @@ def saveParameterXml():
result
=
"An error occurred while applying your settings!<br/>"
+
str
(
e
))
return
jsonify
(
code
=
1
,
result
=
""
)
@
login_required
()
def
getSoftwareType
():
software_type_path
=
os
.
path
.
join
(
app
.
config
[
'etc_dir'
],
".software_type.xml"
)
...
...
@@ -507,6 +565,7 @@ def getSoftwareType():
return
jsonify
(
code
=
1
,
result
=
open
(
software_type_path
,
'r'
).
read
())
return
jsonify
(
code
=
1
,
result
=
"default"
)
#read instance parameters into the local xml file and return a dict
@
login_required
()
def
getParameterXml
(
request
):
...
...
@@ -636,6 +695,7 @@ def fileBrowser():
return
str
(
e
)
return
result
@
login_required
()
def
editFile
():
return
render_template
(
'editFile.html'
,
workDir
=
'workspace'
,
...
...
@@ -643,6 +703,7 @@ def editFile():
projectList
=
getProjectList
(
app
.
config
[
'workspace'
]),
filename
=
urllib
.
unquote
(
request
.
args
.
get
(
'filename'
,
''
)))
#Setup List of URLs
app
.
add_url_rule
(
'/'
,
'home'
,
home
)
app
.
add_url_rule
(
'/browseWorkspace'
,
'browseWorkspace'
,
browseWorkspace
)
...
...
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