Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
slapos
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
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Kristopher Ruzic
slapos
Commits
bc357712
Commit
bc357712
authored
Apr 03, 2015
by
Alain Takoudjou
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 're6st-master' into master
parents
23d31649
484c0931
Changes
14
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
1189 additions
and
2 deletions
+1189
-2
component/re6stnet/buildout.cfg
component/re6stnet/buildout.cfg
+3
-2
setup.py
setup.py
+1
-0
slapos/recipe/re6stnet/__init__.py
slapos/recipe/re6stnet/__init__.py
+220
-0
slapos/recipe/re6stnet/re6stnet.py
slapos/recipe/re6stnet/re6stnet.py
+183
-0
slapos/test/recipe/test_re6stnet.py
slapos/test/recipe/test_re6stnet.py
+184
-0
software/re6stnet/apache.conf.in
software/re6stnet/apache.conf.in
+57
-0
software/re6stnet/instance-logrotate-base.cfg.in
software/re6stnet/instance-logrotate-base.cfg.in
+50
-0
software/re6stnet/instance-re6stnet-input-schema.json
software/re6stnet/instance-re6stnet-input-schema.json
+17
-0
software/re6stnet/instance-re6stnet-output-schema.json
software/re6stnet/instance-re6stnet-output-schema.json
+11
-0
software/re6stnet/instance-re6stnet.cfg.in
software/re6stnet/instance-re6stnet.cfg.in
+214
-0
software/re6stnet/instance.cfg.in
software/re6stnet/instance.cfg.in
+50
-0
software/re6stnet/re6st-registry.conf.in
software/re6stnet/re6st-registry.conf.in
+11
-0
software/re6stnet/software.cfg
software/re6stnet/software.cfg
+167
-0
software/re6stnet/software.cfg.json
software/re6stnet/software.cfg.json
+21
-0
No files found.
component/re6stnet/buildout.cfg
View file @
bc357712
...
@@ -28,8 +28,9 @@ stop-on-error = true
...
@@ -28,8 +28,9 @@ stop-on-error = true
dir = ${re6stnet-repository:location}
dir = ${re6stnet-repository:location}
command =
command =
rm -f "${:dir}/re6stconf.py" && ln -s re6st-conf "${:dir}/re6stconf.py"
rm -f "${:dir}/re6stconf.py" && ln -s re6st-conf "${:dir}/re6stconf.py"
rm -f "${:dir}/re6stregister.py" && ln -s re6st-conf "${:dir}/re6stregister.py"
rm -f "${:dir}/re6stregistry.py" && ln -s re6st-registry "${:dir}/re6stregistry.py"
rm -f "${:dir}/re6stnet.py" && ln -s re6st-conf "${:dir}/re6stnet.py"
rm -f "${:dir}/re6stnet.py" && ln -s re6stnet "${:dir}/re6stnet.py"
sed -i 's#("git",)#("${git:location}/bin/git",)#' ${:dir}/re6st/version.py
update-command = ${:command}
update-command = ${:command}
...
...
setup.py
View file @
bc357712
...
@@ -174,6 +174,7 @@ setup(name=name,
...
@@ -174,6 +174,7 @@ setup(name=name,
'request.serialised = slapos.recipe.request:Serialised'
,
'request.serialised = slapos.recipe.request:Serialised'
,
'request.edge = slapos.recipe.request:RequestEdge'
,
'request.edge = slapos.recipe.request:RequestEdge'
,
'requestoptional = slapos.recipe.request:RequestOptional'
,
'requestoptional = slapos.recipe.request:RequestOptional'
,
're6stnet.registry = slapos.recipe.re6stnet:Recipe'
,
'reverseproxy.nginx = slapos.recipe.reverse_proxy_nginx:Recipe'
,
'reverseproxy.nginx = slapos.recipe.reverse_proxy_nginx:Recipe'
,
'seleniumrunner = slapos.recipe.seleniumrunner:Recipe'
,
'seleniumrunner = slapos.recipe.seleniumrunner:Recipe'
,
'sheepdogtestbed = slapos.recipe.sheepdogtestbed:SheepDogTestBed'
,
'sheepdogtestbed = slapos.recipe.sheepdogtestbed:SheepDogTestBed'
,
...
...
slapos/recipe/re6stnet/__init__.py
0 → 100644
View file @
bc357712
##############################################################################
#
# Copyright (c) 2010 Vifib SARL and Contributors. All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
##############################################################################
import
subprocess
from
slapos.recipe.librecipe
import
GenericBaseRecipe
import
socket
import
struct
import
os
import
string
,
random
import
json
import
traceback
from
slapos
import
slap
class
Recipe
(
GenericBaseRecipe
):
def
__init__
(
self
,
buildout
,
name
,
options
):
"""Default initialisation"""
self
.
slap
=
slap
.
slap
()
# SLAP related information
slap_connection
=
buildout
[
'slap-connection'
]
self
.
computer_id
=
slap_connection
[
'computer-id'
]
self
.
computer_partition_id
=
slap_connection
[
'partition-id'
]
self
.
server_url
=
slap_connection
[
'server-url'
]
self
.
software_release_url
=
slap_connection
[
'software-release-url'
]
self
.
key_file
=
slap_connection
.
get
(
'key-file'
)
self
.
cert_file
=
slap_connection
.
get
(
'cert-file'
)
return
GenericBaseRecipe
.
__init__
(
self
,
buildout
,
name
,
options
)
def
getSerialFromIpv6
(
self
,
ipv6
):
prefix
=
ipv6
.
split
(
'/'
)[
0
].
lower
()
hi
,
lo
=
struct
.
unpack
(
'!QQ'
,
socket
.
inet_pton
(
socket
.
AF_INET6
,
prefix
))
ipv6_int
=
(
hi
<<
64
)
|
lo
serial
=
'0x1%x'
%
ipv6_int
# delete non significant part
for
part
in
prefix
.
split
(
':'
)[::
-
1
]:
if
part
:
for
i
in
[
'0'
]
*
(
4
-
len
(
part
)):
part
=
i
+
part
serial
=
serial
.
split
(
part
)[
0
]
+
part
break
return
serial
def
generateCertificate
(
self
):
key_file
=
self
.
options
[
'key-file'
].
strip
()
cert_file
=
self
.
options
[
'cert-file'
].
strip
()
if
not
os
.
path
.
exists
(
key_file
):
serial
=
self
.
getSerialFromIpv6
(
self
.
options
[
'ipv6-prefix'
].
strip
())
key_command
=
[
self
.
options
[
'openssl-bin'
],
'genrsa'
,
'-out'
,
'%s'
%
key_file
,
self
.
options
[
'key-size'
]]
#'-config', openssl_configuration
cert_command
=
[
self
.
options
[
'openssl-bin'
],
'req'
,
'-nodes'
,
'-new'
,
'-x509'
,
'-batch'
,
'-key'
,
'%s'
%
key_file
,
'-set_serial'
,
'%s'
%
serial
,
'-days'
,
'3650'
,
'-out'
,
'%s'
%
cert_file
]
subprocess
.
check_call
(
key_command
)
subprocess
.
check_call
(
cert_command
)
def
generateSlaveTokenList
(
self
,
slave_instance_list
,
token_file
):
to_remove_dict
=
{}
to_add_dict
=
{}
token_dict
=
self
.
loadJsonFile
(
token_file
)
reference_list
=
[
slave_instance
.
get
(
'slave_reference'
)
for
slave_instance
in
slave_instance_list
]
for
reference
in
reference_list
:
if
not
reference
in
token_dict
:
# we generate new token
number
=
reference
.
split
(
'-'
)[
1
]
new_token
=
number
+
''
.
join
(
random
.
sample
(
string
.
ascii_lowercase
,
15
))
token_dict
[
reference
]
=
new_token
to_add_dict
[
reference
]
=
new_token
for
reference
in
token_dict
.
keys
():
if
not
reference
in
reference_list
:
# This slave instance is destroyed ?
to_remove_dict
[
reference
]
=
token_dict
.
pop
(
reference
)
return
token_dict
,
to_add_dict
,
to_remove_dict
def
loadJsonFile
(
self
,
path
):
if
os
.
path
.
exists
(
path
):
with
open
(
path
,
'r'
)
as
f
:
content
=
f
.
read
()
return
json
.
loads
(
content
)
else
:
return
{}
def
writeFile
(
self
,
path
,
data
):
with
open
(
path
,
'w'
)
as
f
:
f
.
write
(
data
)
return
path
def
readFile
(
self
,
path
):
if
os
.
path
.
exists
(
path
):
with
open
(
path
,
'r'
)
as
f
:
content
=
f
.
read
()
return
content
return
''
def
install
(
self
):
path_list
=
[]
token_save_path
=
os
.
path
.
join
(
self
.
options
[
'conf-dir'
],
'token.json'
)
token_list_path
=
self
.
options
[
'token-dir'
]
self
.
generateCertificate
()
wrapper
=
self
.
createWrapper
(
name
=
self
.
options
[
'wrapper'
],
command
=
self
.
options
[
'command'
],
parameters
=
[
'@%s'
%
self
.
options
[
'config-file'
]])
path_list
.
append
(
wrapper
)
slave_list
=
json
.
loads
(
self
.
options
[
'slave-instance-list'
])
registry_url
=
'http://%s:%s/'
%
(
self
.
options
[
'ipv4'
],
self
.
options
[
'port'
])
token_dict
,
add_token_dict
,
rm_token_dict
=
self
.
generateSlaveTokenList
(
slave_list
,
token_save_path
)
# write request add token
for
reference
in
add_token_dict
:
path
=
os
.
path
.
join
(
token_list_path
,
'%s.add'
%
reference
)
if
not
os
.
path
.
exists
(
path
):
self
.
createFile
(
path
,
add_token_dict
[
reference
])
# write request remove token
for
reference
in
rm_token_dict
:
path
=
os
.
path
.
join
(
token_list_path
,
'%s.remove'
%
reference
)
if
not
os
.
path
.
exists
(
path
):
self
.
createFile
(
path
,
rm_token_dict
[
reference
])
# remove request add file if exists
add_path
=
os
.
path
.
join
(
token_list_path
,
'%s.add'
%
reference
)
if
os
.
path
.
exists
(
add_path
):
os
.
unlink
(
add_path
)
self
.
createFile
(
token_save_path
,
json
.
dumps
(
token_dict
))
service_dict
=
dict
(
token_base_path
=
token_list_path
,
token_json
=
token_save_path
,
db
=
self
.
options
[
'db-path'
],
partition_id
=
self
.
computer_partition_id
,
computer_id
=
self
.
computer_id
,
registry_url
=
registry_url
)
service_dict
[
'server_url'
]
=
self
.
server_url
service_dict
[
'cert_file'
]
=
self
.
cert_file
service_dict
[
'key_file'
]
=
self
.
key_file
request_add
=
self
.
createPythonScript
(
self
.
options
[
'manager-wrapper'
].
strip
(),
'%s.re6stnet.manage'
%
__name__
,
service_dict
)
path_list
.
append
(
request_add
)
request_drop
=
self
.
createPythonScript
(
self
.
options
[
'drop-service-wrapper'
].
strip
(),
'%s.re6stnet.requestRemoveToken'
%
__name__
,
service_dict
)
path_list
.
append
(
request_drop
)
request_check
=
self
.
createPythonScript
(
self
.
options
[
'check-service-wrapper'
].
strip
(),
'%s.re6stnet.checkService'
%
__name__
,
service_dict
)
path_list
.
append
(
request_check
)
# Send connection parameters of slave instances
if
token_dict
:
self
.
slap
.
initializeConnection
(
self
.
server_url
,
self
.
key_file
,
self
.
cert_file
)
computer_partition
=
self
.
slap
.
registerComputerPartition
(
self
.
computer_id
,
self
.
computer_partition_id
)
for
slave_reference
,
token
in
token_dict
.
iteritems
():
try
:
status_file
=
os
.
path
.
join
(
token_list_path
,
'%s.status'
%
slave_reference
)
status
=
self
.
readFile
(
status_file
)
or
'New token requested'
msg
=
status
if
status
==
'TOKEN_ADDED'
:
msg
=
'Token is ready for use'
elif
status
==
'TOKEN_USED'
:
msg
=
'Token not available, it has been used to generate re6stnet certificate.'
computer_partition
.
setConnectionDict
(
{
'token'
:
token
,
'1_info'
:
msg
},
slave_reference
)
except
:
self
.
logger
.
fatal
(
"Error while sending slave %s informations: %s"
,
slave_reference
,
traceback
.
format_exc
())
return
path_list
slapos/recipe/re6stnet/re6stnet.py
0 → 100644
View file @
bc357712
# -*- coding: utf-8 -*-
import
logging
import
json
import
os
import
time
import
sqlite3
import
slapos
from
re6st
import
registry
log
=
logging
.
getLogger
(
'SLAPOS-RE6STNET'
)
logging
.
basicConfig
(
level
=
logging
.
DEBUG
)
def
loadJsonFile
(
path
):
if
os
.
path
.
exists
(
path
):
with
open
(
path
,
'r'
)
as
f
:
content
=
f
.
read
()
return
json
.
loads
(
content
)
else
:
return
{}
def
writeFile
(
path
,
data
):
with
open
(
path
,
'w'
)
as
f
:
f
.
write
(
data
)
def
readFile
(
path
):
if
os
.
path
.
exists
(
path
):
with
open
(
path
,
'r'
)
as
f
:
content
=
f
.
read
()
return
content
return
''
def
getDb
(
db_path
):
db
=
sqlite3
.
connect
(
db_path
,
isolation_level
=
None
,
check_same_thread
=
False
)
db
.
text_factory
=
str
return
db
.
cursor
()
def
bang
(
args
):
computer_guid
=
args
[
'computer_id'
]
partition_id
=
args
[
'partition_id'
]
slap
=
slapos
.
slap
.
slap
()
# Redeploy instance to update published information
slap
.
initializeConnection
(
args
[
'server_url'
],
args
[
'key_file'
],
args
[
'cert_file'
])
partition
=
slap
.
registerComputerPartition
(
computer_guid
=
computer_guid
,
partition_id
=
partition_id
)
partition
.
bang
(
message
=
'Published parameters changed!'
)
log
.
info
(
"Bang with message 'parameters changed'..."
)
def
requestAddToken
(
args
,
can_bang
=
True
):
time
.
sleep
(
3
)
registry_url
=
args
[
'registry_url'
]
base_token_path
=
args
[
'token_base_path'
]
path_list
=
[
x
for
x
in
os
.
listdir
(
base_token_path
)
if
x
.
endswith
(
'.add'
)]
if
not
path_list
:
log
.
info
(
"No new token to add. Exiting..."
)
return
client
=
registry
.
RegistryClient
(
registry_url
)
call_bang
=
False
for
reference_key
in
path_list
:
request_file
=
os
.
path
.
join
(
base_token_path
,
reference_key
)
token
=
readFile
(
request_file
)
if
token
:
reference
=
reference_key
.
split
(
'.'
)[
0
]
email
=
'%s@slapos'
%
reference
.
lower
()
try
:
result
=
client
.
requestAddToken
(
token
,
email
)
except
Exception
,
e
:
log
.
debug
(
'Request add token fail for %s...
\
n
%s'
%
(
request_file
,
str
(
e
)))
continue
if
result
and
result
==
token
:
# update information
log
.
info
(
"New token added for slave instance %s. Updating file status..."
%
reference
)
writeFile
(
os
.
path
.
join
(
base_token_path
,
'%s.status'
%
reference
),
'TOKEN_ADDED'
)
os
.
unlink
(
request_file
)
call_bang
=
True
else
:
log
.
debug
(
'Bad token. Request add token fail for %s...'
%
request_file
)
if
can_bang
and
call_bang
:
bang
(
args
)
def
requestRemoveToken
(
args
):
base_token_path
=
args
[
'token_base_path'
]
path_list
=
[
x
for
x
in
os
.
listdir
(
base_token_path
)
if
x
.
endswith
(
'.remove'
)]
if
not
path_list
:
log
.
info
(
"No token to delete. Exiting..."
)
return
client
=
registry
.
RegistryClient
(
args
[
'registry_url'
])
for
reference_key
in
path_list
:
request_file
=
os
.
path
.
join
(
base_token_path
,
reference_key
)
token
=
readFile
(
request_file
)
if
token
:
reference
=
reference_key
.
split
(
'.'
)[
0
]
try
:
result
=
client
.
requestDeleteToken
(
token
)
except
Exception
,
e
:
log
.
debug
(
'Request delete token fail for %s...
\
n
%s'
%
(
request_file
,
str
(
e
)))
continue
if
result
==
'True'
:
# update information
log
.
info
(
"Token deleted for slave instance %s. Clean up file status..."
%
reference
)
os
.
unlink
(
request_file
)
status_file
=
os
.
path
.
join
(
base_token_path
,
'%s.status'
%
reference
)
if
os
.
path
.
exists
(
status_file
):
os
.
unlink
(
status_file
)
else
:
log
.
debug
(
'Request delete token fail for %s...'
%
request_file
)
else
:
log
.
debug
(
'Bad token. Request add token fail for %s...'
%
request_file
)
def
checkService
(
args
,
can_bang
=
True
):
base_token_path
=
args
[
'token_base_path'
]
token_dict
=
loadJsonFile
(
args
[
'token_json'
])
if
not
token_dict
:
return
db
=
getDb
(
args
[
'db'
])
call_bang
=
False
computer_guid
=
args
[
'computer_id'
]
partition_id
=
args
[
'partition_id'
]
slap
=
slapos
.
slap
.
slap
()
# Check token status
for
slave_reference
,
token
in
token_dict
.
iteritems
():
status_file
=
os
.
path
.
join
(
base_token_path
,
'%s.status'
%
slave_reference
)
if
not
os
.
path
.
exists
(
status_file
):
# This token is not added yet!
continue
msg
=
readFile
(
status_file
)
if
msg
==
'TOKEN_USED'
:
continue
# Check if token is not in the database
status
=
False
try
:
token_found
,
=
db
.
execute
(
"SELECT token FROM token WHERE token = ?"
,
(
token
,)).
next
()
if
token_found
==
token
:
status
=
True
except
StopIteration
:
pass
if
not
status
:
# Token is used to register client
call_bang
=
True
try
:
time
.
sleep
(
1
)
writeFile
(
status_file
,
'TOKEN_USED'
)
log
.
info
(
"Token status of %s updated to 'used'."
%
slave_reference
)
except
IOError
,
e
:
# XXX- this file should always exists
log
.
debug
(
'Error when writing in file %s. Clould not update status of %s...'
%
(
status_file
,
slave_reference
))
if
call_bang
and
can_bang
:
bang
(
args
)
def
manage
(
args
):
# Request Add new tokens
requestAddToken
(
args
)
# Request delete removed token
requestRemoveToken
(
args
)
# check status of all token
checkService
(
args
)
slapos/test/recipe/test_re6stnet.py
0 → 100644
View file @
bc357712
import
os
,
time
import
shutil
import
sys
import
tempfile
import
unittest
from
slapos.slap.slap
import
NotFoundError
from
slapos.recipe
import
re6stnet
class
Re6stnetTest
(
unittest
.
TestCase
):
def
setUp
(
self
):
self
.
ssl_dir
=
tempfile
.
mkdtemp
()
self
.
conf_dir
=
tempfile
.
mkdtemp
()
self
.
base_dir
=
tempfile
.
mkdtemp
()
self
.
token_dir
=
tempfile
.
mkdtemp
()
self
.
dir_list
=
[
self
.
ssl_dir
,
self
.
conf_dir
,
self
.
base_dir
,
self
.
token_dir
]
config_file
=
os
.
path
.
join
(
self
.
base_dir
,
'config'
)
with
open
(
config_file
,
'w'
)
as
f
:
f
.
write
(
'port 9201'
)
self
.
options
=
options
=
{
'openssl-bin'
:
'openssl'
,
'key-file'
:
os
.
path
.
join
(
self
.
ssl_dir
,
'cert.key'
),
'cert-file'
:
os
.
path
.
join
(
self
.
ssl_dir
,
'cert.crt'
),
'key-size'
:
'2048'
,
'conf-dir'
:
self
.
conf_dir
,
'token-dir'
:
self
.
token_dir
,
'wrapper'
:
os
.
path
.
join
(
self
.
base_dir
,
'wrapper'
),
'config-file'
:
config_file
,
'ipv4'
:
'127.0.0.1'
,
'port'
:
'9201'
,
'db-path'
:
'/path/to/db'
,
'command'
:
'/path/to/command'
,
'manager-wrapper'
:
os
.
path
.
join
(
self
.
base_dir
,
'manager_wrapper'
),
'drop-service-wrapper'
:
os
.
path
.
join
(
self
.
base_dir
,
'drop_wrapper'
),
'check-service-wrapper'
:
os
.
path
.
join
(
self
.
base_dir
,
'check_wrapper'
),
'slave-instance-list'
:
'{}'
}
def
tearDown
(
self
):
for
path
in
self
.
dir_list
:
if
os
.
path
.
exists
(
path
):
shutil
.
rmtree
(
path
)
def
new_recipe
(
self
):
buildout
=
{
'buildout'
:
{
'bin-directory'
:
''
,
'find-links'
:
''
,
'allow-hosts'
:
''
,
'develop-eggs-directory'
:
''
,
'eggs-directory'
:
''
,
'python'
:
'testpython'
,
},
'testpython'
:
{
'executable'
:
sys
.
executable
,
},
'slap-connection'
:
{
'computer-id'
:
''
,
'partition-id'
:
''
,
'server-url'
:
''
,
'software-release-url'
:
''
,
}
}
options
=
self
.
options
return
re6stnet
.
Recipe
(
buildout
=
buildout
,
name
=
're6stnet'
,
options
=
options
)
def
test_generateCertificates
(
self
):
self
.
options
[
'ipv6-prefix'
]
=
'2001:db8:24::/48'
self
.
options
[
'key-size'
]
=
'2048'
recipe
=
self
.
new_recipe
()
recipe
.
generateCertificate
()
self
.
assertTrue
(
os
.
path
.
exists
(
self
.
options
[
'key-file'
]))
self
.
assertTrue
(
os
.
path
.
exists
(
self
.
options
[
'cert-file'
]))
last_time
=
time
.
ctime
(
os
.
stat
(
self
.
options
[
'key-file'
])[
7
])
recipe
.
generateCertificate
()
self
.
assertTrue
(
os
.
path
.
exists
(
self
.
options
[
'key-file'
]))
this_time
=
time
.
ctime
(
os
.
stat
(
self
.
options
[
'key-file'
])[
7
])
self
.
assertEqual
(
last_time
,
this_time
)
def
test_generateCertificates_other_ipv6
(
self
):
self
.
options
[
'ipv6-prefix'
]
=
'be28:db8:fe6a:d85:4fe:54a:ae:aea/64'
recipe
=
self
.
new_recipe
()
recipe
.
generateCertificate
()
self
.
assertTrue
(
os
.
path
.
exists
(
self
.
options
[
'key-file'
]))
self
.
assertTrue
(
os
.
path
.
exists
(
self
.
options
[
'cert-file'
]))
def
test_install
(
self
):
recipe
=
self
.
new_recipe
()
recipe
.
options
.
update
({
'ipv6-prefix'
:
'2001:db8:24::/48'
,
'slave-instance-list'
:
'''[
{"slave_reference":"SOFTINST-58770"},
{"slave_reference":"SOFTINST-58778"}
]
'''
})
try
:
recipe
.
install
()
except
NotFoundError
:
# Recipe will raise not found error when trying to publish slave informations
pass
self
.
assertItemsEqual
(
os
.
listdir
(
self
.
ssl_dir
),
[
'cert.key'
,
'cert.crt'
])
token_file
=
os
.
path
.
join
(
self
.
options
[
'conf-dir'
],
'token.json'
)
self
.
assertTrue
(
os
.
path
.
exists
(
token_file
))
# token file must contain 2 elements
token_content
=
recipe
.
readFile
(
token_file
)
self
.
assertIn
(
'SOFTINST-58770'
,
token_content
)
self
.
assertIn
(
'SOFTINST-58778'
,
token_content
)
token_dict
=
recipe
.
loadJsonFile
(
token_file
)
self
.
assertEqual
(
len
(
token_dict
),
2
)
self
.
assertTrue
(
token_dict
.
has_key
(
'SOFTINST-58770'
))
self
.
assertTrue
(
token_dict
.
has_key
(
'SOFTINST-58778'
))
self
.
assertItemsEqual
(
os
.
listdir
(
self
.
token_dir
),
[
'SOFTINST-58770.add'
,
'SOFTINST-58778.add'
])
first_add
=
recipe
.
readFile
(
os
.
path
.
join
(
self
.
token_dir
,
'SOFTINST-58770.add'
))
self
.
assertEqual
(
token_dict
[
'SOFTINST-58770'
],
first_add
)
second_add
=
recipe
.
readFile
(
os
.
path
.
join
(
self
.
token_dir
,
'SOFTINST-58778.add'
))
self
.
assertEqual
(
token_dict
[
'SOFTINST-58778'
],
second_add
)
# Remove one element
recipe
.
options
.
update
({
"slave-instance-list"
:
"""[{"slave_reference":"SOFTINST-58770"}]"""
})
try
:
recipe
.
install
()
except
NotFoundError
:
# Recipe will raise not found error when trying to publish slave informations
pass
token_dict
=
recipe
.
loadJsonFile
(
token_file
)
self
.
assertEqual
(
len
(
token_dict
),
1
)
self
.
assertEqual
(
token_dict
[
'SOFTINST-58770'
],
first_add
)
self
.
assertItemsEqual
(
os
.
listdir
(
self
.
token_dir
),
[
'SOFTINST-58770.add'
,
'SOFTINST-58778.remove'
])
second_remove
=
recipe
.
readFile
(
os
.
path
.
join
(
self
.
token_dir
,
'SOFTINST-58778.remove'
))
self
.
assertEqual
(
second_add
,
second_remove
)
def
test_install_empty_slave
(
self
):
recipe
=
self
.
new_recipe
()
recipe
.
options
.
update
({
'ipv6-prefix'
:
'2001:db8:24::/48'
})
recipe
.
install
()
self
.
assertItemsEqual
(
os
.
listdir
(
self
.
ssl_dir
),
[
'cert.key'
,
'cert.crt'
])
token_file
=
os
.
path
.
join
(
self
.
options
[
'conf-dir'
],
'token.json'
)
self
.
assertTrue
(
os
.
path
.
exists
(
token_file
))
token_content
=
recipe
.
readFile
(
token_file
)
self
.
assertEqual
(
token_content
,
'{}'
)
self
.
assertItemsEqual
(
os
.
listdir
(
self
.
options
[
'token-dir'
]),
[])
software/re6stnet/apache.conf.in
0 → 100644
View file @
bc357712
LoadModule unixd_module modules/mod_unixd.so
LoadModule access_compat_module modules/mod_access_compat.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule authz_host_module modules/mod_authz_host.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule setenvif_module modules/mod_setenvif.so
LoadModule version_module modules/mod_version.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule socache_shmcb_module modules/mod_socache_shmcb.so
LoadModule ssl_module modules/mod_ssl.so
LoadModule mime_module modules/mod_mime.so
#LoadModule dav_module modules/mod_dav.so
#LoadModule dav_fs_module modules/mod_dav_fs.so
LoadModule negotiation_module modules/mod_negotiation.so
LoadModule rewrite_module modules/mod_rewrite.so
LoadModule headers_module modules/mod_headers.so
PidFile "{{ pid_file }}"
ServerAdmin admin@
TypesConfig conf/mime.types
AddType application/x-compress .Z
AddType application/x-gzip .gz .tgz
ServerTokens Prod
ServerSignature Off
TraceEnable Off
ErrorLog "{{ error_log }}"
# Default apache log format with request time in microsecond at the end
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %D" combined
CustomLog "{{ access_log }}" combined
{% if uri_scheme == 'https' -%}
# SSL Configuration
SSLCertificateFile {{ certificate }}
SSLCertificateKeyFile {{ key }}
SSLRandomSeed startup builtin
SSLRandomSeed connect builtin
SSLProtocol ALL -SSLv2
{% endif -%}
<Directory />
Options FollowSymLinks
AllowOverride None
Allow from all
</Directory>
Listen {{ ipv6 }}:{{ apache_port }}
<VirtualHost *:{{ apache_port }}>
{% if uri_scheme == 'https' -%}
SSLEngine On
SSLProxyEngine On
{% endif -%}
ProxyPass / {{ uri_scheme }}://{{ re6st_ipv4 }}:{{ re6st_port }}/
</VirtualHost>
\ No newline at end of file
software/re6stnet/instance-logrotate-base.cfg.in
0 → 100644
View file @
bc357712
[buildout]
parts =
cron-entry-logrotate
[cron]
recipe = slapos.cookbook:cron
cron-entries = ${logrotate-directory:cron-entries}
dcrond-binary = {{ dcron_location }}/sbin/crond
crontabs = ${logrotate-directory:crontabs}
cronstamps = ${logrotate-directory:cronstamps}
catcher = ${cron-simplelogger:wrapper}
binary = ${logrotate-directory:services}/crond
[cron-simplelogger]
recipe = slapos.cookbook:simplelogger
wrapper = ${logrotate-directory:bin}/cron_simplelogger
log = ${logrotate-directory:log}/cron.log
[logrotate]
recipe = slapos.cookbook:logrotate
logrotate-entries = ${logrotate-directory:logrotate-entries}
backup = ${logrotate-directory:logrotate-backup}
logrotate-binary = {{ logrotate_location }}/usr/sbin/logrotate
gzip-binary = {{ gzip_location }}/bin/gzip
gunzip-binary = {{ gzip_location }}/bin/gunzip
wrapper = ${logrotate-directory:bin}/logrotate
conf = ${logrotate-directory:etc}/logrotate.conf
state-file = ${logrotate-directory:srv}/logrotate.status
[cron-entry-logrotate]
recipe = slapos.cookbook:cron.d
cron-entries = ${cron:cron-entries}
name = logrotate
frequency = 0 0 * * *
command = ${logrotate:wrapper}
[logrotate-directory]
recipe = slapos.cookbook:mkdirectory
cron-entries = ${:etc}/cron.d
cronstamps = ${:etc}/cronstamps
crontabs = ${:etc}/crontabs
logrotate-backup = ${:backup}/logrotate
logrotate-entries = ${:etc}/logrotate.d
bin = ${buildout:directory}/bin
srv = ${buildout:directory}/srv
backup = ${:srv}/backup
etc = ${buildout:directory}/etc
services = ${:etc}/service
log = ${buildout:directory}/var/log
software/re6stnet/instance-re6stnet-input-schema.json
0 → 100644
View file @
bc357712
{
"$schema"
:
"http://json-schema.org/draft-04/schema#"
,
"properties"
:
{
"ipv6-prefix"
:
{
"title"
:
"Ipv6 prefix to use to setup the new re6st network"
,
"description"
:
"Prefix ipv6 used by re6st to setup network. It is something like 2001:db8:42::/48"
,
"type"
:
"string"
},
"key-size"
:
{
"title"
:
"Number of bit to use for certificate generation"
,
"description"
:
"Specify the size of certificate generated by re6st. by default, generate 2048-bit key length"
,
"type"
:
"integer"
,
"minimum"
:
1024
,
"default"
:
2048
}
}
}
\ No newline at end of file
software/re6stnet/instance-re6stnet-output-schema.json
0 → 100644
View file @
bc357712
{
"$schema"
:
"http://json-schema.org/draft-04/schema#"
,
"description"
:
"Values returned by Re6st Master instanciation"
,
"properties"
:
{
"re6stry-url"
:
{
"description"
:
"ipv6 url to access your re6st registry service"
,
"type"
:
"string"
}
},
"type"
:
"object"
}
\ No newline at end of file
software/re6stnet/instance-re6stnet.cfg.in
0 → 100644
View file @
bc357712
{% set python_bin = parameter_dict['python-executable'] -%}
{% set re6st_registry = parameter_dict['re6st-registry'] -%}
{% set publish_dict = {} -%}
{% set part_list = [] -%}
{% set ipv6 = (ipv6_set | list)[0] -%}
{% set ipv4 = (ipv4_set | list)[0] -%}
{% set uri_scheme = slapparameter_dict.get('uri-scheme', 'http') -%}
{% macro section(name) %}{% do part_list.append(name) %}{{ name }}{% endmacro -%}
[directory]
recipe = slapos.cookbook:mkdirectory
bin = ${buildout:directory}/bin
etc = ${buildout:directory}/etc
srv = ${buildout:directory}/srv
var = ${buildout:directory}/var
log = ${:var}/log
services = ${:etc}/service
script = ${:etc}/run
promises = ${:etc}/promise
run = ${:var}/run
ca-dir = ${:etc}/ssl
requests = ${:ca-dir}/requests
private = ${:ca-dir}/private
certs = ${:ca-dir}/certs
newcerts = ${:ca-dir}/newcerts
crl = ${:ca-dir}/crl
re6st = ${:srv}/res6stnet
[re6stnet-dirs]
recipe = slapos.cookbook:mkdirectory
registry = ${directory:re6st}/registry
log = ${directory:log}/re6stnet
conf = ${directory:etc}/re6stnet
ssl = ${:conf}/ssl
token = ${:conf}/token
[certificate-authority]
recipe = slapos.cookbook:certificate_authority
openssl-binary = {{ openssl_bin }}/openssl
ca-dir = ${directory:ca-dir}
requests-directory = ${directory:requests}
wrapper = ${directory:services}/certificate_authority
ca-private = ${directory:private}
ca-certs = ${directory:certs}
ca-newcerts = ${directory:newcerts}
ca-crl = ${directory:crl}
[apache-conf]
recipe = slapos.recipe.template:jinja2
template = {{ parameter_dict['template-apache-conf'] }}
rendered = ${directory:etc}/apache.conf
ipv6 = {{ ipv6 }}
port = 9026
error-log = ${directory:log}/apache-error.log
access-log = ${directory:log}/apache-access.log
pid-file = ${directory:run}/apache.pid
context =
key apache_port :port
key re6st_ipv4 re6st-registry:ipv4
key re6st_port re6st-registry:port
key access_log :access-log
key error_log :error-log
key pid_file :pid-file
raw certificate ${directory:certs}/apache.crt
raw key ${directory:private}/apache.key
raw ipv6 {{ ipv6 }}
raw uri_scheme {{ uri_scheme }}
{% set apache_wrapper = '${directory:services}/httpd' -%}
{% if uri_scheme == 'https' -%}
{% set apache_wrapper = '${directory:bin}/httpd_raw' -%}
{% endif -%}
[apache-httpd]
recipe = slapos.cookbook:wrapper
wrapper-path = {{ apache_wrapper }}
command-line = "{{ parameter_dict['apache-location'] }}/bin/httpd" -f "${apache-conf:rendered}" -DFOREGROUND
{% if uri_scheme == 'https' %}
[{{ section('apache-ca') }}]
<= certificate-authority
recipe = slapos.cookbook:certificate_authority.request
executable = ${apache-httpd:wrapper-path}
wrapper = ${directory:services}/httpd
key-file = ${certificate-authority:ca-private}/apache.key
cert-file = ${certificate-authority:ca-certs}/apache.crt
{% endif %}
[logrotate-apache]
< = logrotate-entry-base
name = apache
log = ${apache-conf:error-log} ${apache-conf:access-log}
post = {{ parameter_dict['bin-directory'] }}/slapos-kill --pidfile ${apache-conf:pid-file} -s USR1
[logrotate-entry-base]
recipe = slapos.cookbook:logrotate.d
logrotate-entries = ${logrotate:logrotate-entries}
backup = ${logrotate:backup}
[re6st-registry-conf-dict]
port = 9201
ipv4 = {{ ipv4 }}
ipv6 = {{ ipv6 }}
db = ${re6stnet-dirs:registry}/registry.db
ca = ${re6stnet-dirs:ssl}/re6stnet.crt
key = ${re6stnet-dirs:ssl}/re6stnet.key
mailhost = 127.0.0.1
prefix-length = 16
anonymous-prefix-length = 32
logfile = ${re6stnet-dirs:log}/registry.log
verbose = 2
[re6st-registry-conf]
recipe = slapos.recipe.template:jinja2
template = {{ parameter_dict['template-re6st-registry-conf'] }}
rendered = ${directory:etc}/re6st-registry.conf
context = section parameter_dict re6st-registry-conf-dict
[re6st-registry]
recipe = slapos.cookbook:re6stnet.registry
port = ${re6st-registry-conf-dict:port}
ipv4 = ${re6st-registry-conf-dict:ipv4}
command = {{ re6st_registry }}
config-file = ${re6st-registry-conf:rendered}
db-path = ${re6st-registry-conf-dict:db}
wrapper = ${directory:services}/re6st-registry
manager-wrapper = ${directory:bin}/re6stManageToken
check-service-wrapper = ${directory:bin}/re6stCheckService
drop-service-wrapper = ${directory:bin}/re6stManageDeleteToken
key-file = ${re6st-registry-conf-dict:key}
cert-file = ${re6st-registry-conf-dict:ca}
openssl-bin = {{ openssl_bin }}/openssl
python-bin = {{ python_bin }}
ipv6-prefix = {{ slapparameter_dict.get('ipv6-prefix', '2001:db8:24::/48') }}
key-size = {{ slapparameter_dict.get('key-size', 2048) }}
conf-dir = ${re6stnet-dirs:conf}
token-dir = ${re6stnet-dirs:token}
slave-instance-list = ${slap-parameter:slave_instance_list}
environment =
PATH={{ openssl_bin }}
[re6stnet-manage]
recipe = slapos.cookbook:wrapper
wrapper-path = ${directory:script}/re6st-token-manager
command-line = "{{ python_bin }}" ${re6st-registry:manager-wrapper}
[cron-entry-re6st-check]
recipe = slapos.cookbook:cron.d
cron-entries = ${cron:cron-entries}
name = re6stnet-check-token
frequency = 0 */1 * * *
command = {{ python_bin }} ${re6st-registry:check-service-wrapper}
[cron-entry-re6st-drop]
recipe = slapos.cookbook:cron.d
cron-entries = ${cron:cron-entries}
name = re6stnet-drop-token
frequency = */30 * * * *
command = {{ python_bin }} ${re6st-registry:drop-service-wrapper}
[logrotate-entry-re6stnet]
< = logrotate-entry-base
name = re6stnet
log = ${re6st-registry-conf-dict:logfile}
[re6st-registry-promise]
recipe = slapos.cookbook:check_port_listening
path = ${directory:promises}/re6st-registry
hostname = ${re6st-registry:ipv4}
port = ${re6st-registry:port}
[apache-registry-promise]
recipe = slapos.cookbook:check_port_listening
path = ${directory:promises}/apache-re6st-registry
hostname = ${apache-conf:ipv6}
port = ${apache-conf:port}
{% do publish_dict.__setitem__('re6stry-url', uri_scheme ~ '://[${apache-conf:ipv6}]:${apache-conf:port}') -%}
[publish]
recipe = slapos.cookbook:publish
{% for name, value in publish_dict.items() -%}
{{ name }} = {{ value }}
{% endfor -%}
[buildout]
extends =
{{ logrotate_cfg }}
parts =
certificate-authority
logrotate-apache
logrotate-entry-re6stnet
re6stnet-manage
cron-entry-logrotate
cron-entry-re6st-check
cron-entry-re6st-drop
apache-httpd
publish
re6st-registry-promise
apache-registry-promise
# Complete parts with sections
{{ part_list | join('\n ') }}
eggs-directory = {{ eggs_directory }}
develop-eggs-directory = {{ develop_eggs_directory }}
offline = true
[slap-parameter]
slave_instance_list = {}
software/re6stnet/instance.cfg.in
0 → 100644
View file @
bc357712
[buildout]
parts = switch-softwaretype
eggs-directory = {{ eggs_directory }}
develop-eggs-directory = {{ develop_eggs_directory }}
[slap-configuration]
recipe = slapos.cookbook:slapconfiguration.serialised
computer = ${slap-connection:computer-id}
partition = ${slap-connection:partition-id}
url = ${slap-connection:server-url}
key = ${slap-connection:key-file}
cert = ${slap-connection:cert-file}
[jinja2-template-base]
recipe = slapos.recipe.template:jinja2
rendered = ${buildout:parts-directory}/${:_buildout_section_name_}/${:filename}
extra-context =
context =
key develop_eggs_directory buildout:develop-eggs-directory
key eggs_directory buildout:eggs-directory
key ipv6_set slap-configuration:ipv6
key ipv4_set slap-configuration:ipv4
key slapparameter_dict slap-configuration:configuration
key computer_id slap-configuration:computer
raw logrotate_cfg {{ template_logrotate_base }}
raw dash_binary {{ dash_location }}/bin/dash
raw openssl_bin {{ openssl_location}}/bin
${:extra-context}
[dynamic-template-re6stnet-parameters]
bin-directory = {{ bin_directory }}
python-executable = {{ python_with_eggs }}
re6st-registry = {{ re6stnet_registry }}
template-apache-conf = {{ template_apache_conf }}
apache-location = {{ apache_location }}
template-re6st-registry-conf = {{ template_re6st_registry_conf }}
[dynamic-template-re6stnet]
< = jinja2-template-base
template = {{ template_re6stnet }}
filename = instance-re6stnet.cfg
extensions = jinja2.ext.do
extra-context =
section parameter_dict dynamic-template-re6stnet-parameters
[switch-softwaretype]
recipe = slapos.cookbook:softwaretype
default = ${dynamic-template-re6stnet:rendered}
registry = ${:default}
software/re6stnet/re6st-registry.conf.in
0 → 100644
View file @
bc357712
port {{ parameter_dict['port'] }}
4 {{ parameter_dict['ipv4'] }}
6 {{ parameter_dict['ipv6'] }}
db {{ parameter_dict['db'] }}
ca {{ parameter_dict['ca'] }}
key {{ parameter_dict['key'] }}
mailhost {{ parameter_dict['mailhost'] }}
prefix-length {{ parameter_dict['prefix-length'] }}
anonymous-prefix-length {{ parameter_dict['anonymous-prefix-length'] }}
logfile {{ parameter_dict['logfile'] }}
verbose {{ parameter_dict['verbose'] }}
\ No newline at end of file
software/re6stnet/software.cfg
0 → 100644
View file @
bc357712
[buildout]
extends =
../../component/re6stnet/buildout.cfg
../../component/dash/buildout.cfg
../../component/git/buildout.cfg
../../component/dcron/buildout.cfg
../../component/gzip/buildout.cfg
../../component/openssl/buildout.cfg
../../component/logrotate/buildout.cfg
../../component/apache/buildout.cfg
../../stack/slapos.cfg
develop =
${:parts-directory}/re6stnet-repository
${:parts-directory}/slapos.cookbook-repository
parts =
slapos-cookbook
eggs
dash
babeld
re6stnet-develop
re6stnet
template
slapos.cookbook-repository
check-recipe
[eggs]
recipe = zc.recipe.egg
eggs =
${lxml-python:egg}
slapos.toolbox
scripts =
slapos-kill
[extra-eggs]
recipe = zc.recipe.egg
interpreter = pythonwitheggs
eggs =
${lxml-python:egg}
${python-cffi:egg}
${python-cryptography:egg}
pyOpenSSL
miniupnpc
re6stnet
[re6stnet-repository]
repository = http://git.erp5.org/repos/re6stnet.git
branch = re6st-slapos
[slapos.cookbook-repository]
recipe = slapos.recipe.build:gitclone
repository = http://git.erp5.org/repos/slapos.git
branch = re6st-master
git-executable = ${git:location}/bin/git
[download-base]
recipe = slapos.recipe.build:download
url = ${:_profile_base_location_}/${:filename}
mode = 644
[template-jinja2-base]
recipe = slapos.recipe.template:jinja2
template = ${:_profile_base_location_}/${:filename}.in
rendered = ${buildout:directory}/${:filename}
# XXX: extra-context is needed because we cannot append to a key of an extended
# section.
extra-context =
context =
key bin_directory buildout:bin-directory
key develop_eggs_directory buildout:develop-eggs-directory
key eggs_directory buildout:eggs-directory
${:extra-context}
[template]
< = template-jinja2-base
filename = template.cfg
template = ${:_profile_base_location_}/instance.cfg.in
md5sum = 0929cf851c4883bcb5c69fc2f918eaeb
extra-context =
key apache_location apache:location
key dash_location dash:location
key logrotate_location logrotate:location
key openssl_location openssl:location
key template_apache_conf template-apache-conf:target
key template_re6stnet template-re6stnet:target
key template_re6st_registry_conf template-re6st-registry-conf:target
key template_logrotate_base template-logrotate-base:rendered
raw python_with_eggs ${buildout:directory}/bin/${extra-eggs:interpreter}
raw re6stnet_registry ${buildout:directory}/bin/re6st-registry
[template-re6stnet]
< = download-base
filename = instance-re6stnet.cfg.in
md5sum = e088fb05ea6e1ceff8a5ac00fd28bd75
[template-logrotate-base]
< = template-jinja2-base
filename = instance-logrotate-base.cfg
md5sum = f28fbd310944f321ccb34b2a34c82005
extra-context =
key dcron_location dcron:location
key gzip_location gzip:location
key logrotate_location logrotate:location
[template-apache-conf]
< = download-base
filename = apache.conf.in
md5sum = c220229ee37866c8cc404d602edd389d
[template-re6st-registry-conf]
< = download-base
filename = re6st-registry.conf.in
md5sum = ae910e8e154be6575bb19f6eae686a87
[check-recipe]
recipe = plone.recipe.command
stop-on-error = true
update-command = ${:command}
command =
grep parts ${buildout:develop-eggs-directory}/re6stnet.egg-link
grep parts ${buildout:develop-eggs-directory}/slapos.cookbook.egg-link
[versions]
apache-libcloud = 0.17.0
ecdsa = 0.13
gitdb = 0.6.4
plone.recipe.command = 1.1
pycrypto = 2.6.1
slapos.recipe.template = 2.6
slapos.toolbox = 0.47.3
smmap = 0.9.0
# Required by:
# slapos.toolbox==0.47.3
GitPython = 0.3.6
# Required by:
# slapos.toolbox==0.47.3
atomize = 0.2.0
# Required by:
# apache-libcloud==0.17.0
backports.ssl-match-hostname = 3.4.0.2
# Required by:
# slapos.toolbox==0.47.3
feedparser = 5.1.3
# Required by:
# slapos.toolbox==0.47.3
lockfile = 0.10.2
# Required by:
# re6stnet===0-413.gbec6b3c.dirty
miniupnpc = 1.9
# Required by:
# slapos.toolbox==0.47.3
paramiko = 1.15.2
# Required by:
# slapos.toolbox==0.47.3
rpdb = 0.1.5
software/re6stnet/software.cfg.json
0 → 100644
View file @
bc357712
{
"name"
:
"RE6STNET"
,
"description"
:
"Master instance of re6st (Resilient, Scalable, IPv6 Network application)"
,
"serialisation"
:
"xml"
,
"software-type"
:
{
"default"
:
{
"title"
:
"Default"
,
"description"
:
"Re6st registry"
,
"request"
:
"instance-re6stnet-input-schema.json"
,
"response"
:
"instance-re6stnet-output-schema.json"
,
"index"
:
0
},
"registry"
:
{
"title"
:
"registry"
,
"description"
:
"Re6st registry"
,
"request"
:
"instance-re6stnet-resilient-input-schema.json"
,
"response"
:
"instance-re6stnet-output-schema.json"
,
"index"
:
1
}
}
}
\ No newline at end of file
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