diff --git a/charmtools/charms.py b/charmtools/charms.py index 565bcaf4..a9f62d1e 100644 --- a/charmtools/charms.py +++ b/charmtools/charms.py @@ -17,7 +17,7 @@ from charmtools.linter import Linter from charmtools.utils import validate_display_name -KNOWN_METADATA_KEYS = [ +KNOWN_METADATA_KEYS = ( 'name', 'display-name', 'summary', @@ -39,16 +39,17 @@ 'terms', 'resources', 'devices', -] + 'deployment', +) -REQUIRED_METADATA_KEYS = [ +REQUIRED_METADATA_KEYS = ( 'name', 'summary', -] +) -KNOWN_RELATION_KEYS = ['interface', 'scope', 'limit', 'optional'] +KNOWN_RELATION_KEYS = ('interface', 'scope', 'limit', 'optional') -KNOWN_SCOPES = ['global', 'container'] +KNOWN_SCOPES = ('global', 'container') TEMPLATE_PATH = os.path.abspath(os.path.dirname(__file__)) @@ -331,6 +332,7 @@ def proof(self): validate_payloads(charm, lint, proof_extensions.get('payloads')) validate_terms(charm, lint) validate_resources(charm, lint, proof_extensions.get('resources')) + validate_deployment(charm, lint, proof_extensions.get('deployment')) if not os.path.exists(os.path.join(charm_path, 'icon.svg')): lint.info("No icon.svg file.") @@ -562,12 +564,42 @@ def schema_type(self, **kw): colander.String(), name='type', ) + count = colander.SchemaNode( colander.Integer(), missing=1, ) +class DeploymentItem(colander.MappingSchema): + def schema_type(self, **kw): + return StrictMapping() + + type_ = colander.SchemaNode( + colander.String(), + validator=colander.OneOf(['stateless', 'stateful']), + name='type', + ) + + service = colander.SchemaNode( + colander.String(), + validator=colander.OneOf(['loadbalancer', 'cluster', 'omit']), + name='service', + ) + + daemonset = colander.SchemaNode( + Boolean(), + name='daemonset', + missing=False, + ) + + min_version = colander.SchemaNode( + colander.String(), + name='min-version', + missing='', + ) + + class StorageItem(colander.MappingSchema): def schema_type(self, **kw): return StrictMapping() @@ -701,6 +733,38 @@ def validate_resources(charm, linter, proof_extensions=None): linter.err('resources.{}: {}'.format(k, v)) +def validate_deployment(charm, linter, proof_extensions=None): + """Validate deployment in charm metadata. + + :param charm: dict of charm metadata parsed from metadata.yaml + :param linter: :class:`CharmLinter` object to which info/warning/error + messages will be written + + """ + + deployment = charm.get('deployment', {}) + if deployment == {}: + return + + if not isinstance(deployment, dict): + linter.err('deployment: must be a dict of config') + return + + deployment = dict(deployment=deployment) + schema = colander.SchemaNode(colander.Mapping()) + for item in deployment: + schema.add(DeploymentItem(name=item)) + + try: + try: + schema.deserialize(deployment) + except colander.Invalid as e: + _try_proof_extensions(e, proof_extensions) + except colander.Invalid as e: + for k, v in e.asdict().items(): + linter.err('deployment.{}: {}'.format(k, v)) + + def validate_extra_bindings(charm, linter): """Validate extra-bindings in charm metadata. diff --git a/tests/test_charm_proof.py b/tests/test_charm_proof.py index ec91be47..64a8caf1 100644 --- a/tests/test_charm_proof.py +++ b/tests/test_charm_proof.py @@ -43,6 +43,7 @@ from charmtools.charms import validate_actions # noqa from charmtools.charms import validate_terms # noqa from charmtools.charms import validate_resources # noqa +from charmtools.charms import validate_deployment # noqa class TestCharmProof(TestCase): @@ -814,6 +815,155 @@ def test_storage_proof_extensions(self): self.assertEqual(linter.err.call_args_list, []) +class DeploymentValidationTest(TestCase): + def test_deployment(self): + """Charm has valid deployment.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'stateful', + 'service': 'omit', + 'daemonset': True, + 'min-version': "1.15.0", + } + } + validate_deployment(charm, linter) + self.assertFalse(linter.err.called) + + def test_invalid_deployment(self): + """Charm has invalid deployment.""" + linter = Mock() + charm = { + 'deployment': [], + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call('deployment: must be a dict of config'), + ], any_order=True) + + def test_deployment_unsupported_field(self): + """Charm has the invalid deployment field.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'stateful', + 'service': 'omit', + 'daemonset': True, + 'min-version': "1.15.0", + 'unknow-field': 'xxx', + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call('deployment.deployment: Unrecognized keys in mapping: "{\'unknow-field\': \'xxx\'}"'), + ], any_order=True) + + + def test_deployment_invalid_type(self): + """Charm has the invalid deployment type.""" + linter = Mock() + charm = { + 'deployment': { + 'type': True, + 'service': 'omit', + 'daemonset': True, + 'min-version': "1.15.0", + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call("deployment.deployment.type: True is not a string: {'type': ''}"), + ], any_order=True) + + def test_deployment_unsupported_type(self): + """Charm has the unsupported deployment type.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'foo', + 'service': 'omit', + 'daemonset': True, + 'min-version': "1.15.0", + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call('deployment.deployment.type: "foo" is not one of stateless, stateful'), + ], any_order=True) + + def test_deployment_invalid_service(self): + """Charm has the invalid deployment service.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'stateful', + 'service': 1, + 'daemonset': True, + 'min-version': "1.15.0", + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call("deployment.deployment.service: 1 is not a string: {'service': ''}"), + ], any_order=True) + + def test_deployment_unsupported_service(self): + """Charm has the unsupported deployment service.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'stateful', + 'service': 'foo', + 'daemonset': True, + 'min-version': "1.15.0", + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call('deployment.deployment.service: "foo" is not one of loadbalancer, cluster, omit'), + ], any_order=True) + + def test_deployment_invalid_daemonset(self): + """Charm has the invalid deployment daemonset.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'stateful', + 'service': 'omit', + 'daemonset': 'xx', + 'min-version': "1.15.0", + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call('deployment.deployment.daemonset: "xx" is not one of true, false'), + ], any_order=True) + + def test_deployment_invalid_min_version(self): + """Charm has the invalid deployment min-version.""" + linter = Mock() + charm = { + 'deployment': { + 'type': 'stateful', + 'service': 'omit', + 'daemonset': True, + 'min-version': 1.15, + } + } + validate_deployment(charm, linter) + self.assertEqual(linter.err.call_count, 1) + linter.err.assert_has_calls([ + call("deployment.deployment.min-version: 1.15 is not a string: {'min-version': ''}"), + ], any_order=True) + + class ResourcesValidationTest(TestCase): def test_minimal_resources_config(self): """Charm has the minimum allowed resources configuration."""