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
5
Merge Requests
5
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
Jérome Perrin
slapos
Commits
4198868e
Commit
4198868e
authored
Apr 09, 2015
by
Alain Takoudjou
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 're6st-master'
parents
f72fe31b
6323707e
Changes
7
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
132 additions
and
48 deletions
+132
-48
slapos/recipe/re6stnet/__init__.py
slapos/recipe/re6stnet/__init__.py
+25
-3
slapos/recipe/re6stnet/re6stnet.py
slapos/recipe/re6stnet/re6stnet.py
+49
-9
slapos/test/recipe/test_re6stnet.py
slapos/test/recipe/test_re6stnet.py
+30
-24
software/re6stnet/instance-re6stnet.cfg.in
software/re6stnet/instance-re6stnet.cfg.in
+21
-6
software/re6stnet/instance.cfg.in
software/re6stnet/instance.cfg.in
+2
-1
software/re6stnet/re6st-registry.conf.in
software/re6stnet/re6st-registry.conf.in
+2
-1
software/re6stnet/software.cfg
software/re6stnet/software.cfg
+3
-4
No files found.
slapos/recipe/re6stnet/__init__.py
View file @
4198868e
...
...
@@ -71,9 +71,13 @@ class Recipe(GenericBaseRecipe):
def
generateCertificate
(
self
):
key_file
=
self
.
options
[
'key-file'
].
strip
()
cert_file
=
self
.
options
[
'cert-file'
].
strip
()
dh_file
=
self
.
options
[
'dh-file'
].
strip
()
if
not
os
.
path
.
exists
(
key_file
):
serial
=
self
.
getSerialFromIpv6
(
self
.
options
[
'ipv6-prefix'
].
strip
())
dh_command
=
[
self
.
options
[
'openssl-bin'
],
'dhparam'
,
'-out'
,
'%s'
%
dh_file
,
self
.
options
[
'key-size'
]]
key_command
=
[
self
.
options
[
'openssl-bin'
],
'genrsa'
,
'-out'
,
'%s'
%
key_file
,
self
.
options
[
'key-size'
]]
...
...
@@ -82,6 +86,7 @@ class Recipe(GenericBaseRecipe):
'-x509'
,
'-batch'
,
'-key'
,
'%s'
%
key_file
,
'-set_serial'
,
'%s'
%
serial
,
'-days'
,
'3650'
,
'-out'
,
'%s'
%
cert_file
]
subprocess
.
check_call
(
dh_command
)
subprocess
.
check_call
(
key_command
)
subprocess
.
check_call
(
cert_command
)
...
...
@@ -96,7 +101,7 @@ class Recipe(GenericBaseRecipe):
if
not
reference
in
token_dict
:
# we generate new token
number
=
reference
.
split
(
'-'
)[
1
]
new_token
=
number
+
''
.
join
(
random
.
sample
(
string
.
ascii_lowercase
,
15
))
new_token
=
number
+
''
.
join
(
random
.
sample
(
string
.
ascii_lowercase
,
20
))
token_dict
[
reference
]
=
new_token
to_add_dict
[
reference
]
=
new_token
...
...
@@ -127,6 +132,17 @@ class Recipe(GenericBaseRecipe):
return
content
return
''
def
genHash
(
self
,
length
):
hash_path
=
os
.
path
.
join
(
self
.
options
[
'conf-dir'
],
'%s-hash'
%
length
)
if
not
os
.
path
.
exists
(
hash_path
):
pool
=
string
.
letters
+
string
.
digits
hash_string
=
''
.
join
(
random
.
choice
(
pool
)
for
i
in
xrange
(
length
))
self
.
writeFile
(
hash_path
,
hash_string
)
else
:
hash_string
=
self
.
readFile
(
hash_path
)
return
hash_string
def
install
(
self
):
path_list
=
[]
token_save_path
=
os
.
path
.
join
(
self
.
options
[
'conf-dir'
],
'token.json'
)
...
...
@@ -156,7 +172,7 @@ class Recipe(GenericBaseRecipe):
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
# remove request add
token
if exists
add_path
=
os
.
path
.
join
(
token_list_path
,
'%s.add'
%
reference
)
if
os
.
path
.
exists
(
add_path
):
os
.
unlink
(
add_path
)
...
...
@@ -191,6 +207,12 @@ class Recipe(GenericBaseRecipe):
)
path_list
.
append
(
request_check
)
revoke_check
=
self
.
createPythonScript
(
self
.
options
[
'revoke-service-wrapper'
].
strip
(),
'%s.re6stnet.requestRevoqueCertificate'
%
__name__
,
service_dict
)
path_list
.
append
(
revoke_check
)
# Send connection parameters of slave instances
if
token_dict
:
self
.
slap
.
initializeConnection
(
self
.
server_url
,
self
.
key_file
,
...
...
@@ -211,7 +233,7 @@ class Recipe(GenericBaseRecipe):
computer_partition
.
setConnectionDict
(
{
'token'
:
token
,
'1_info'
:
msg
},
slave_reference
)
except
:
except
Exception
:
self
.
logger
.
fatal
(
"Error while sending slave %s informations: %s"
,
slave_reference
,
traceback
.
format_exc
())
...
...
slapos/recipe/re6stnet/re6stnet.py
View file @
4198868e
...
...
@@ -5,8 +5,10 @@ import os
import
time
import
sqlite3
import
slapos
import
traceback
from
re6st
import
registry
from
re6st
import
registry
,
x509
from
OpenSSL
import
crypto
log
=
logging
.
getLogger
(
'SLAPOS-RE6STNET'
)
logging
.
basicConfig
(
level
=
logging
.
DEBUG
)
...
...
@@ -50,6 +52,7 @@ def bang(args):
partition
.
bang
(
message
=
'Published parameters changed!'
)
log
.
info
(
"Bang with message 'parameters changed'..."
)
def
requestAddToken
(
args
,
can_bang
=
True
):
time
.
sleep
(
3
)
...
...
@@ -69,12 +72,13 @@ def requestAddToken(args, can_bang=True):
token
=
readFile
(
request_file
)
if
token
:
reference
=
reference_key
.
split
(
'.'
)[
0
]
# email is unique as reference is also unique
email
=
'%s@slapos'
%
reference
.
lower
()
try
:
result
=
client
.
requestAddToken
(
token
,
email
)
except
Exception
,
e
:
except
Exception
:
log
.
debug
(
'Request add token fail for %s...
\
n
%s'
%
(
request_file
,
str
(
e
)))
traceback
.
format_exc
(
)))
continue
if
result
and
result
==
token
:
# update information
...
...
@@ -97,7 +101,7 @@ def requestRemoveToken(args):
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
)
...
...
@@ -106,23 +110,58 @@ def requestRemoveToken(args):
reference
=
reference_key
.
split
(
'.'
)[
0
]
try
:
result
=
client
.
requestDeleteToken
(
token
)
except
Exception
,
e
:
except
Exception
:
log
.
debug
(
'Request delete token fail for %s...
\
n
%s'
%
(
request_file
,
str
(
e
)))
traceback
.
format_exc
(
)))
continue
else
:
# certificate is invalidated, it will be revoked
writeFile
(
os
.
path
.
join
(
base_token_path
,
'%s.revoke'
%
reference
),
''
)
if
result
==
'True'
:
# update information
log
.
info
(
"Token deleted for slave instance %s. Clean up file status..."
%
reference
)
if
result
in
[
'True'
,
'False'
]:
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
requestRevoqueCertificate
(
args
):
base_token_path
=
args
[
'token_base_path'
]
db
=
getDb
(
args
[
'db'
])
path_list
=
[
x
for
x
in
os
.
listdir
(
base_token_path
)
if
x
.
endswith
(
'.revoke'
)]
client
=
registry
.
RegistryClient
(
args
[
'registry_url'
])
for
reference_key
in
path_list
:
reference
=
reference_key
.
split
(
'.'
)[
0
]
# XXX - email is always unique
email
=
'%s@slapos'
%
reference
.
lower
()
cert_string
=
''
try
:
cert_string
,
=
db
.
execute
(
"SELECT cert FROM cert WHERE email = ?"
,
(
email
,)).
next
()
except
StopIteration
:
# Certificate was not generated yet !!!
pass
try
:
if
cert_string
:
cert
=
crypto
.
load_certificate
(
crypto
.
FILETYPE_PEM
,
cert_string
)
cn
=
x509
.
subnetFromCert
(
cert
)
result
=
client
.
revoke
(
str
(
cn
))
time
.
sleep
(
2
)
except
Exception
:
log
.
debug
(
'Request revoke certificate fail for %s...
\
n
%s'
%
(
reference
,
traceback
.
format_exc
()))
continue
else
:
os
.
unlink
(
os
.
path
.
join
(
base_token_path
,
reference_key
))
log
.
info
(
"Certificate revoked for slave instance %s."
%
reference
)
def
checkService
(
args
,
can_bang
=
True
):
base_token_path
=
args
[
'token_base_path'
]
token_dict
=
loadJsonFile
(
args
[
'token_json'
])
...
...
@@ -164,7 +203,7 @@ def checkService(args, can_bang=True):
time
.
sleep
(
1
)
writeFile
(
status_file
,
'TOKEN_USED'
)
log
.
info
(
"Token status of %s updated to 'used'."
%
slave_reference
)
except
IOError
,
e
:
except
IOError
:
# XXX- this file should always exists
log
.
debug
(
'Error when writing in file %s. Clould not update status of %s...'
%
(
status_file
,
slave_reference
))
...
...
@@ -181,3 +220,4 @@ def manage(args):
# check status of all token
checkService
(
args
)
slapos/test/recipe/test_re6stnet.py
View file @
4198868e
...
...
@@ -24,6 +24,7 @@ class Re6stnetTest(unittest.TestCase):
'openssl-bin'
:
'/usr/bin/openssl'
,
'key-file'
:
os
.
path
.
join
(
self
.
ssl_dir
,
'cert.key'
),
'cert-file'
:
os
.
path
.
join
(
self
.
ssl_dir
,
'cert.crt'
),
'dh-file'
:
os
.
path
.
join
(
self
.
ssl_dir
,
'dh.pem'
),
'key-size'
:
'2048'
,
'conf-dir'
:
self
.
conf_dir
,
'token-dir'
:
self
.
token_dir
,
...
...
@@ -36,6 +37,7 @@ class Re6stnetTest(unittest.TestCase):
'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'
),
'revoke-service-wrapper'
:
os
.
path
.
join
(
self
.
base_dir
,
'revoke_wrapper'
),
'slave-instance-list'
:
'{}'
}
...
...
@@ -70,7 +72,7 @@ class Re6stnetTest(unittest.TestCase):
options
=
self
.
options
return
re6stnet
.
Recipe
(
buildout
=
buildout
,
name
=
're6stnet'
,
options
=
options
)
def
checkWrapper
(
self
,
path
):
self
.
assertTrue
(
os
.
path
.
exists
(
path
))
content
=
""
...
...
@@ -96,7 +98,10 @@ class Re6stnetTest(unittest.TestCase):
with
open
(
path
,
'r'
)
as
f
:
content
=
f
.
read
()
self
.
assertIn
(
"@%s"
%
config_file
,
content
)
def
fake_generateCertificates
(
self
):
return
def
test_generateCertificates
(
self
):
self
.
options
[
'ipv6-prefix'
]
=
'2001:db8:24::/48'
...
...
@@ -106,31 +111,35 @@ class Re6stnetTest(unittest.TestCase):
recipe
.
generateCertificate
()
self
.
assert
True
(
os
.
path
.
exists
(
self
.
options
[
'key-file'
]))
self
.
assertTrue
(
os
.
path
.
exists
(
self
.
options
[
'cert-file'
])
)
self
.
assert
ItemsEqual
(
os
.
listdir
(
self
.
ssl_dir
),
[
'cert.key'
,
'cert.crt'
,
'dh.pem'
]
)
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_ge
nerateCertificates_other_i
pv6
(
self
):
self
.
options
[
'ipv6-prefix'
]
=
'be28:db8:fe6a:d85:4fe:54a:ae:aea/64'
def
test_ge
tSerialFromI
pv6
(
self
):
ipv6
=
'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'
]))
serial
=
recipe
.
getSerialFromIpv6
(
ipv6
)
self
.
assertEqual
(
serial
,
'0x1be280db8fe6a0d8504fe054a00ae0aea'
)
ipv6
=
'2001:db8:24::/48'
serial
=
recipe
.
getSerialFromIpv6
(
ipv6
)
self
.
assertEqual
(
serial
,
'0x120010db80024'
)
def
test_install
(
self
):
recipe
=
self
.
new_recipe
()
recipe
.
generateCertificate
=
self
.
fake_generateCertificates
recipe
.
options
.
update
({
'ipv6-prefix'
:
'2001:db8:24::/48'
,
...
...
@@ -147,9 +156,6 @@ class Re6stnetTest(unittest.TestCase):
# 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
))
...
...
@@ -175,6 +181,7 @@ class Re6stnetTest(unittest.TestCase):
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'manager_wrapper'
))
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'drop_wrapper'
))
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'check_wrapper'
))
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'revoke_wrapper'
))
self
.
checkRegistryWrapper
()
# Remove one element
...
...
@@ -198,25 +205,24 @@ class Re6stnetTest(unittest.TestCase):
def
test_install_empty_slave
(
self
):
recipe
=
self
.
new_recipe
()
recipe
.
generateCertificate
=
self
.
fake_generateCertificates
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'
]),
[])
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'manager_wrapper'
))
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'drop_wrapper'
))
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'check_wrapper'
))
self
.
checkWrapper
(
os
.
path
.
join
(
self
.
base_dir
,
'revoke_wrapper'
))
self
.
checkRegistryWrapper
()
software/re6stnet/instance-re6stnet.cfg.in
View file @
4198868e
{% set python_bin = parameter_dict['python-executable'] -%}
{% set re6st_registry = parameter_dict['re6st-registry'] -%}
{% set re6stnet = parameter_dict['re6stnet'] -%}
{% set publish_dict = {} -%}
{% set part_list = [] -%}
{% set ipv6 = (ipv6_set | list)[0] -%}
...
...
@@ -105,6 +106,7 @@ ipv6 = {{ ipv6 }}
db = ${re6stnet-dirs:registry}/registry.db
ca = ${re6stnet-dirs:ssl}/re6stnet.crt
key = ${re6stnet-dirs:ssl}/re6stnet.key
dh = ${re6stnet-dirs:ssl}/dh.pem
mailhost = 127.0.0.1
prefix-length = 16
anonymous-prefix-length = 32
...
...
@@ -119,17 +121,12 @@ 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}
revoke-service-wrapper = ${directory:bin}/re6stRevokeCertificate
openssl-bin = {{ openssl_bin }}/openssl
python-bin = {{ python_bin }}
ipv6-prefix = {{ slapparameter_dict.get('ipv6-prefix', '2001:db8:24::/48') }}
...
...
@@ -137,6 +134,15 @@ key-size = {{ slapparameter_dict.get('key-size', 2048) }}
conf-dir = ${re6stnet-dirs:conf}
token-dir = ${re6stnet-dirs:token}
#Re6st config
config-file = ${re6st-registry-conf:rendered}
port = ${re6st-registry-conf-dict:port}
ipv4 = ${re6st-registry-conf-dict:ipv4}
db-path = ${re6st-registry-conf-dict:db}
key-file = ${re6st-registry-conf-dict:key}
cert-file = ${re6st-registry-conf-dict:ca}
dh-file = ${re6st-registry-conf-dict:dh}
slave-instance-list = ${slap-parameter:slave_instance_list}
environment =
...
...
@@ -154,6 +160,13 @@ name = re6stnet-check-token
frequency = 0 */1 * * *
command = {{ python_bin }} ${re6st-registry:check-service-wrapper}
[cron-entry-re6st-revoke]
recipe = slapos.cookbook:cron.d
cron-entries = ${cron:cron-entries}
name = re6stnet-revoke-cert
frequency = */30 * * * *
command = {{ python_bin }} ${re6st-registry:revoke-service-wrapper}
[cron-entry-re6st-drop]
recipe = slapos.cookbook:cron.d
cron-entries = ${cron:cron-entries}
...
...
@@ -179,6 +192,7 @@ hostname = ${apache-conf:ipv6}
port = ${apache-conf:port}
{% do publish_dict.__setitem__('re6stry-url', uri_scheme ~ '://[${apache-conf:ipv6}]:${apache-conf:port}') -%}
{% do publish_dict.__setitem__('re6stry-local-url', 'http://${re6st-registry:ipv4}:${re6st-registry:port}/') -%}
[publish]
recipe = slapos.cookbook:publish
{% for name, value in publish_dict.items() -%}
...
...
@@ -197,6 +211,7 @@ parts =
cron-entry-logrotate
cron-entry-re6st-check
cron-entry-re6st-drop
cron-entry-re6st-revoke
apache-httpd
publish
...
...
software/re6stnet/instance.cfg.in
View file @
4198868e
...
...
@@ -30,7 +30,8 @@ context =
[dynamic-template-re6stnet-parameters]
bin-directory = {{ bin_directory }}
python-executable = {{ python_with_eggs }}
re6st-registry = {{ re6stnet_registry }}
re6st-registry = {{ bin_directory }}/re6st-registry
re6stnet = {{ bin_directory }}/re6stnet
template-apache-conf = {{ template_apache_conf }}
apache-location = {{ apache_location }}
template-re6st-registry-conf = {{ template_re6st_registry_conf }}
...
...
software/re6stnet/re6st-registry.conf.in
View file @
4198868e
port {{ parameter_dict['port'] }}
4 {{ parameter_dict['ipv4'] }}
6 {{ parameter_dict['ipv6'] }}
#
6 {{ parameter_dict['ipv6'] }}
db {{ parameter_dict['db'] }}
ca {{ parameter_dict['ca'] }}
key {{ parameter_dict['key'] }}
dh {{ parameter_dict['dh'] }}
mailhost {{ parameter_dict['mailhost'] }}
prefix-length {{ parameter_dict['prefix-length'] }}
anonymous-prefix-length {{ parameter_dict['anonymous-prefix-length'] }}
...
...
software/re6stnet/software.cfg
View file @
4198868e
...
...
@@ -78,7 +78,7 @@ context =
< = template-jinja2-base
filename = template.cfg
template = ${:_profile_base_location_}/instance.cfg.in
md5sum =
0929cf851c4883bcb5c69fc2f918eaeb
md5sum =
ded1faad7f289ffe9ac7aeee3d98413e
extra-context =
key apache_location apache:location
key dash_location dash:location
...
...
@@ -89,12 +89,11 @@ extra-context =
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
md5sum =
df2a0c4f63c5e12cbd314cc02fbf23e1
[template-logrotate-base]
< = template-jinja2-base
...
...
@@ -113,7 +112,7 @@ md5sum = c220229ee37866c8cc404d602edd389d
[template-re6st-registry-conf]
< = download-base
filename = re6st-registry.conf.in
md5sum =
ae910e8e154be6575bb19f6eae686a87
md5sum =
7760a213896755e707993d67d8d980bb
[check-recipe]
recipe = plone.recipe.command
...
...
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