Commit 20b22dcc authored by Jérome Perrin's avatar Jérome Perrin

Merge remote-tracking branches 'origin/master' and...

Merge remote-tracking branches 'origin/master' and 'origin/feat/cli_request_file' into fix/checkSoftware_so
...@@ -74,6 +74,8 @@ setup(name=name, ...@@ -74,6 +74,8 @@ setup(name=name,
'six', 'six',
'cachecontrol', 'cachecontrol',
'lockfile', 'lockfile',
'jsonschema',
'PyYAML',
'uritemplate', # used by hateoas navigator 'uritemplate', # used by hateoas navigator
'subprocess32; python_version<"3"', 'subprocess32; python_version<"3"',
'ipaddress; python_version<"3"', # used by whitelistfirewall 'ipaddress; python_version<"3"', # used by whitelistfirewall
......
...@@ -27,15 +27,49 @@ ...@@ -27,15 +27,49 @@
# #
############################################################################## ##############################################################################
import argparse
import json
import os.path
import pprint import pprint
import lxml.etree
import six
import yaml
from slapos.cli.config import ClientConfigCommand from slapos.cli.config import ClientConfigCommand
from slapos.client import init, ClientConfig, _getSoftwareReleaseFromSoftwareString from slapos.client import (ClientConfig, _getSoftwareReleaseFromSoftwareString,
init)
from slapos.slap import ResourceNotReady from slapos.slap import ResourceNotReady
from slapos.util import SoftwareReleaseSchema, SoftwareReleaseSerialisation
try:
from typing import IO, Dict
except ImportError:
pass
def getParametersFromFile(file, serialisation):
# type: (IO[str], SoftwareReleaseSerialisation) -> Dict
extension = os.path.splitext(file.name)[1]
if extension in ('.yaml', '.yml'):
params = yaml.safe_load(file)
elif extension == '.xml':
tree = lxml.etree.parse(file)
params = {e.attrib['id']: e.text for e in tree.findall('/parameter')}
# because the use case of xml files is to copy paste existing XML parameters
# as found on slapos web interface, we aren't clever regarding the
# serialisation and assume they are already correct.
serialisation = None
else:
params = json.load(file)
if serialisation == SoftwareReleaseSerialisation.JsonInXml and list(params.keys()) != ['_']:
params = {'_': json.dumps(params)}
return params
def parse_option_dict(options): def parse_option_dict(options):
# type: (str) -> Dict
""" """
Parse a list of option strings like foo=bar baz=qux and return a dictionary. Parse a list of option strings like foo=bar baz=qux and return a dictionary.
Will raise if keys are repeated. Will raise if keys are repeated.
...@@ -80,8 +114,12 @@ class RequestCommand(ClientConfigCommand): ...@@ -80,8 +114,12 @@ class RequestCommand(ClientConfigCommand):
ap.add_argument('--parameters', ap.add_argument('--parameters',
nargs='+', nargs='+',
help="Give your configuration 'option1=value1 option2=value2'") help="Instance parameters, in the form 'option1=value1 option2=value2'.")
ap.add_argument('--parameters-file',
type=argparse.FileType('r'),
help="Instance parameters, in a file.\n"
"The file will be interpreted as json, yaml or xml depending on the file extension.")
return ap return ap
def take_action(self, args): def take_action(self, args):
...@@ -95,6 +133,16 @@ class RequestCommand(ClientConfigCommand): ...@@ -95,6 +133,16 @@ class RequestCommand(ClientConfigCommand):
do_request(self.app.log, conf, local) do_request(self.app.log, conf, local)
# BBB on python3 we can use pprint.pformat
class StrPrettyPrinter(pprint.PrettyPrinter):
"""A PrettyPrinter which produces consistent output on python 2 and 3
"""
def format(self, object, context, maxlevels, level):
if six.PY2 and isinstance(object, six.text_type):
object = object.encode('utf-8')
return pprint.PrettyPrinter.format(self, object, context, maxlevels, level)
def do_request(logger, conf, local): def do_request(logger, conf, local):
logger.info('Requesting %s as instance of %s...', logger.info('Requesting %s as instance of %s...',
conf.reference, conf.software_url) conf.reference, conf.software_url)
...@@ -104,11 +152,17 @@ def do_request(logger, conf, local): ...@@ -104,11 +152,17 @@ def do_request(logger, conf, local):
if conf.software_url in local: if conf.software_url in local:
conf.software_url = local[conf.software_url] conf.software_url = local[conf.software_url]
software_schema = SoftwareReleaseSchema(conf.software_url, conf.type)
software_schema_serialisation = software_schema.getSerialisation()
parameters = conf.parameters
if conf.parameters_file:
parameters = getParametersFromFile(conf.parameters_file, software_schema_serialisation)
try: try:
partition = local['slap'].registerOpenOrder().request( partition = local['slap'].registerOpenOrder().request(
software_release=conf.software_url, software_release=conf.software_url,
partition_reference=conf.reference, partition_reference=conf.reference,
partition_parameter_kw=conf.parameters, partition_parameter_kw=parameters,
software_type=conf.type, software_type=conf.type,
filter_kw=conf.node, filter_kw=conf.node,
state=conf.state, state=conf.state,
...@@ -116,7 +170,11 @@ def do_request(logger, conf, local): ...@@ -116,7 +170,11 @@ def do_request(logger, conf, local):
) )
logger.info('Instance requested.\nState is : %s.', partition.getState()) logger.info('Instance requested.\nState is : %s.', partition.getState())
logger.info('Connection parameters of instance are:') logger.info('Connection parameters of instance are:')
logger.info(pprint.pformat(partition.getConnectionParameterDict())) connection_parameter_dict = partition.getConnectionParameterDict()
if software_schema_serialisation == SoftwareReleaseSerialisation.JsonInXml:
if '_' in connection_parameter_dict:
connection_parameter_dict = json.loads(connection_parameter_dict['_'])
logger.info(StrPrettyPrinter().pformat(connection_parameter_dict))
logger.info('You can rerun the command to get up-to-date information.') logger.info('You can rerun the command to get up-to-date information.')
except ResourceNotReady: except ResourceNotReady:
logger.warning('Instance requested. Master is provisioning it. Please rerun in a ' logger.warning('Instance requested. Master is provisioning it. Please rerun in a '
......
...@@ -40,14 +40,17 @@ import os ...@@ -40,14 +40,17 @@ import os
import logging import logging
import re import re
from functools import wraps from functools import wraps
import warnings
import json import json
import jsonschema
import six import six
from .exception import ResourceNotReady, ServerError, NotFoundError, \ from .exception import ResourceNotReady, ServerError, NotFoundError, \
ConnectionError ConnectionError
from .hateoas import SlapHateoasNavigator, ConnectionHelper from .hateoas import SlapHateoasNavigator, ConnectionHelper
from slapos.util import loads, dumps, bytes2str, unicode2str, xml2dict, dict2xml, calculate_dict_hash from slapos.util import (SoftwareReleaseSchema, bytes2str, calculate_dict_hash,
dict2xml, dumps, loads, unicode2str, xml2dict)
from xml.sax import saxutils from xml.sax import saxutils
from zope.interface import implementer from zope.interface import implementer
...@@ -87,6 +90,27 @@ class SlapRequester(SlapDocument): ...@@ -87,6 +90,27 @@ class SlapRequester(SlapDocument):
""" """
def _requestComputerPartition(self, request_dict): def _requestComputerPartition(self, request_dict):
try:
SoftwareReleaseSchema(
request_dict['software_release'],
request_dict['software_type']
).validateInstanceParameterDict(
loads(request_dict['partition_parameter_xml']))
except jsonschema.ValidationError as e:
warnings.warn(
"Request parameters do not validate against schema definition:\n{e}".format(e=e),
UserWarning,
)
except Exception as e:
# note that we intentionally catch wide exceptions, so that if anything
# is wrong with fetching the schema or the schema itself this does not
# prevent users from requesting instances.
warnings.warn(
"Error validating request parameters against schema definition:\n{e.__class__.__name__} {e}".format(e=e),
UserWarning,
)
try: try:
xml = self._connection_helper.POST('requestComputerPartition', data=request_dict) xml = self._connection_helper.POST('requestComputerPartition', data=request_dict)
except ResourceNotReady: except ResourceNotReady:
......
...@@ -79,6 +79,21 @@ class SlapOSNodeCommandError(Exception): ...@@ -79,6 +79,21 @@ class SlapOSNodeCommandError(Exception):
) )
@zope.interface.implementer(IException)
class SlapOSNodeSoftwareError(SlapOSNodeCommandError):
"""Exception raised when runing SlapOS Node software command failed.
"""
@zope.interface.implementer(IException)
class SlapOSNodeInstanceError(SlapOSNodeCommandError):
"""Exception raised when runing SlapOS Node instance command failed.
"""
@zope.interface.implementer(IException)
class SlapOSNodeReportError(SlapOSNodeCommandError):
"""Exception raised when runing SlapOS Node report command failed.
"""
@zope.interface.implementer(IException) @zope.interface.implementer(IException)
class PathTooDeepError(Exception): class PathTooDeepError(Exception):
"""Exception raised when path is too deep to create an unix socket. """Exception raised when path is too deep to create an unix socket.
...@@ -769,15 +784,18 @@ class StandaloneSlapOS(object): ...@@ -769,15 +784,18 @@ class StandaloneSlapOS(object):
running `slapos node software --all`. running `slapos node software --all`.
Error cases: Error cases:
* `SlapOSNodeCommandError` when buildout error while installing software. * `SlapOSNodeSoftwareError` when buildout error while installing software.
* Unexpected `Exception` if unable to connect to embedded slap server. * Unexpected `Exception` if unable to connect to embedded slap server.
""" """
try:
return self._runSlapOSCommand( return self._runSlapOSCommand(
'slapos-node-software-all' if install_all else 'slapos-node-software', 'slapos-node-software-all' if install_all else 'slapos-node-software',
max_retry=max_retry, max_retry=max_retry,
debug=debug, debug=debug,
error_lines=error_lines, error_lines=error_lines,
) )
except SlapOSNodeCommandError as e:
raise SlapOSNodeSoftwareError(*e.args)
def waitForInstance(self, max_retry=0, debug=False, error_lines=30): def waitForInstance(self, max_retry=0, debug=False, error_lines=30):
"""Instantiate all partitions previously requested for start. """Instantiate all partitions previously requested for start.
...@@ -790,15 +808,18 @@ class StandaloneSlapOS(object): ...@@ -790,15 +808,18 @@ class StandaloneSlapOS(object):
drop in a debugger session if error occurs. drop in a debugger session if error occurs.
Error cases: Error cases:
* `SlapOSNodeCommandError` when buildout error while creating instances. * `SlapOSNodeInstanceError` when buildout error while creating instances.
* Unexpected `Exception` if unable to connect to embedded slap server. * Unexpected `Exception` if unable to connect to embedded slap server.
""" """
try:
return self._runSlapOSCommand( return self._runSlapOSCommand(
'slapos-node-instance-all' if self._force_slapos_node_instance_all else 'slapos-node-instance', 'slapos-node-instance-all' if self._force_slapos_node_instance_all else 'slapos-node-instance',
max_retry=max_retry, max_retry=max_retry,
debug=debug, debug=debug,
error_lines=error_lines, error_lines=error_lines,
) )
except SlapOSNodeCommandError as e:
raise SlapOSNodeInstanceError(*e.args)
def waitForReport(self, max_retry=0, debug=False, error_lines=30): def waitForReport(self, max_retry=0, debug=False, error_lines=30):
"""Destroy all partitions previously requested for destruction. """Destroy all partitions previously requested for destruction.
...@@ -811,15 +832,18 @@ class StandaloneSlapOS(object): ...@@ -811,15 +832,18 @@ class StandaloneSlapOS(object):
drop in a debugger session if error occurs. drop in a debugger session if error occurs.
Error cases: Error cases:
* `SlapOSNodeCommandError` when buildout error while destroying instances. * `SlapOSNodeReportError` when buildout error while destroying instances.
* Unexpected `Exception` if unable to connect to embedded slap server. * Unexpected `Exception` if unable to connect to embedded slap server.
""" """
try:
return self._runSlapOSCommand( return self._runSlapOSCommand(
'slapos-node-report', 'slapos-node-report',
max_retry=max_retry, max_retry=max_retry,
debug=debug, debug=debug,
error_lines=error_lines, error_lines=error_lines,
) )
except SlapOSNodeCommandError as e:
raise SlapOSNodeReportError(*e.args)
def _runSlapOSCommand( def _runSlapOSCommand(
self, command, max_retry=0, debug=False, error_lines=30): self, command, max_retry=0, debug=False, error_lines=30):
......
...@@ -60,6 +60,8 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -60,6 +60,8 @@ def makeModuleSetUpAndTestCaseClass(
ipv6_address=os.environ['SLAPOS_TEST_IPV6'], ipv6_address=os.environ['SLAPOS_TEST_IPV6'],
debug=bool(int(os.environ.get('SLAPOS_TEST_DEBUG', 0))), debug=bool(int(os.environ.get('SLAPOS_TEST_DEBUG', 0))),
verbose=bool(int(os.environ.get('SLAPOS_TEST_VERBOSE', 0))), verbose=bool(int(os.environ.get('SLAPOS_TEST_VERBOSE', 0))),
skip_software_check=bool(int(os.environ.get('SLAPOS_TEST_SKIP_SOFTWARE_CHECK', 0))),
skip_software_rebuild=bool(int(os.environ.get('SLAPOS_TEST_SKIP_SOFTWARE_REBUILD', 0))),
shared_part_list=[ shared_part_list=[
os.path.expanduser(p) for p in os.environ.get( os.path.expanduser(p) for p in os.environ.get(
'SLAPOS_TEST_SHARED_PART_LIST', '').split(os.pathsep) if p 'SLAPOS_TEST_SHARED_PART_LIST', '').split(os.pathsep) if p
...@@ -67,7 +69,7 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -67,7 +69,7 @@ def makeModuleSetUpAndTestCaseClass(
snapshot_directory=os.environ.get('SLAPOS_TEST_LOG_DIRECTORY'), snapshot_directory=os.environ.get('SLAPOS_TEST_LOG_DIRECTORY'),
software_id=None software_id=None
): ):
# type: (str, str, str, str, bool, bool, Iterable[str], Optional[str], Optional[str]) -> Tuple[Callable[[], None], Type[SlapOSInstanceTestCase]] # type: (str, str, str, str, bool, bool, bool, bool, Iterable[str], Optional[str], Optional[str]) -> Tuple[Callable[[], None], Type[SlapOSInstanceTestCase]]
"""Create a setup module function and a testcase for testing `software_url`. """Create a setup module function and a testcase for testing `software_url`.
This function returns a tuple of two arguments: This function returns a tuple of two arguments:
...@@ -79,12 +81,16 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -79,12 +81,16 @@ def makeModuleSetUpAndTestCaseClass(
environment variables `SLAPOS_TEST_IPV4` and `SLAPOS_TEST_IPV6`, or by the environment variables `SLAPOS_TEST_IPV4` and `SLAPOS_TEST_IPV6`, or by the
explicits `ipv4_address` and `ipv6_address` arguments. explicits `ipv4_address` and `ipv6_address` arguments.
To ease development and troubleshooting, two switches are available: To ease development and troubleshooting, those switches are available:
* `verbose` (also controlled by `SLAPOS_TEST_VERBOSE` environment variable) * `verbose` (also controlled by `SLAPOS_TEST_VERBOSE` environment variable)
to tell the test framework to log information describing the actions taken. to tell the test framework to log information describing the actions taken.
* `debug` (also controlled by `SLAPOS_TEST_DEBUG` environment variable) to * `debug` (also controlled by `SLAPOS_TEST_DEBUG` environment variable) to
enable debugging mode which will drop in a debugger session when errors enable debugging mode which will drop in a debugger session when errors
occurs. occurs.
* `_skip_software_check` (also controlled by `SLAPOS_TEST_SKIP_SOFTWARE_CHECK`
environment variable) to skip costly software checks
* `_skip_software_rebuild` (also controlled by `SLAPOS_TEST_SKIP_SOFTWARE_REBUILD`
environment variable) to skip costly software builds
The base_directory directory is by default .slapos in current directory, The base_directory directory is by default .slapos in current directory,
or a path from `SLAPOS_TEST_WORKING_DIR` environment variable. or a path from `SLAPOS_TEST_WORKING_DIR` environment variable.
...@@ -155,6 +161,8 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -155,6 +161,8 @@ def makeModuleSetUpAndTestCaseClass(
'software_id': software_id, 'software_id': software_id,
'_debug': debug, '_debug': debug,
'_verbose': verbose, '_verbose': verbose,
'_skip_software_check': skip_software_check,
'_skip_software_rebuild': skip_software_rebuild,
'_ipv4_address': ipv4_address, '_ipv4_address': ipv4_address,
'_ipv6_address': ipv6_address, '_ipv6_address': ipv6_address,
'_base_directory': base_directory, '_base_directory': base_directory,
...@@ -209,12 +217,16 @@ def installSoftwareUrlList(cls, software_url_list, max_retry=10, debug=False): ...@@ -209,12 +217,16 @@ def installSoftwareUrlList(cls, software_url_list, max_retry=10, debug=False):
cls.logger.debug("Supplying %s", software_url) cls.logger.debug("Supplying %s", software_url)
cls.slap.supply(software_url) cls.slap.supply(software_url)
cls.logger.debug("Waiting for slapos node software to build") cls.logger.debug("Waiting for slapos node software to build")
cls.slap.waitForSoftware(max_retry=max_retry, debug=debug) cls.slap.waitForSoftware(max_retry=max_retry, debug=debug, install_all=not cls._skip_software_rebuild)
_storeSoftwareSnapshot('setupModule') _storeSoftwareSnapshot('setupModule')
if not cls._skip_software_check:
for software_url in software_url_list: for software_url in software_url_list:
cls.logger.debug("Checking software %s", software_url) cls.logger.debug("Checking software %s", software_url)
checkSoftware(cls.slap, software_url) checkSoftware(cls.slap, software_url)
cls.logger.debug("Done checking software %s", software_url) cls.logger.debug("Done checking software %s", software_url)
else:
cls.logger.debug("Software checks skipped")
except BaseException as e: except BaseException as e:
_storeSoftwareSnapshot('setupModule failed installing software') _storeSoftwareSnapshot('setupModule failed installing software')
if not debug: if not debug:
...@@ -266,6 +278,10 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -266,6 +278,10 @@ class SlapOSInstanceTestCase(unittest.TestCase):
partition_count = 10 partition_count = 10
# reference of the default requested partition # reference of the default requested partition
default_partition_reference = 'testing partition 0' default_partition_reference = 'testing partition 0'
# skips software checks
_skip_software_check = False
# skips software rebuild
_skip_software_rebuild = False
# a logger for messages of the testing framework # a logger for messages of the testing framework
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -352,14 +368,23 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -352,14 +368,23 @@ class SlapOSInstanceTestCase(unittest.TestCase):
# Unittest methods # Unittest methods
@classmethod @classmethod
def setUpClass(cls): def waitForInstance(cls):
"""Request an instance. # waitForInstance does not tolerate any error but with instances,
""" # promises sometimes fail on first run, because services did not
cls._instance_parameter_dict = cls.getInstanceParameterDict() # have time to start.
snapshot_name = "{}.{}.setUpClass".format(cls.__module__, cls.__name__) # To make debug usable, we tolerate instance_max_retry-1 errors and
# only debug the last.
if cls._debug and cls.instance_max_retry:
try: try:
cls.logger.debug("Starting setUpClass %s", cls) cls.slap.waitForInstance(max_retry=cls.instance_max_retry - 1)
except SlapOSNodeCommandError:
cls.slap.waitForInstance(debug=True)
else:
cls.slap.waitForInstance(
max_retry=cls.instance_max_retry, debug=cls._debug)
@classmethod
def _setUpClass(cls):
cls.slap.start() cls.slap.start()
cls.logger.debug( cls.logger.debug(
"Formatting to remove old partitions XXX should not be needed because we delete ..." "Formatting to remove old partitions XXX should not be needed because we delete ..."
...@@ -375,20 +400,7 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -375,20 +400,7 @@ class SlapOSInstanceTestCase(unittest.TestCase):
# slapos node instance # slapos node instance
cls.logger.debug("Waiting for instance") cls.logger.debug("Waiting for instance")
# waitForInstance does not tolerate any error but with instances, cls.waitForInstance()
# promises sometimes fail on first run, because services did not
# have time to start.
# To make debug usable, we tolerate instance_max_retry-1 errors and
# only debug the last.
if cls._debug and cls.instance_max_retry:
try:
cls.slap.waitForInstance(max_retry=cls.instance_max_retry - 1)
except SlapOSNodeCommandError:
cls.slap.waitForInstance(debug=True)
else:
cls.slap.waitForInstance(
max_retry=cls.instance_max_retry, debug=cls._debug)
# expose some class attributes so that tests can use them: # expose some class attributes so that tests can use them:
# the main ComputerPartition instance, to use getInstanceParameterDict # the main ComputerPartition instance, to use getInstanceParameterDict
cls.computer_partition = cls.requestDefaultInstance() cls.computer_partition = cls.requestDefaultInstance()
...@@ -396,7 +408,17 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -396,7 +408,17 @@ class SlapOSInstanceTestCase(unittest.TestCase):
# the path of the instance on the filesystem, for low level inspection # the path of the instance on the filesystem, for low level inspection
cls.computer_partition_root_path = os.path.join( cls.computer_partition_root_path = os.path.join(
cls.slap._instance_root, cls.computer_partition.getId()) cls.slap._instance_root, cls.computer_partition.getId())
cls.logger.debug("setUpClass done")
@classmethod
def setUpClass(cls):
"""Request an instance.
"""
cls.logger.debug("Starting setUpClass %s", cls)
cls._instance_parameter_dict = cls.getInstanceParameterDict()
snapshot_name = "{}.{}.setUpClass".format(cls.__module__, cls.__name__)
try:
cls._setUpClass()
except BaseException: except BaseException:
cls.logger.exception("Error during setUpClass") cls.logger.exception("Error during setUpClass")
cls._storeSystemSnapshot(snapshot_name) cls._storeSystemSnapshot(snapshot_name)
...@@ -405,6 +427,7 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -405,6 +427,7 @@ class SlapOSInstanceTestCase(unittest.TestCase):
raise raise
else: else:
cls._storeSystemSnapshot(snapshot_name) cls._storeSystemSnapshot(snapshot_name)
cls.logger.debug("setUpClass done")
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
......
...@@ -113,8 +113,10 @@ class ManagedHTTPServer(ManagedResource): ...@@ -113,8 +113,10 @@ class ManagedHTTPServer(ManagedResource):
proto = 'http' proto = 'http'
# hostname to listen to, default to ipv4 address of the current test # hostname to listen to, default to ipv4 address of the current test
hostname = None # type: str hostname = None # type: str
# port to listen to, default # port to listen to, default to a free port selected with `findFreeTCPPort`
port = None # type: int port = None # type: int
# current working directory of the server process
working_directory = None # type: str
@property @property
def url(self): def url(self):
...@@ -160,10 +162,19 @@ class ManagedHTTPServer(ManagedResource): ...@@ -160,10 +162,19 @@ class ManagedHTTPServer(ManagedResource):
if not self.port: if not self.port:
self.port = findFreeTCPPort(self.hostname) self.port = findFreeTCPPort(self.hostname)
def serve_forever(server, cwd):
if cwd:
os.chdir(cwd)
server.serve_forever()
server = self._makeServer() server = self._makeServer()
self._process = multiprocessing.Process( self._process = multiprocessing.Process(
target=server.serve_forever, target=serve_forever,
name=self._name, name=self._name,
kwargs={
'server': server,
'cwd': self.working_directory,
}
) )
self._process.start() self._process.start()
......
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
# #
############################################################################## ##############################################################################
import json
import logging import logging
import pprint import pprint
import unittest import unittest
...@@ -40,6 +41,7 @@ from contextlib import contextmanager ...@@ -40,6 +41,7 @@ from contextlib import contextmanager
from mock import patch, create_autospec from mock import patch, create_autospec
import mock import mock
from slapos.util import sqlite_connect, bytes2str from slapos.util import sqlite_connect, bytes2str
from slapos.slap.slap import DEFAULT_SOFTWARE_TYPE
import slapos.cli.console import slapos.cli.console
import slapos.cli.entry import slapos.cli.entry
...@@ -49,10 +51,12 @@ import slapos.cli.computer_info ...@@ -49,10 +51,12 @@ import slapos.cli.computer_info
import slapos.cli.computer_list import slapos.cli.computer_list
import slapos.cli.computer_token import slapos.cli.computer_token
import slapos.cli.supervisorctl import slapos.cli.supervisorctl
import slapos.cli.request
from slapos.cli.proxy_show import do_show, StringIO from slapos.cli.proxy_show import do_show, StringIO
from slapos.cli.cache import do_lookup as cache_do_lookup from slapos.cli.cache import do_lookup as cache_do_lookup
from slapos.cli.cache_source import do_lookup as cache_source_do_lookup from slapos.cli.cache_source import do_lookup as cache_source_do_lookup
from slapos.client import ClientConfig from slapos.client import ClientConfig
from slapos.slap import SoftwareProductCollection
import slapos.grid.svcbackend import slapos.grid.svcbackend
import slapos.proxy import slapos.proxy
import slapos.slap import slapos.slap
...@@ -65,8 +69,8 @@ def raiseNotFoundError(*args, **kwargs): ...@@ -65,8 +69,8 @@ def raiseNotFoundError(*args, **kwargs):
class CliMixin(unittest.TestCase): class CliMixin(unittest.TestCase):
def setUp(self): def setUp(self):
slap = slapos.slap.slap() slap = slapos.slap.slap()
self.local = {'slap': slap}
self.logger = create_autospec(logging.Logger) self.logger = create_autospec(logging.Logger)
self.local = {'slap': slap, 'product': SoftwareProductCollection(self.logger, slap)}
self.conf = create_autospec(ClientConfig) self.conf = create_autospec(ClientConfig)
class TestCliCache(CliMixin): class TestCliCache(CliMixin):
...@@ -641,3 +645,227 @@ class TestCliComplete(CliMixin): ...@@ -641,3 +645,227 @@ class TestCliComplete(CliMixin):
with patch.object(sys, 'stdout', StringIO()) as app_stdout: with patch.object(sys, 'stdout', StringIO()) as app_stdout:
self.assertEqual(slapos.cli.entry.SlapOSApp().run(['complete', '--shell=fish']), 0) self.assertEqual(slapos.cli.entry.SlapOSApp().run(['complete', '--shell=fish']), 0)
self.assertIn('__fish_seen_subcommand_from', app_stdout.getvalue()) self.assertIn('__fish_seen_subcommand_from', app_stdout.getvalue())
class TestCliRequest(CliMixin):
def test_parse_option_dict(self):
parse_option_dict = slapos.cli.request.parse_option_dict
self.assertEqual(parse_option_dict(['foo=bar', 'a=b']), {'foo': 'bar', 'a': 'b'})
# malformed option = assignment
self.assertRaises(ValueError, parse_option_dict, ['a'])
# duplicated key
self.assertRaises(ValueError, parse_option_dict, ['a=b', 'a=c'])
# corner cases
self.assertEqual(parse_option_dict(['a=a=b']), {'a': 'a=b'})
self.assertEqual(parse_option_dict(['a=a\nb']), {'a': 'a\nb'})
self.assertEqual(parse_option_dict([]), {})
def test_request(self):
self.conf.reference = 'instance reference'
self.conf.software_url = 'software URL'
self.conf.parameters = {'key': 'value'}
self.conf.parameters_file = None
self.conf.node = {'computer_guid': 'COMP-1234'}
self.conf.type = None
self.conf.state = None
self.conf.slave = False
with patch.object(
slapos.slap.slap,
'registerOpenOrder',
return_value=mock.create_autospec(slapos.slap.OpenOrder)) as registerOpenOrder:
slapos.cli.request.do_request(self.logger, self.conf, self.local)
registerOpenOrder().request.assert_called_once_with(
software_release='software URL',
partition_reference='instance reference',
partition_parameter_kw={'key': 'value'},
software_type=None,
filter_kw={'computer_guid': 'COMP-1234'},
state=None,
shared=False,
)
self.logger.info.assert_any_call(
'Requesting %s as instance of %s...',
'instance reference',
'software URL',
)
def test_request_json_in_xml_published_parameters(self):
tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, tmpdir)
with open(os.path.join(tmpdir, 'software.cfg.json'), 'w') as f:
json.dump(
{
"name": "Test Software",
"description": "Dummy software for Test",
"serialisation": "json-in-xml",
"software-type": {
DEFAULT_SOFTWARE_TYPE: {
"title": "Default",
"description": "Default type",
"request": "instance-default-input-schema.json",
"response": "instance-default-output-schema.json",
"index": 0
},
}
}, f)
self.conf.reference = 'instance reference'
self.conf.software_url = os.path.join(tmpdir, 'software.cfg')
self.conf.parameters = {'key': 'value'}
self.conf.parameters_file = None
self.conf.node = {'computer_guid': 'COMP-1234'}
self.conf.type = None
self.conf.state = None
self.conf.slave = False
cp = slapos.slap.ComputerPartition(
'computer_%s' % self.id(),
'partition_%s' % self.id())
cp._requested_state = 'started'
cp._connection_dict = {'_': json.dumps({'foo': 'bar'})}
with patch.object(
slapos.slap.slap,
'registerOpenOrder',
return_value=mock.create_autospec(slapos.slap.OpenOrder)) as registerOpenOrder:
registerOpenOrder().request.return_value = cp
slapos.cli.request.do_request(self.logger, self.conf, self.local)
registerOpenOrder().request.assert_called_once()
self.assertEqual(self.logger.info.mock_calls, [
mock.call('Requesting %s as instance of %s...', self.conf.reference,
self.conf.software_url),
mock.call('Instance requested.\nState is : %s.', 'started'),
mock.call('Connection parameters of instance are:'),
mock.call("{'foo': 'bar'}"),
mock.call('You can rerun the command to get up-to-date information.'),
])
class TestCliRequestParametersFileJson(CliMixin):
"""Request with --parameter-file, with a .json file.
"""
expected_partition_parameter_kw = {'foo': ['bar']}
def _makeParameterFile(self):
f = tempfile.NamedTemporaryFile(suffix='.json', mode='w', delete=False)
self.addCleanup(os.unlink, f.name)
f.write(textwrap.dedent('''\
{
"foo": ["bar"]
}
'''))
f.flush()
return f.name
def test_request_parameters_file(self):
self.conf.reference = 'instance reference'
self.conf.software_url = 'software URL'
self.conf.parameters = None
f = open(self._makeParameterFile())
self.addCleanup(f.close)
self.conf.parameters_file = f
self.conf.node = {'computer_guid': 'COMP-1234'}
self.conf.type = None
self.conf.state = None
self.conf.slave = False
with patch.object(
slapos.slap.slap,
'registerOpenOrder',
return_value=mock.create_autospec(slapos.slap.OpenOrder)) as registerOpenOrder:
slapos.cli.request.do_request(self.logger, self.conf, self.local)
registerOpenOrder().request.assert_called_once_with(
software_release='software URL',
partition_reference='instance reference',
partition_parameter_kw=self.expected_partition_parameter_kw,
software_type=None,
filter_kw={'computer_guid': 'COMP-1234'},
state=None,
shared=False,
)
self.logger.info.assert_any_call(
'Requesting %s as instance of %s...',
'instance reference',
'software URL',
)
class TestCliRequestParametersFileJsonJsonInXMLSerialisation(
TestCliRequestParametersFileJson):
"""Request with --parameter-file, with a .json file and a software using
json-in-xml for serialisation. In that case, the parameters are automatically
serialised with {'_': json.dumps(params)}
"""
expected_partition_parameter_kw = {"_": "{\"foo\": [\"bar\"]}"}
def test_request_parameters_file(self):
with mock.patch(
'slapos.cli.request.SoftwareReleaseSchema.getSerialisation',
return_value='json-in-xml'):
super(TestCliRequestParametersFileJsonJsonInXMLSerialisation,
self).test_request_parameters_file()
class TestCliRequestParametersFileJsonJsonInXMLSerialisationAlreadySerialised(
TestCliRequestParametersFileJson):
"""Request with --parameter-file, with a .json file and a software using
json-in-xml for serialisation and parameters already serialised with
{'_': json.dumps(params)}. In that case, parameters are not serialized one
more time.
"""
expected_partition_parameter_kw = {"_": "{\"foo\": [\"bar\"]}"}
def _makeParameterFile(self):
f = tempfile.NamedTemporaryFile(suffix='.json', mode='w', delete=False)
self.addCleanup(os.unlink, f.name)
f.write(textwrap.dedent(r'''
{"_": "{\"foo\": [\"bar\"]}"}
'''))
f.flush()
return f.name
def test_request_parameters_file(self):
with mock.patch(
'slapos.cli.request.SoftwareReleaseSchema.getSerialisation',
return_value='json-in-xml'):
super(
TestCliRequestParametersFileJsonJsonInXMLSerialisationAlreadySerialised,
self).test_request_parameters_file()
class TestCliRequestParametersFileYaml(TestCliRequestParametersFileJson):
"""Request with --parameter-file, with a .yaml file. This behaves like json.
"""
def _makeParameterFile(self):
f = tempfile.NamedTemporaryFile(suffix='.yaml', mode='w', delete=False)
self.addCleanup(os.unlink, f.name)
f.write(textwrap.dedent('''\
foo:
- bar
'''))
f.flush()
return f.name
class TestCliRequestParametersFileXml(TestCliRequestParametersFileJson):
"""Request with --parameter-file, with a .xml file
"""
expected_partition_parameter_kw = {'foo': 'bar'}
def _makeParameterFile(self):
f = tempfile.NamedTemporaryFile(suffix='.xml', mode='w', delete=False)
f.write(textwrap.dedent('''\
<?xml version="1.0" encoding="utf-8"?>
<instance>
<parameter id="foo">bar</parameter>
</instance>
'''))
f.flush()
self.addCleanup(os.unlink, f.name)
return f.name
...@@ -32,6 +32,7 @@ from six.moves.urllib import parse ...@@ -32,6 +32,7 @@ from six.moves.urllib import parse
from six import PY3 from six import PY3
import tempfile import tempfile
import logging import logging
import warnings
from collections import OrderedDict from collections import OrderedDict
import httmock import httmock
...@@ -872,6 +873,188 @@ class TestComputerPartition(SlapMixin): ...@@ -872,6 +873,188 @@ class TestComputerPartition(SlapMixin):
content_list = f.read().splitlines() content_list = f.read().splitlines()
self.assertEqual(sorted(content_list), ['myref', 'mysecondref']) self.assertEqual(sorted(content_list), ['myref', 'mysecondref'])
def test_request_validate_request_parameter(self):
def handler(url, req):
if url.path.endswith('/software.cfg.json'):
return json.dumps(
{
"name": "Test Software",
"description": "Dummy software for Test",
"serialisation": "json-in-xml",
"software-type": {
'default': {
"title": "Default",
"description": "Default type",
"request": "instance-default-input-schema.json",
"response": "instance-default-output-schema.json",
"index": 0
},
}
})
if url.path.endswith('/instance-default-input-schema.json'):
return json.dumps(
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "Simple instance parameters schema for tests",
"required": ["foo"],
"properties": {
"foo": {
"$ref": "./schemas-definitions.json#/foo"
}
},
"type": "object"
})
if url.path.endswith('/schemas-definitions.json'):
return json.dumps({"foo": {"type": "string", "const": "bar"}})
raise ValueError(404)
with httmock.HTTMock(handler):
with mock.patch.object(warnings, 'warn') as warn:
cp = slapos.slap.ComputerPartition('computer_id', 'partition_id')
cp._connection_helper = mock.Mock()
cp._connection_helper.POST.side_effect = slapos.slap.ResourceNotReady
cp.request(
'https://example.com/software.cfg', 'default', 'reference',
partition_parameter_kw={'foo': 'bar'})
warn.assert_not_called()
with httmock.HTTMock(handler):
with mock.patch.object(warnings, 'warn') as warn:
cp = slapos.slap.ComputerPartition('computer_id', 'partition_id')
cp._connection_helper = mock.Mock()
cp._connection_helper.POST.side_effect = slapos.slap.ResourceNotReady
cp.request(
'https://example.com/software.cfg', 'default', 'reference',
partition_parameter_kw={'foo': 'baz'})
if PY3:
warn.assert_called_with(
"Request parameters do not validate against schema definition:\n"
"'bar' was expected\n\n"
"Failed validating 'const' in schema['properties']['foo']:\n"
" {'const': 'bar', 'type': 'string'}\n\n"
"On instance['foo']:\n 'baz'", UserWarning
)
else: # BBB
warn.assert_called_with(
"Request parameters do not validate against schema definition:\n"
"u'bar' was expected\n\n"
"Failed validating u'const' in schema[u'properties'][u'foo']:\n"
" {u'const': u'bar', u'type': u'string'}\n\n"
"On instance[u'foo']:\n 'baz'", UserWarning
)
def test_request_validate_request_parameter_broken_software_release_schema(self):
"""Corner case tests for incorrect software release schema, these should
not prevent the request (mostly for backward compatibility)
"""
def wrong_software_cfg_schema(url, req):
if url.path.endswith('/software.cfg.json'):
return "wrong"
raise ValueError(404)
def wrong_instance_parameter_schema(url, req):
if url.path.endswith('/software.cfg.json'):
return json.dumps(
{
"name": "Test Software",
"description": "Dummy software for Test",
"serialisation": "json-in-xml",
"software-type": {
'default': {
"title": "Default",
"description": "Default type",
"request": "instance-default-input-schema.json",
"response": "instance-default-output-schema.json",
"index": 0
},
}
})
if url.path.endswith('/instance-default-input-schema.json'):
return "wrong"
raise ValueError(404)
def invalid_instance_parameter_schema(url, req):
if url.path.endswith('/software.cfg.json'):
return json.dumps(
{
"name": "Test Software",
"description": "Dummy software for Test",
"serialisation": "json-in-xml",
"software-type": {
'default': {
"title": "Default",
"description": "Default type",
"request": "instance-default-input-schema.json",
"response": "instance-default-output-schema.json",
"index": 0
},
}
})
if url.path.endswith('/instance-default-input-schema.json'):
return json.dumps(
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "Invalid json schema",
"required": {"wrong": True},
"properties": {
["wrong schema"]
},
"type": "object"
})
raise ValueError(404)
def broken_reference(url, req):
if url.path.endswith('/software.cfg.json'):
return json.dumps(
{
"name": "Test Software",
"description": "Dummy software for Test",
"serialisation": "json-in-xml",
"software-type": {
'default': {
"title": "Default",
"description": "Default type",
"request": "instance-default-input-schema.json",
"response": "instance-default-output-schema.json",
"index": 0
},
}
})
if url.path.endswith('/instance-default-input-schema.json'):
return json.dumps(
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "Simple instance parameters schema for tests",
"required": ["foo"],
"properties": {
"foo": {
"$ref": "broken"
}
},
"type": "object"
})
raise ValueError(404)
for handler, warning_expected in (
(broken_reference, True),
(wrong_software_cfg_schema, False),
(wrong_instance_parameter_schema, True),
(invalid_instance_parameter_schema, True),
):
with httmock.HTTMock(handler):
with mock.patch.object(warnings, 'warn') as warn:
cp = slapos.slap.ComputerPartition('computer_id', 'partition_id')
cp._connection_helper = mock.Mock()
cp._connection_helper.POST.side_effect = slapos.slap.ResourceNotReady
cp.request(
'https://example.com/software.cfg', 'default', 'reference',
partition_parameter_kw={'foo': 'bar'})
if warning_expected:
warn.assert_called()
else:
warn.assert_not_called()
def _test_new_computer_partition_state(self, state): def _test_new_computer_partition_state(self, state):
""" """
Helper method to automate assertions of failing states on new Computer Helper method to automate assertions of failing states on new Computer
......
...@@ -44,7 +44,7 @@ from six.moves.configparser import ConfigParser ...@@ -44,7 +44,7 @@ from six.moves.configparser import ConfigParser
import psutil import psutil
from slapos.slap.standalone import StandaloneSlapOS from slapos.slap.standalone import StandaloneSlapOS
from slapos.slap.standalone import SlapOSNodeCommandError from slapos.slap.standalone import SlapOSNodeSoftwareError
from slapos.slap.standalone import PartitionForwardConfiguration from slapos.slap.standalone import PartitionForwardConfiguration
from slapos.slap.standalone import PartitionForwardAsPartitionConfiguration from slapos.slap.standalone import PartitionForwardAsPartitionConfiguration
...@@ -401,16 +401,16 @@ class TestSlapOSStandaloneSoftware(SlapOSStandaloneTestCase): ...@@ -401,16 +401,16 @@ class TestSlapOSStandaloneSoftware(SlapOSStandaloneTestCase):
f.flush() f.flush()
self.standalone.supply(f.name) self.standalone.supply(f.name)
with self.assertRaises(SlapOSNodeCommandError) as e: with self.assertRaises(SlapOSNodeSoftwareError) as e:
self.standalone.waitForSoftware() self.standalone.waitForSoftware()
self.assertEqual(1, e.exception.args[0]['exitstatus']) self.assertEqual(1, e.exception.args[0]['exitstatus'])
self.assertIn( self.assertIn(
"Error: Non zero exit code (123) while running command.", "Error: Non zero exit code (123) while running command.",
e.exception.args[0]['output']) e.exception.args[0]['output'])
# SlapOSNodeCommandError.__str__ also displays output nicely # SlapOSNodeSoftwareError.__str__ also displays output nicely
self.assertIn( self.assertIn(
"SlapOSNodeCommandError exitstatus: 1 output:", str(e.exception)) "SlapOSNodeSoftwareError exitstatus: 1 output:", str(e.exception))
self.assertIn( self.assertIn(
"Error: Non zero exit code (123) while running command.", "Error: Non zero exit code (123) while running command.",
str(e.exception)) str(e.exception))
......
...@@ -24,14 +24,26 @@ ...@@ -24,14 +24,26 @@
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
# #
############################################################################## ##############################################################################
import functools
import json
import logging
import os import os
import slapos.util import shutil
from slapos.util import string_to_boolean, unicode2str
import tempfile import tempfile
import textwrap
import unittest import unittest
import shutil
from pwd import getpwnam from pwd import getpwnam
from six.moves import SimpleHTTPServer
import jsonschema
import slapos.util
from slapos.slap.slap import DEFAULT_SOFTWARE_TYPE
from slapos.testing.utils import ManagedHTTPServer
from slapos.util import (SoftwareReleaseSchema, SoftwareReleaseSerialisation,
string_to_boolean, unicode2str)
class TestUtil(unittest.TestCase): class TestUtil(unittest.TestCase):
""" """
Tests methods available in the slapos.util module. Tests methods available in the slapos.util module.
...@@ -232,5 +244,221 @@ class TestUtil(unittest.TestCase): ...@@ -232,5 +244,221 @@ class TestUtil(unittest.TestCase):
self.assertRaises(Exception, slapos.util.dumps, Nasty()) self.assertRaises(Exception, slapos.util.dumps, Nasty())
class SoftwareReleaseSchemaTestXmlSerialisationMixin:
serialisation = SoftwareReleaseSerialisation.Xml
class SoftwareReleaseSchemaTestJsonInXmlSerialisationMixin:
serialisation = SoftwareReleaseSerialisation.JsonInXml
class SoftwareReleaseSchemaTestMixin(object):
"""Mixin with test methods
"""
software_url = None # type: str
serialisation = None # type: SoftwareReleaseSerialisation
def test_software_schema(self):
schema = SoftwareReleaseSchema(self.software_url, None)
software_schema = schema.getSoftwareSchema()
self.assertEqual(software_schema['name'], 'Test Software')
self.assertEqual(len(software_schema['software-type']), 2)
def test_serialisation(self):
schema = SoftwareReleaseSchema(self.software_url, None)
self.assertEqual(schema.getSerialisation(), self.serialisation)
def test_instance_request_parameter_schema_default_software_type(self):
schema = SoftwareReleaseSchema(self.software_url, None)
self.assertTrue(schema.getInstanceRequestParameterSchemaURL())
instance_parameter_schema = schema.getInstanceRequestParameterSchema()
self.assertEqual(instance_parameter_schema['description'],
"Simple instance parameters schema for tests")
def test_connection_parameter_schema(self):
schema = SoftwareReleaseSchema(self.software_url, None)
self.assertTrue(schema.getInstanceConnectionParameterSchemaURL())
instance_parameter_schema = schema.getInstanceConnectionParameterSchema()
self.assertEqual(instance_parameter_schema['description'],
"Simple connection parameters schema for tests")
def test_instance_request_parameter_validate_default_software_type(self):
schema = SoftwareReleaseSchema(self.software_url, None)
self.assertTrue(schema.getInstanceRequestParameterSchemaURL())
instance_ok = {'key': 'value', 'type': 'default'}
schema.validateInstanceParameterDict(instance_ok)
if self.serialisation == SoftwareReleaseSerialisation.JsonInXml:
# already serialized values are also tolerated
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
with self.assertRaises(jsonschema.ValidationError):
schema.validateInstanceParameterDict({"wrong": True})
instance_ok['key'] = False # wrong type
with self.assertRaises(jsonschema.ValidationError):
schema.validateInstanceParameterDict(instance_ok)
with self.assertRaises(jsonschema.ValidationError):
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
def test_instance_request_parameter_validate_alternate_software_type(self):
schema = SoftwareReleaseSchema(self.software_url, 'alternate')
self.assertTrue(schema.getInstanceRequestParameterSchemaURL())
instance_ok = {'key': 'value', 'type': 'alternate'}
schema.validateInstanceParameterDict(instance_ok)
if self.serialisation == SoftwareReleaseSerialisation.JsonInXml:
# already serialized values are also tolerated
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
with self.assertRaises(jsonschema.ValidationError):
schema.validateInstanceParameterDict({"wrong": True})
instance_ok['type'] = 'wrong'
with self.assertRaises(jsonschema.ValidationError):
schema.validateInstanceParameterDict(instance_ok)
with self.assertRaises(jsonschema.ValidationError):
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
def test_instance_request_parameter_schema_alternate_software_type(self):
schema = SoftwareReleaseSchema(self.software_url, 'alternate')
self.assertTrue(schema.getInstanceRequestParameterSchemaURL())
instance_parameter_schema = schema.getInstanceRequestParameterSchema()
self.assertEqual(instance_parameter_schema['description'],
"Simple instance parameters schema for tests")
class SoftwareReleaseSchemaTestFileSoftwareReleaseMixin(SoftwareReleaseSchemaTestMixin):
"""Mixin with tests and software release profiles and schema in a
temporary directory.
"""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmpdir)
tmpfile = functools.partial(os.path.join, self.tmpdir)
with open(tmpfile('software.cfg'), 'w') as f:
f.write(
textwrap.dedent("""\
[buildout]
"""))
with open(tmpfile('software.cfg.json'), 'w') as f:
json.dump(
{
"name": "Test Software",
"description": "Dummy software for Test",
"serialisation": self.serialisation,
"software-type": {
DEFAULT_SOFTWARE_TYPE: {
"title": "Default",
"description": "Default type",
"request": "instance-default-input-schema.json",
"response": "instance-default-output-schema.json",
"index": 0
},
"alternate": {
"title": "Alternate",
"description": "Alternate type",
"request": "instance-alternate-input-schema.json",
"response": "instance-alternate-output-schema.json",
"index": 0
},
}
}, f)
for software_type in ('default', 'alternate'):
with open(
tmpfile('instance-{software_type}-input-schema.json'.format(
software_type=software_type)), 'w') as f:
json.dump(
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "Simple instance parameters schema for tests",
"required": ["key", "type"],
"properties": {
"key": {
"$ref": "./schemas-definitions.json#/key"
},
"type": {
"type": "string",
"const": software_type
}
},
"type": "object"
}, f)
with open(
tmpfile('instance-{software_type}-output-schema.json'.format(
software_type=software_type)), 'w') as f:
json.dump(
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "Simple connection parameters schema for tests",
}, f)
with open(tmpfile('schemas-definitions.json'), 'w') as f:
json.dump({"key": {"type": "string"}}, f)
self.software_url = tmpfile('software.cfg')
class SoftwareReleaseSchemaTestHTTPSoftwareReleaseMixin(SoftwareReleaseSchemaTestFileSoftwareReleaseMixin):
"""Mixin serving software release files over http.
"""
def setUp(self):
super(SoftwareReleaseSchemaTestHTTPSoftwareReleaseMixin, self).setUp()
class ProfileHTTPServer(ManagedHTTPServer):
hostname = os.environ['SLAPOS_TEST_IPV4']
working_directory = self.tmpdir
RequestHandler = SimpleHTTPServer.SimpleHTTPRequestHandler
self.logger = logging.getLogger(self.id())
self.logger.propagate = False
server = ProfileHTTPServer(self, 'server')
server.open()
self.addCleanup(server.close)
self.software_url = server.url + '/software.cfg'
class TestSoftwareReleaseSchemaFileSoftwareReleaseXmlSerialisation(
SoftwareReleaseSchemaTestXmlSerialisationMixin,
SoftwareReleaseSchemaTestFileSoftwareReleaseMixin,
unittest.TestCase):
pass
class TestSoftwareReleaseSchemaFileSoftwareReleaseJsonInXmlSerialisation(
SoftwareReleaseSchemaTestJsonInXmlSerialisationMixin,
SoftwareReleaseSchemaTestFileSoftwareReleaseMixin,
unittest.TestCase):
pass
class TestSoftwareReleaseSchemaHTTPSoftwareReleaseXmlSerialisation(
SoftwareReleaseSchemaTestXmlSerialisationMixin,
SoftwareReleaseSchemaTestHTTPSoftwareReleaseMixin,
unittest.TestCase):
pass
class TestSoftwareReleaseSchemaHTTPSoftwareReleaseJsonInXmlSerialisation(
SoftwareReleaseSchemaTestJsonInXmlSerialisationMixin,
SoftwareReleaseSchemaTestHTTPSoftwareReleaseMixin,
unittest.TestCase):
pass
class TestSoftwareReleaseSchemaEdgeCases(unittest.TestCase):
def test_software_schema_file_not_exist(self):
schema = SoftwareReleaseSchema('/file/not/exist', None)
self.assertIsNone(schema.getSoftwareSchema())
def test_software_schema_wrong_URL(self):
schema = SoftwareReleaseSchema('http://slapos.invalid/software.cfg', None)
self.assertIsNone(schema.getSoftwareSchema())
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
...@@ -27,19 +27,32 @@ ...@@ -27,19 +27,32 @@
# #
############################################################################## ##############################################################################
import enum
import errno import errno
import hashlib
import json
import os import os
import shutil
import socket import socket
import sqlite3
import struct import struct
import subprocess import subprocess
import sqlite3 import warnings
from xml_marshaller.xml_marshaller import Marshaller, Unmarshaller
from lxml import etree import jsonschema
import netaddr
import requests
import six import six
from lxml import etree
from six.moves.urllib import parse from six.moves.urllib import parse
import hashlib from six.moves.urllib_parse import urljoin
import netaddr from xml_marshaller.xml_marshaller import Marshaller, Unmarshaller
import shutil
try:
from typing import Dict, Optional, IO
except ImportError:
pass
try: try:
...@@ -295,3 +308,148 @@ def rmtree(path): ...@@ -295,3 +308,148 @@ def rmtree(path):
raise e # XXX make pylint happy raise e # XXX make pylint happy
shutil.rmtree(path, onerror=chmod_retry) shutil.rmtree(path, onerror=chmod_retry)
def _readAsJson(url):
# type: (str) -> Optional[Dict]
"""Reads and parse the json file located at `url`.
`url` can also be the path of a local file.
"""
if url.startswith('file://'):
url = url[len('file://'):]
path = url if os.path.exists(url) else None
if path:
with open(path) as f:
try:
return json.load(f)
except ValueError:
return None
if url.startswith('http://') or url.startswith('https://'):
try:
r = requests.get(url)
r.raise_for_status()
return r.json()
except (requests.exceptions.RequestException, ValueError):
return None
return None
class SoftwareReleaseSerialisation(str, enum.Enum):
Xml = 'xml'
JsonInXml = 'json-in-xml'
class SoftwareReleaseSchema(object):
def __init__(self, software_url, software_type):
# type: (str, Optional[str]) -> None
self.software_url = software_url
self.software_type = software_type
def getSoftwareSchema(self):
# type: () -> Optional[Dict]
"""Returns the schema for this software.
"""
return _readAsJson(self.software_url + '.json')
def getSoftwareTypeSchema(self):
# type: () -> Optional[Dict]
"""Returns schema for this software type.
"""
software_schema = self.getSoftwareSchema()
if software_schema is None:
return None
software_type = self.software_type
from slapos.slap.slap import DEFAULT_SOFTWARE_TYPE # TODO
if software_type is None:
software_type = DEFAULT_SOFTWARE_TYPE
# XXX some software are using "default" for default software type
if software_type == DEFAULT_SOFTWARE_TYPE \
and software_type not in software_schema['software-type'] \
and 'default' in software_schema['software-type']:
warnings.warn(
"Software release {} does not have schema for DEFAULT_SOFTWARE_TYPE but has one for 'default'."
" Using 'default' instead.".format(self.software_url),
UserWarning,
)
software_type = 'default'
return software_schema['software-type'].get(software_type)
def getSerialisation(self):
# type: () -> Optional[SoftwareReleaseSerialisation]
"""Returns the serialisation method used for parameters.
"""
software_schema = self.getSoftwareSchema()
if software_schema is None:
return None
return SoftwareReleaseSerialisation(software_schema['serialisation'])
def getInstanceRequestParameterSchemaURL(self):
# type: () -> Optional[str]
"""Returns the URL of the schema defining instance parameters.
"""
software_type_schema = self.getSoftwareTypeSchema()
if software_type_schema is None:
return None
software_url = self.software_url
if os.path.exists(software_url):
software_url = 'file://' + software_url
return urljoin(software_url, software_type_schema['request'])
def getInstanceRequestParameterSchema(self):
# type: () -> Optional[Dict]
"""Returns the schema defining instance parameters.
"""
instance_parameter_schema_url = self.getInstanceRequestParameterSchemaURL()
if instance_parameter_schema_url is None:
return None
schema = _readAsJson(instance_parameter_schema_url)
if schema:
# so that jsonschema knows how to resolve references
schema.setdefault('$id', instance_parameter_schema_url)
return schema
def getInstanceConnectionParameterSchemaURL(self):
# type: () -> Optional[str]
"""Returns the URL of the schema defining connection parameters published by the instance.
"""
software_type_schema = self.getSoftwareTypeSchema()
if software_type_schema is None:
return None
return urljoin(self.software_url, software_type_schema['response'])
def getInstanceConnectionParameterSchema(self):
# type: () -> Optional[Dict]
"""Returns the schema defining connection parameters published by the instance.
"""
instance_parameter_schema_url = self.getInstanceConnectionParameterSchemaURL()
if instance_parameter_schema_url is None:
return None
schema = _readAsJson(instance_parameter_schema_url)
if schema:
# so that jsonschema knows how to resolve references
schema.setdefault('$id', instance_parameter_schema_url)
return schema
def validateInstanceParameterDict(self, parameter_dict):
# type: (Dict) -> None
"""Validate instance parameters against the software schema.
Raise jsonschema.ValidationError if parameters does not validate.
"""
schema_url = self.getInstanceRequestParameterSchemaURL()
if schema_url:
instance = parameter_dict
if self.getSerialisation() == SoftwareReleaseSerialisation.JsonInXml:
try:
instance = json.loads(parameter_dict['_'])
except KeyError:
instance = parameter_dict
instance.pop('$schema', None)
jsonschema.validate(
instance=instance,
schema=self.getInstanceRequestParameterSchema(),
)
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