Commit d26d1810 authored by Xavier Thompson's avatar Xavier Thompson

Introduce slapconfiguration:jsonschema recipe

See merge request !1638
parents 93374140 c0306e8b
Pipeline #38975 failed with stage
in 0 seconds
......@@ -150,6 +150,7 @@ setup(name=name,
'simplehttpserver = slapos.recipe.simplehttpserver:Recipe',
'slapconfiguration = slapos.recipe.slapconfiguration:Recipe',
'slapconfiguration.serialised = slapos.recipe.slapconfiguration:Serialised',
'slapconfiguration.jsonschema = slapos.recipe.slapconfiguration:JsonSchema',
'slapconfiguration.jsondump = slapos.recipe.slapconfiguration:JsonDump',
'squid = slapos.recipe.squid:Recipe',
'sshkeys_authority = slapos.recipe.sshkeys_authority:Recipe',
......
......@@ -29,13 +29,24 @@ import json
import logging
import os
from collections import defaultdict
from contextlib import contextmanager
import jsonschema
import slapos.slap
from slapos.recipe.librecipe import unwrap
import six
from six.moves.configparser import RawConfigParser
from netaddr import valid_ipv4, valid_ipv6
from slapos.util import mkdir_p
from slapos.util import (
mkdir_p,
SoftwareReleaseSchema,
SoftwareReleaseSerialisation,
SoftwareReleaseSchemaValidationError,
urljoin,
)
from slapos import format as slapformat
from zc.buildout import UserError
logger = logging.getLogger("slapos")
......@@ -52,21 +63,21 @@ class Recipe(object):
For example {"tun": {"ipv4": <addr>}} would be available in buildout as ${instance:tun-ipv4}.
Input:
url
url | server-url
Slap server url.
Example:
${slap-connection:server-url}
key & cert (optional)
key & cert | key-file & cert-file (optional)
Path of files containing key and certificate for secure connection to
slap server.
Example:
${slap-connection:key-file}
${slap-connection:cert-file}
computer
computer | computer-id
Computer identifier.
Example:
${slap-connection:computer-id}
partition
partition | partition-id
Partition identifier.
Example:
${slap-connection:partition-id}
......@@ -129,14 +140,16 @@ class Recipe(object):
2. format.Partition.resource_file - for partition specific details
"""
slap = slapos.slap.slap()
# BBB: or ... (right side) clauses kept for compatibility;
# left-side clauses correspond directly to slap-connection.
slap.initializeConnection(
options['url'],
options.get('key'),
options.get('cert'),
options.get('server-url') or options['url'],
options.get('key-file') or options.get('key'),
options.get('cert-file') or options.get('cert'),
)
computer_partition = slap.registerComputerPartition(
options['computer'],
options['partition'],
options.get('computer-id') or options['computer'],
options.get('partition-id') or options['partition'],
)
parameter_dict = computer_partition.getInstanceParameterDict()
options['instance-state'] = computer_partition.getState()
......@@ -162,7 +175,6 @@ class Recipe(object):
options['root-instance-title'] = parameter_dict.pop('root_instance_title',
'UNKNOWN')
options['instance-guid'] = computer_partition.getInstanceGuid()
ipv4_set = set()
v4_add = ipv4_set.add
ipv6_set = set()
......@@ -185,6 +197,10 @@ class Recipe(object):
v6_add(ip)
# XXX: emit warning on unknown address type ?
# XXX slapproxy is sending 'full_address_list' not 'full_ip_list' (like real slapos master)
# just pop this value for now. Remove this when slapproxy is fixed.
parameter_dict.pop('full_address_list', None)
if 'full_ip_list' in parameter_dict:
for item in parameter_dict.pop('full_ip_list'):
if len(item) == 5:
......@@ -199,6 +215,11 @@ class Recipe(object):
if valid_ipv4(network):
route_net_add(network)
# validate the parameters (only when using JsonSchema recipe)
# after popping the custom values sent by slapos master
# but before adding the value from .slapos-resources file
parameter_dict = self._validateParameterDict(options,parameter_dict)
options['ipv4'] = ipv4_set
options['ipv6'] = ipv6_set
......@@ -247,6 +268,9 @@ class Recipe(object):
logger.debug(str(options))
return self._expandParameterDict(options, parameter_dict)
def _validateParameterDict(self, options, parameter_dict):
return parameter_dict
def _expandParameterDict(self, options, parameter_dict):
options['configuration'] = parameter_dict
return parameter_dict
......@@ -261,6 +285,188 @@ class Serialised(Recipe):
else:
return {}
class BasicValidator(object):
def __init__(self, schema):
self.schema = schema
self.validator = jsonschema.validators.validator_for(schema)(schema)
def validate(self, instance):
for error in self.validator.iter_errors(instance):
yield error
class DefaultValidator(object):
def __init__(self, schema):
self.schema = schema
self.validatorfor = v = jsonschema.validators.validator_for(schema)
# Retain original properties validator
validate_properties = v.VALIDATORS["properties"]
# Define new properties validator
def collect_defaults(validator, properties, instance, schema):
# Call original properties validator
error = False
for e in validate_properties(validator, properties, instance, schema):
error = True
yield e
# Collect defaults if the instance validates this schema
if not error:
for key, subschema in properties.items():
if "default" in subschema:
try:
_, defaults = self.defaults[id(instance)]
except KeyError:
defaults = defaultdict(dict)
self.defaults[id(instance)] = instance, defaults
defaults[key][id(subschema)] = subschema["default"]
# Extend validator class with extended properties validator
kls = jsonschema.validators.extend(v, {"properties" : collect_defaults})
self.validator = kls(schema)
@contextmanager
def propagate(self):
# Workaround https://github.com/python-jsonschema/jsonschema/issues/994
# This only works if all $ref schemas have the same $schema as the first.
version = self.schema.get("$schema")
try:
if version is not None:
jsonschema.validators.validates(version)(type(self.validator))
yield
finally:
if version is not None:
jsonschema.validators.validates(version)(self.validatorfor)
def validate(self, instance):
# Initialise default collection
self.defaults = {}
# Validate instance
invalid = False
with self.propagate():
for error in self.validator.iter_errors(instance):
invalid = True
yield error
# Stop there in case of validation errors
if invalid:
return
# Apply collected defaults
for data, defaults in self.defaults.values():
for key, defaultdict in defaults.items():
if key not in data:
it = iter(defaultdict.values())
default = next(it)
if any(d != default for d in it):
raise UserError(
"Conflicting defaults for key %s: %r" % (key, defaultlist))
data[key] = default
# Validate the updated instance
for error in self.validatorfor(self.schema).iter_errors(instance):
yield error
class JsonSchema(Recipe):
"""
Input:
jsonschema
JSON Schema for the SR.
All instance schemas must be available at the advertised relative paths.
Example:
${buildout:directory}/software.cfg.json
set-default
Enum to control adding defaults specified by the JSON schema
to both/neither/either-of main and shared instance parameters.
Accepted values: all|main|shared|none.
Default value: none.
Example:
shared
validate-parameters
Enum to control validating instance parameters
for both/neither/either-of main and shared instances.
Accepted values: all|main|shared|none.
Example:
shared
"""
def _schema(self, options):
path = options['jsonschema']
# because SoftwareReleaseSchema accepts only file:// paths
path = path if path.startswith('file://') else 'file://' + path
# because SoftwareReleaseSchema expects the SR url and adds .json
path = path[:-5] if path.endswith('.json') else path
return SoftwareReleaseSchema(path, options['slap-software-type'])
def _getSharedSchema(self, software_schema):
t = software_schema.software_type
software_json_dict = software_schema.getSoftwareSchema()
for type_dict in software_json_dict['software-type'].values():
if type_dict['software-type'] == t and type_dict.get('shared') == True:
url = urljoin(software_schema.software_url, type_dict['request'])
return software_schema._readAsJson(url, True)
def _parseParameterDict(self, software_schema, parameter_dict):
instance_schema = software_schema.getInstanceRequestParameterSchema()
instance = parameter_dict if isinstance(parameter_dict, dict) else {}
validator = self.Validator(instance_schema)
errors = list(validator.validate(instance))
if errors:
err = SoftwareReleaseSchemaValidationError(errors).format_error(indent=2)
msg = "Invalid parameters:\n" + err
raise UserError(msg)
return instance
def _parseSharedParameterDict(self, software_schema, options):
shared_list = options.pop('slave-instance-list')
if not shared_list:
return
shared_schema = self._getSharedSchema(software_schema)
validator = self.SharedValidator(shared_schema)
valid, invalid = [], []
for instance in shared_list:
reference = instance.pop('slave_reference')
try:
errors = list(validator.validate(instance))
except UserError as e:
errors = list(e.args)
shared_item = {'reference': reference, 'parameters': instance}
if errors:
shared_item['errors'] = errors
invalid.append(shared_item)
else:
valid.append(shared_item)
options['valid-shared-instance-list'] = valid
options['invalid-shared-instance-list'] = invalid
def _parseOption(self, options, key, default):
value = options.get(key, default)
accepted = ('none', 'main', 'shared', 'all')
try:
index = accepted.index(value)
except ValueError:
raise UserError(
"%r is not a valid value for option %r"
"Accepted values are %r" % (value, key, accepted)
)
# return: value in ('main', 'all'), value in ('shared', 'all')
return index & 1, index & 2
def _validateParameterDict(self, options, parameter_dict):
set_main, set_shared = self._parseOption(options, 'set-default', 'none')
validate_tuple = self._parseOption(options, 'validate-parameters', 'all')
validate_main, validate_shared = validate_tuple
self.Validator = DefaultValidator if set_main else BasicValidator
self.SharedValidator = DefaultValidator if set_shared else BasicValidator
software_schema = self._schema(options)
serialisation = software_schema.getSerialisation(strict=True)
if serialisation == SoftwareReleaseSerialisation.JsonInXml:
parameter_dict = unwrap(parameter_dict)
if validate_shared:
self._parseSharedParameterDict(software_schema, options)
if validate_main:
parameter_dict = self._parseParameterDict(software_schema, parameter_dict)
options['configuration'] = parameter_dict
if validate_main or isinstance(parameter_dict, dict):
return parameter_dict
return {}
class JsonDump(Recipe):
def __init__(self, buildout, name, options):
parameter_dict = self.fetch_parameter_dict(options)
......
# coding: utf-8
import copy
import json
import mock
import os
import shutil
import unittest
import tempfile
from collections import defaultdict
......@@ -32,17 +34,16 @@ class SlapConfigurationTest(unittest.TestCase):
"directory": self.instance_root
}
}
self.software_root = tempfile.mkdtemp()
def tearDown(self):
os.unlink(self.resource_file)
os.rmdir(self.instance_root)
shutil.rmtree(self.software_root)
@mock.patch("slapos.slap.slap")
def test_correct_naming(self, MockClient):
def test_correct_naming(self, _):
"""Test correct naming of variables from resource file."""
MockClient.initializeConnection.return_value = None
MockClient.getInstanceParameterDict.return_value = dict()
options = defaultdict(str)
recipe = slapconfiguration.Recipe(self.buildout, "slapconfiguration", options)
......@@ -52,3 +53,393 @@ class SlapConfigurationTest(unittest.TestCase):
self.assertEqual(options['address-list'], [10, 20],
"All underscores should be replaced with -")
def writeJsonSchema(self, serialisation='json-in-xml', valid_defaults=True):
self.software_json_file = os.path.join(self.software_root, 'software.cfg.json')
software_schema = {
"name": "Test",
"serialisation": serialisation,
"software-type": {
"default": {
"title": "Default",
"software-type": "default",
"request": "instance-default-input.json",
"index": 0,
},
"default/*": {
"title": "Default",
"shared": True,
"software-type": "default",
"request": "shared-default-input.json",
"index": 0,
},
}
}
with open(self.software_json_file, 'w') as f:
json.dump(software_schema, f)
self.instance_json_file = os.path.join(self.software_root, 'instance-default-input.json')
properties = {
"letter": {
"type": "string",
"enum": ["a", "b", "c"],
"default": "a" if valid_defaults else 1,
},
"number": {
"type": "integer",
},
}
default_dict = {}
without_default = []
for key, obj in properties.items():
default = obj.get('default')
if default is None:
without_default.append(key)
else:
default_dict[key] = default
self.default_dict = default_dict
instance_schema = {
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$defs": {
"instance-parameters": {
"type": "object",
"properties": properties,
"required": without_default,
},
},
"unevaluatedProperties": False,
"allOf": [{"$ref": "#/$defs/instance-parameters" },]
}
with open(self.instance_json_file, 'w') as f:
json.dump(instance_schema, f)
self.shared_json_file = os.path.join(self.software_root, 'shared-default-input.json')
shared_schema = {
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"oneOf": [
{
"$ref": "shared-1-default-input.json"
},
{
"$ref": "shared-2-default-input.json"
},
]
}
with open(self.shared_json_file, 'w') as f:
json.dump(shared_schema, f)
self.shared_1_json_file = os.path.join(self.software_root, 'shared-1-default-input.json')
shared_1_schema = {
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"properties": {
"kind": {
"const": 1,
},
"thing": {
"type": "string",
"default": "hello" if valid_defaults else 1,
},
},
"required": ["kind"],
}
with open(self.shared_1_json_file, 'w') as f:
json.dump(shared_1_schema, f)
self.shared_2_json_file = os.path.join(self.software_root, 'shared-2-default-input.json')
shared_2_schema = {
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"properties": {
"kind": {
"const": 2,
},
"thing": {
"type": "integer",
"default": 42 if valid_defaults else "forty-two",
},
"required": ["kind"],
}
}
with open(self.shared_2_json_file, 'w') as f:
json.dump(shared_2_schema, f)
def patchSlap(self, parameters, serialise, shared=None, software_type='default'):
shared = copy.deepcopy(shared) if shared else []
d = {'_': json.dumps(parameters)} if serialise else copy.deepcopy(parameters)
for i, s in enumerate(shared):
s['slave_reference'] = 'SHARED%s' % i
slap = mock.MagicMock()
slap_object = slap.return_value
slap_object.initializeConnection.return_value = None
computer_partition = slap_object.registerComputerPartition.return_value
computer_partition.getInstanceParameterDict.return_value = d
d['ip_list'] = []
d['slap_software_type'] = software_type
d['slave_instance_list'] = shared
return mock.patch("slapos.slap.slap", slap)
def runJsonSchemaRecipe(self, options=()):
options = defaultdict(str, options)
options['jsonschema'] = self.software_json_file
slapconfiguration.JsonSchema(self.buildout, "slapconfiguration", options)
return options
def receiveParameters(self, options=(('set-default', 'all'),)):
options = self.runJsonSchemaRecipe(options)
return options['configuration']
def receiveSharedParameters(self, options=(('set-default', 'all'),)):
options = self.runJsonSchemaRecipe(options)
self.assertNotIn('slave-instance-list', options)
valid = options['valid-shared-instance-list']
invalid = options['invalid-shared-instance-list']
valid = {d['reference']: d['parameters'] for d in valid}
invalid = {d['reference']: d['parameters'] for d in invalid}
return valid, invalid
def checkParametersWithDefaults(self, received_parameters, sent_parameters):
expected_dict = dict(self.default_dict)
expected_dict.update(sent_parameters)
self.assertEqual(received_parameters, expected_dict)
def test_jsonschema_json_in_xml_valid_xml_input_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
with self.patchSlap(parameters, False):
received = self.receiveParameters()
self.checkParametersWithDefaults(received, parameters)
def test_jsonschema_json_in_xml_valid_json_input_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
with self.patchSlap(parameters, True):
received = self.receiveParameters()
self.checkParametersWithDefaults(received, parameters)
def test_jsonschema_json_in_xml_valid_input_without_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
with self.patchSlap(parameters, True):
received = self.receiveParameters(options=())
self.assertEqual(received, parameters)
def test_jsonschema_json_in_xml_valid_input_with_only_shared_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
with self.patchSlap(parameters, True):
received = self.receiveParameters({'set-default': 'shared'})
self.assertEqual(received, parameters)
def test_jsonschema_json_in_xml_valid_input_with_only_main_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
with self.patchSlap(parameters, True):
received = self.receiveParameters({'set-default': 'main'})
self.checkParametersWithDefaults(received, parameters)
def test_jsonschema_json_in_xml_valid_xml_input_full(self):
self.writeJsonSchema()
parameters = {"letter": "b", "number": 1}
with self.patchSlap(parameters, False):
received = self.receiveParameters()
self.checkParametersWithDefaults(received, parameters)
def test_jsonschema_json_in_xml_valid_json_input_full(self):
self.writeJsonSchema()
parameters = {"letter": "b", "number": 1}
with self.patchSlap(parameters, True):
received = self.receiveParameters()
self.checkParametersWithDefaults(received, parameters)
def test_jsonschema_json_in_xml_wrong_type_xml_input(self):
self.writeJsonSchema()
parameters = {"number": "1"}
with self.patchSlap(parameters, False):
self.assertRaises(
slapconfiguration.UserError,
self.receiveParameters,
)
def test_jsonschema_json_in_xml_wrong_type_json_input(self):
self.writeJsonSchema()
parameters = {"number": "1"}
with self.patchSlap(parameters, True):
self.assertRaises(
slapconfiguration.UserError,
self.receiveParameters,
)
def test_jsonschema_json_in_xml_wrong_type_json_input_skip_main(self):
self.writeJsonSchema()
parameters = {"number": "1"}
with self.patchSlap(parameters, True):
received = self.receiveParameters({'validate-parameters': 'shared'})
self.assertEqual(received, parameters)
def test_jsonschema_json_in_xml_wrong_type_json_input_skip_shared(self):
self.writeJsonSchema()
parameters = {"number": "1"}
with self.patchSlap(parameters, True):
self.assertRaises(
slapconfiguration.UserError,
self.receiveParameters,
{'validate-parameters': 'main'},
)
def test_jsonschema_json_in_xml_incomplete_xml_input(self):
self.writeJsonSchema()
parameters = {}
with self.patchSlap(parameters, False):
self.assertRaises(
slapconfiguration.UserError,
self.receiveParameters,
)
def test_jsonschema_json_in_xml_incomplete_json_input(self):
self.writeJsonSchema()
parameters = {}
with self.patchSlap(parameters, True):
self.assertRaises(
slapconfiguration.UserError,
self.receiveParameters,
)
def test_jsonschema_json_in_xml_invalid_defaults_json_input(self):
self.writeJsonSchema(valid_defaults=False)
parameters = {"number": 1}
with self.patchSlap(parameters, True):
self.assertRaises(
slapconfiguration.UserError,
self.receiveParameters,
)
def test_jsonschema_shared_1_valid_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 1}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(invalid, {})
self.assertEqual(list(valid.values()), [{"kind": 1, "thing": "hello"}])
def test_jsonschema_shared_2_valid_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 2}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(invalid, {})
self.assertEqual(list(valid.values()), [{"kind": 2, "thing": 42}])
def test_jsonschema_shared_1_invalid_defaults(self):
self.writeJsonSchema(valid_defaults=False)
parameters = {"number": 1, "letter": "a"}
shared = [{"kind": 1}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(valid, {})
self.assertEqual(list(invalid.values()), [{"kind": 1, "thing": 1}])
def test_jsonschema_shared_2_invalid_defaults(self):
self.writeJsonSchema(valid_defaults=False)
parameters = {"number": 1, "letter": "a"}
shared = [{"kind": 2}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(valid, {})
invalid_values = list(invalid.values())
self.assertEqual(invalid_values, [{"kind": 2, "thing": "forty-two"}])
def test_jsonschema_shared_2_valid_without_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 2}]
with self.patchSlap(parameters, True, shared):
valid, _ = self.receiveSharedParameters(options=())
self.assertEqual(list(valid.values()), shared)
def test_jsonschema_shared_2_valid_with_only_main_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 2}]
with self.patchSlap(parameters, True, shared):
valid, _ = self.receiveSharedParameters({'set-default': 'main'})
self.assertEqual(list(valid.values()), shared)
def test_jsonschema_shared_2_valid_with_only_shared_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 2}]
with self.patchSlap(parameters, True, shared):
valid, _ = self.receiveSharedParameters({'set-default': 'shared'})
self.assertEqual(list(valid.values()), [{"kind": 2, "thing": 42}])
def test_jsonschema_shared_1_and_2_valid_defaults(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 1}, {"kind": 2}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(invalid, {})
expected = [{"kind": 1, "thing": "hello"}, {"kind": 2, "thing": 42}]
self.assertEqual(list(valid.values()), expected)
def test_jsonschema_shared_invalid_kind(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 3}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(valid, {})
self.assertEqual(list(invalid.values()), [{"kind": 3}])
def test_jsonschema_shared_1_invalid_thing(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 1, "thing": {}}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(valid, {})
self.assertEqual(list(invalid.values()), [{"kind": 1, "thing": {}}])
def test_jsonschema_shared_2_invalid_thing(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 2, "thing": {}}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(valid, {})
self.assertEqual(list(invalid.values()), [{"kind": 2, "thing": {}}])
def test_jsonschema_shared_valid_and_invalid(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 1}, {"kind": 2, "thing": "hello"}]
with self.patchSlap(parameters, True, shared):
valid, invalid = self.receiveSharedParameters()
self.assertEqual(list(valid.values()), [{"kind": 1, "thing": "hello"}])
self.assertEqual(list(invalid.values()), [{"kind": 2, "thing": "hello"}])
def test_jsonschema_shared_valid_and_invalid_skip_shared(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 1}, {"kind": 2, "thing": "hello"}]
with self.patchSlap(parameters, True, shared):
options = self.runJsonSchemaRecipe({'validate-parameters': 'main'})
shared = options['slave-instance-list']
self.assertEqual(
shared,
[
{"slave_reference": "SHARED0", "kind": 1},
{"slave_reference": "SHARED1", "kind": 2, "thing": "hello"},
],
)
def test_jsonschema_shared_valid_and_invalid_skip_main(self):
self.writeJsonSchema()
parameters = {"number": 1}
shared = [{"kind": 1}, {"kind": 2, "thing": "hello"}]
with self.patchSlap(parameters, True, shared):
options = {'validate-parameters': 'shared', 'set-default': 'all'}
valid, invalid = self.receiveSharedParameters(options)
self.assertEqual(list(valid.values()), [{"kind": 1, "thing": "hello"}])
self.assertEqual(list(invalid.values()), [{"kind": 2, "thing": "hello"}])
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment