summaryrefslogtreecommitdiffstats
path: root/src/python-common/ceph/tests/test_service_spec.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/python-common/ceph/tests/test_service_spec.py')
-rw-r--r--src/python-common/ceph/tests/test_service_spec.py866
1 files changed, 866 insertions, 0 deletions
diff --git a/src/python-common/ceph/tests/test_service_spec.py b/src/python-common/ceph/tests/test_service_spec.py
new file mode 100644
index 000000000..d3fb43296
--- /dev/null
+++ b/src/python-common/ceph/tests/test_service_spec.py
@@ -0,0 +1,866 @@
+# flake8: noqa
+import json
+import re
+
+import yaml
+
+import pytest
+
+from ceph.deployment.service_spec import HostPlacementSpec, PlacementSpec, \
+ ServiceSpec, RGWSpec, NFSServiceSpec, IscsiServiceSpec, AlertManagerSpec, \
+ CustomContainerSpec
+from ceph.deployment.drive_group import DriveGroupSpec
+from ceph.deployment.hostspec import SpecValidationError
+
+
+@pytest.mark.parametrize("test_input,expected, require_network",
+ [("myhost", ('myhost', '', ''), False),
+ ("myhost=sname", ('myhost', '', 'sname'), False),
+ ("myhost:10.1.1.10", ('myhost', '10.1.1.10', ''), True),
+ ("myhost:10.1.1.10=sname", ('myhost', '10.1.1.10', 'sname'), True),
+ ("myhost:10.1.1.0/32", ('myhost', '10.1.1.0/32', ''), True),
+ ("myhost:10.1.1.0/32=sname", ('myhost', '10.1.1.0/32', 'sname'), True),
+ ("myhost:[v1:10.1.1.10:6789]", ('myhost', '[v1:10.1.1.10:6789]', ''), True),
+ ("myhost:[v1:10.1.1.10:6789]=sname", ('myhost', '[v1:10.1.1.10:6789]', 'sname'), True),
+ ("myhost:[v1:10.1.1.10:6789,v2:10.1.1.11:3000]", ('myhost', '[v1:10.1.1.10:6789,v2:10.1.1.11:3000]', ''), True),
+ ("myhost:[v1:10.1.1.10:6789,v2:10.1.1.11:3000]=sname", ('myhost', '[v1:10.1.1.10:6789,v2:10.1.1.11:3000]', 'sname'), True),
+ ])
+def test_parse_host_placement_specs(test_input, expected, require_network):
+ ret = HostPlacementSpec.parse(test_input, require_network=require_network)
+ assert ret == expected
+ assert str(ret) == test_input
+
+ ps = PlacementSpec.from_string(test_input)
+ assert ps.pretty_str() == test_input
+ assert ps == PlacementSpec.from_string(ps.pretty_str())
+
+ # Testing the old verbose way of generating json. Don't remove:
+ assert ret == HostPlacementSpec.from_json({
+ 'hostname': ret.hostname,
+ 'network': ret.network,
+ 'name': ret.name
+ })
+
+ assert ret == HostPlacementSpec.from_json(ret.to_json())
+
+
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ ('', "PlacementSpec()"),
+ ("count:2", "PlacementSpec(count=2)"),
+ ("3", "PlacementSpec(count=3)"),
+ ("host1 host2", "PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacementSpec(hostname='host2', network='', name='')])"),
+ ("host1;host2", "PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacementSpec(hostname='host2', network='', name='')])"),
+ ("host1,host2", "PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacementSpec(hostname='host2', network='', name='')])"),
+ ("host1 host2=b", "PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacementSpec(hostname='host2', network='', name='b')])"),
+ ("host1=a host2=b", "PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name='a'), HostPlacementSpec(hostname='host2', network='', name='b')])"),
+ ("host1:1.2.3.4=a host2:1.2.3.5=b", "PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='1.2.3.4', name='a'), HostPlacementSpec(hostname='host2', network='1.2.3.5', name='b')])"),
+ ("myhost:[v1:10.1.1.10:6789]", "PlacementSpec(hosts=[HostPlacementSpec(hostname='myhost', network='[v1:10.1.1.10:6789]', name='')])"),
+ ('2 host1 host2', "PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacementSpec(hostname='host2', network='', name='')])"),
+ ('label:foo', "PlacementSpec(label='foo')"),
+ ('3 label:foo', "PlacementSpec(count=3, label='foo')"),
+ ('*', "PlacementSpec(host_pattern='*')"),
+ ('3 data[1-3]', "PlacementSpec(count=3, host_pattern='data[1-3]')"),
+ ('3 data?', "PlacementSpec(count=3, host_pattern='data?')"),
+ ('3 data*', "PlacementSpec(count=3, host_pattern='data*')"),
+ ("count-per-host:4 label:foo", "PlacementSpec(count_per_host=4, label='foo')"),
+ ])
+def test_parse_placement_specs(test_input, expected):
+ ret = PlacementSpec.from_string(test_input)
+ assert str(ret) == expected
+ assert PlacementSpec.from_string(ret.pretty_str()) == ret, f'"{ret.pretty_str()}" != "{test_input}"'
+
+@pytest.mark.parametrize(
+ "test_input",
+ [
+ ("host=a host*"),
+ ("host=a label:wrong"),
+ ("host? host*"),
+ ('host=a count-per-host:0'),
+ ('host=a count-per-host:-10'),
+ ('count:2 count-per-host:1'),
+ ('host1=a host2=b count-per-host:2'),
+ ('host1:10/8 count-per-host:2'),
+ ('count-per-host:2'),
+ ]
+)
+def test_parse_placement_specs_raises(test_input):
+ with pytest.raises(SpecValidationError):
+ PlacementSpec.from_string(test_input)
+
+@pytest.mark.parametrize("test_input",
+ # wrong subnet
+ [("myhost:1.1.1.1/24"),
+ # wrong ip format
+ ("myhost:1"),
+ ])
+def test_parse_host_placement_specs_raises_wrong_format(test_input):
+ with pytest.raises(ValueError):
+ HostPlacementSpec.parse(test_input)
+
+
+@pytest.mark.parametrize(
+ "p,hosts,size",
+ [
+ (
+ PlacementSpec(count=3),
+ ['host1', 'host2', 'host3', 'host4', 'host5'],
+ 3
+ ),
+ (
+ PlacementSpec(host_pattern='*'),
+ ['host1', 'host2', 'host3', 'host4', 'host5'],
+ 5
+ ),
+ (
+ PlacementSpec(count_per_host=2, host_pattern='*'),
+ ['host1', 'host2', 'host3', 'host4', 'host5'],
+ 10
+ ),
+ (
+ PlacementSpec(host_pattern='foo*'),
+ ['foo1', 'foo2', 'bar1', 'bar2'],
+ 2
+ ),
+ (
+ PlacementSpec(count_per_host=2, host_pattern='foo*'),
+ ['foo1', 'foo2', 'bar1', 'bar2'],
+ 4
+ ),
+ ])
+def test_placement_target_size(p, hosts, size):
+ assert p.get_target_count(
+ [HostPlacementSpec(n, '', '') for n in hosts]
+ ) == size
+
+
+def _get_dict_spec(s_type, s_id):
+ dict_spec = {
+ "service_id": s_id,
+ "service_type": s_type,
+ "placement":
+ dict(hosts=["host1:1.1.1.1"])
+ }
+ if s_type == 'nfs':
+ pass
+ elif s_type == 'iscsi':
+ dict_spec['pool'] = 'pool'
+ dict_spec['api_user'] = 'api_user'
+ dict_spec['api_password'] = 'api_password'
+ elif s_type == 'osd':
+ dict_spec['spec'] = {
+ 'data_devices': {
+ 'all': True
+ }
+ }
+ elif s_type == 'rgw':
+ dict_spec['rgw_realm'] = 'realm'
+ dict_spec['rgw_zone'] = 'zone'
+
+ return dict_spec
+
+
+@pytest.mark.parametrize(
+ "s_type,o_spec,s_id",
+ [
+ ("mgr", ServiceSpec, 'test'),
+ ("mon", ServiceSpec, 'test'),
+ ("mds", ServiceSpec, 'test'),
+ ("rgw", RGWSpec, 'realm.zone'),
+ ("nfs", NFSServiceSpec, 'test'),
+ ("iscsi", IscsiServiceSpec, 'test'),
+ ("osd", DriveGroupSpec, 'test'),
+ ])
+def test_servicespec_map_test(s_type, o_spec, s_id):
+ spec = ServiceSpec.from_json(_get_dict_spec(s_type, s_id))
+ assert isinstance(spec, o_spec)
+ assert isinstance(spec.placement, PlacementSpec)
+ assert isinstance(spec.placement.hosts[0], HostPlacementSpec)
+ assert spec.placement.hosts[0].hostname == 'host1'
+ assert spec.placement.hosts[0].network == '1.1.1.1'
+ assert spec.placement.hosts[0].name == ''
+ assert spec.validate() is None
+ ServiceSpec.from_json(spec.to_json())
+
+def test_osd_unmanaged():
+ osd_spec = {"placement": {"host_pattern": "*"},
+ "service_id": "all-available-devices",
+ "service_name": "osd.all-available-devices",
+ "service_type": "osd",
+ "spec": {"data_devices": {"all": True}, "filter_logic": "AND", "objectstore": "bluestore"},
+ "unmanaged": True}
+
+ dg_spec = ServiceSpec.from_json(osd_spec)
+ assert dg_spec.unmanaged == True
+
+
+@pytest.mark.parametrize("y",
+"""service_type: crash
+service_name: crash
+placement:
+ host_pattern: '*'
+---
+service_type: crash
+service_name: crash
+placement:
+ host_pattern: '*'
+unmanaged: true
+---
+service_type: rgw
+service_id: default-rgw-realm.eu-central-1.1
+service_name: rgw.default-rgw-realm.eu-central-1.1
+placement:
+ hosts:
+ - ceph-001
+networks:
+- 10.0.0.0/8
+- 192.168.0.0/16
+spec:
+ rgw_frontend_type: civetweb
+ rgw_realm: default-rgw-realm
+ rgw_zone: eu-central-1
+---
+service_type: osd
+service_id: osd_spec_default
+service_name: osd.osd_spec_default
+placement:
+ host_pattern: '*'
+spec:
+ data_devices:
+ model: MC-55-44-XZ
+ db_devices:
+ model: SSD-123-foo
+ filter_logic: AND
+ objectstore: bluestore
+ wal_devices:
+ model: NVME-QQQQ-987
+---
+service_type: alertmanager
+service_name: alertmanager
+spec:
+ port: 1234
+ user_data:
+ default_webhook_urls:
+ - foo
+---
+service_type: grafana
+service_name: grafana
+spec:
+ port: 1234
+---
+service_type: grafana
+service_name: grafana
+spec:
+ initial_admin_password: secure
+ port: 1234
+---
+service_type: ingress
+service_id: rgw.foo
+service_name: ingress.rgw.foo
+placement:
+ hosts:
+ - host1
+ - host2
+ - host3
+spec:
+ backend_service: rgw.foo
+ frontend_port: 8080
+ monitor_port: 8081
+ virtual_ip: 192.168.20.1/24
+---
+service_type: nfs
+service_id: mynfs
+service_name: nfs.mynfs
+spec:
+ port: 1234
+---
+service_type: iscsi
+service_id: iscsi
+service_name: iscsi.iscsi
+networks:
+- ::0/8
+spec:
+ api_user: api_user
+ pool: pool
+ trusted_ip_list:
+ - ::1
+ - ::2
+---
+service_type: container
+service_id: hello-world
+service_name: container.hello-world
+spec:
+ args:
+ - --foo
+ bind_mounts:
+ - - type=bind
+ - source=lib/modules
+ - destination=/lib/modules
+ - ro=true
+ dirs:
+ - foo
+ - bar
+ entrypoint: /usr/bin/bash
+ envs:
+ - FOO=0815
+ files:
+ bar.conf:
+ - foo
+ - bar
+ foo.conf: 'foo
+
+ bar'
+ gid: 2000
+ image: docker.io/library/hello-world:latest
+ ports:
+ - 8080
+ - 8443
+ uid: 1000
+ volume_mounts:
+ foo: /foo
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_community: public
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V2c
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ auth_protocol: MD5
+ credentials:
+ snmp_v3_auth_password: mypassword
+ snmp_v3_auth_username: myuser
+ engine_id: 8000C53F00000000
+ port: 9464
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V3
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_password: mypassword
+ snmp_v3_auth_username: myuser
+ snmp_v3_priv_password: mysecret
+ engine_id: 8000C53F00000000
+ privacy_protocol: AES
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V3
+""".split('---\n'))
+def test_yaml(y):
+ data = yaml.safe_load(y)
+ object = ServiceSpec.from_json(data)
+
+ assert yaml.dump(object) == y
+ assert yaml.dump(ServiceSpec.from_json(object.to_json())) == y
+
+
+def test_alertmanager_spec_1():
+ spec = AlertManagerSpec()
+ assert spec.service_type == 'alertmanager'
+ assert isinstance(spec.user_data, dict)
+ assert len(spec.user_data.keys()) == 0
+ assert spec.get_port_start() == [9093, 9094]
+
+
+def test_alertmanager_spec_2():
+ spec = AlertManagerSpec(user_data={'default_webhook_urls': ['foo']})
+ assert isinstance(spec.user_data, dict)
+ assert 'default_webhook_urls' in spec.user_data.keys()
+
+
+
+def test_repr():
+ val = """ServiceSpec.from_json(yaml.safe_load('''service_type: crash
+service_name: crash
+placement:
+ count: 42
+'''))"""
+ obj = eval(val)
+ assert obj.service_type == 'crash'
+ assert val == repr(obj)
+
+@pytest.mark.parametrize("spec1, spec2, eq",
+ [
+ (
+ ServiceSpec(
+ service_type='mon'
+ ),
+ ServiceSpec(
+ service_type='mon'
+ ),
+ True
+ ),
+ (
+ ServiceSpec(
+ service_type='mon'
+ ),
+ ServiceSpec(
+ service_type='mon',
+ service_id='foo'
+ ),
+ True
+ ),
+ # Add service_type='mgr'
+ (
+ ServiceSpec(
+ service_type='osd'
+ ),
+ ServiceSpec(
+ service_type='osd',
+ ),
+ True
+ ),
+ (
+ ServiceSpec(
+ service_type='osd'
+ ),
+ DriveGroupSpec(),
+ True
+ ),
+ (
+ ServiceSpec(
+ service_type='osd'
+ ),
+ ServiceSpec(
+ service_type='osd',
+ service_id='foo',
+ ),
+ False
+ ),
+ (
+ ServiceSpec(
+ service_type='rgw',
+ service_id='foo',
+ ),
+ RGWSpec(service_id='foo'),
+ True
+ ),
+ ])
+def test_spec_hash_eq(spec1: ServiceSpec,
+ spec2: ServiceSpec,
+ eq: bool):
+
+ assert (spec1 == spec2) is eq
+
+@pytest.mark.parametrize(
+ "s_type,s_id,s_name",
+ [
+ ('mgr', 's_id', 'mgr'),
+ ('mon', 's_id', 'mon'),
+ ('mds', 's_id', 'mds.s_id'),
+ ('rgw', 's_id', 'rgw.s_id'),
+ ('nfs', 's_id', 'nfs.s_id'),
+ ('iscsi', 's_id', 'iscsi.s_id'),
+ ('osd', 's_id', 'osd.s_id'),
+ ])
+def test_service_name(s_type, s_id, s_name):
+ spec = ServiceSpec.from_json(_get_dict_spec(s_type, s_id))
+ spec.validate()
+ assert spec.service_name() == s_name
+
+@pytest.mark.parametrize(
+ 's_type,s_id',
+ [
+ ('mds', 's:id'), # MDS service_id cannot contain an invalid char ':'
+ ('mds', '1abc'), # MDS service_id cannot start with a numeric digit
+ ('mds', ''), # MDS service_id cannot be empty
+ ('rgw', '*s_id'),
+ ('nfs', 's/id'),
+ ('iscsi', 's@id'),
+ ('osd', 's;id'),
+ ])
+
+def test_service_id_raises_invalid_char(s_type, s_id):
+ with pytest.raises(SpecValidationError):
+ spec = ServiceSpec.from_json(_get_dict_spec(s_type, s_id))
+ spec.validate()
+
+def test_custom_container_spec():
+ spec = CustomContainerSpec(service_id='hello-world',
+ image='docker.io/library/hello-world:latest',
+ entrypoint='/usr/bin/bash',
+ uid=1000,
+ gid=2000,
+ volume_mounts={'foo': '/foo'},
+ args=['--foo'],
+ envs=['FOO=0815'],
+ bind_mounts=[
+ [
+ 'type=bind',
+ 'source=lib/modules',
+ 'destination=/lib/modules',
+ 'ro=true'
+ ]
+ ],
+ ports=[8080, 8443],
+ dirs=['foo', 'bar'],
+ files={
+ 'foo.conf': 'foo\nbar',
+ 'bar.conf': ['foo', 'bar']
+ })
+ assert spec.service_type == 'container'
+ assert spec.entrypoint == '/usr/bin/bash'
+ assert spec.uid == 1000
+ assert spec.gid == 2000
+ assert spec.volume_mounts == {'foo': '/foo'}
+ assert spec.args == ['--foo']
+ assert spec.envs == ['FOO=0815']
+ assert spec.bind_mounts == [
+ [
+ 'type=bind',
+ 'source=lib/modules',
+ 'destination=/lib/modules',
+ 'ro=true'
+ ]
+ ]
+ assert spec.ports == [8080, 8443]
+ assert spec.dirs == ['foo', 'bar']
+ assert spec.files == {
+ 'foo.conf': 'foo\nbar',
+ 'bar.conf': ['foo', 'bar']
+ }
+
+
+def test_custom_container_spec_config_json():
+ spec = CustomContainerSpec(service_id='foo', image='foo', dirs=None)
+ config_json = spec.config_json()
+ for key in ['entrypoint', 'uid', 'gid', 'bind_mounts', 'dirs']:
+ assert key not in config_json
+
+
+def test_ingress_spec():
+ yaml_str = """service_type: ingress
+service_id: rgw.foo
+placement:
+ hosts:
+ - host1
+ - host2
+ - host3
+spec:
+ virtual_ip: 192.168.20.1/24
+ backend_service: rgw.foo
+ frontend_port: 8080
+ monitor_port: 8081
+"""
+ yaml_file = yaml.safe_load(yaml_str)
+ spec = ServiceSpec.from_json(yaml_file)
+ assert spec.service_type == "ingress"
+ assert spec.service_id == "rgw.foo"
+ assert spec.virtual_ip == "192.168.20.1/24"
+ assert spec.frontend_port == 8080
+ assert spec.monitor_port == 8081
+
+
+@pytest.mark.parametrize("y, error_match", [
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count_per_host: "twelve"
+""", "count-per-host must be a numeric value",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count_per_host: "2"
+""", "count-per-host must be an integer value",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count_per_host: 7.36
+""", "count-per-host must be an integer value",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count: "fifteen"
+""", "num/count must be a numeric value",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count: "4"
+""", "num/count must be an integer value",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count: 7.36
+""", "num/count must be an integer value",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count: 0
+""", "num/count must be >= 1",),
+ ("""
+service_type: rgw
+service_id: foo
+placement:
+ count_per_host: 0
+""", "count-per-host must be >= 1",),
+ ("""
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_password: mypassword
+ snmp_v3_auth_username: myuser
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ engine_id: 8000c53f0000000000
+ privacy_protocol: WEIRD
+ snmp_destination: 192.168.122.1:162
+ auth_protocol: BIZARRE
+ snmp_version: V3
+""", "auth_protocol unsupported. Must be one of MD5, SHA"),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_community: public
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V4
+""", 'snmp_version unsupported. Must be one of V2c, V3'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_community: public
+ port: 9464
+ snmp_destination: 192.168.1.42:162
+""", re.escape('Missing SNMP version (snmp_version)')),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ port: 9464
+ auth_protocol: wah
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V3
+""", 'auth_protocol unsupported. Must be one of MD5, SHA'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: weewah
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V3
+""", 'privacy_protocol unsupported. Must be one of DES, AES'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V3
+""", 'Must provide an engine_id for SNMP V3 notifications'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_community: public
+ port: 9464
+ snmp_destination: 192.168.1.42
+ snmp_version: V2c
+""", re.escape('SNMP destination (snmp_destination) type (IPv4) is invalid. Must be either: IPv4:Port, Name:Port')),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: bogus
+ snmp_destination: 192.168.1.42:162
+ snmp_version: V3
+""", 'engine_id must be a string containing 10-64 hex characters. Its length must be divisible by 2'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ port: 9464
+ auth_protocol: SHA
+ engine_id: 8000C53F0000000000
+ snmp_version: V3
+""", re.escape('SNMP destination (snmp_destination) must be provided')),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: 8000C53F0000000000
+ snmp_destination: my.imaginary.snmp-host
+ snmp_version: V3
+""", re.escape('SNMP destination (snmp_destination) is invalid: DNS lookup failed')),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: 8000C53F0000000000
+ snmp_destination: 10.79.32.10:fred
+ snmp_version: V3
+""", re.escape('SNMP destination (snmp_destination) is invalid: Port must be numeric')),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: 8000C53
+ snmp_destination: 10.79.32.10:162
+ snmp_version: V3
+""", 'engine_id must be a string containing 10-64 hex characters. Its length must be divisible by 2'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: 8000C53DOH!
+ snmp_destination: 10.79.32.10:162
+ snmp_version: V3
+""", 'engine_id must be a string containing 10-64 hex characters. Its length must be divisible by 2'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: 8000C53FCA7344403DC611EC9B985254002537A6C53FCA7344403DC6112537A60
+ snmp_destination: 10.79.32.10:162
+ snmp_version: V3
+""", 'engine_id must be a string containing 10-64 hex characters. Its length must be divisible by 2'),
+ ("""
+---
+service_type: snmp-gateway
+service_name: snmp-gateway
+placement:
+ count: 1
+spec:
+ credentials:
+ snmp_v3_auth_username: myuser
+ snmp_v3_auth_password: mypassword
+ snmp_v3_priv_password: mysecret
+ port: 9464
+ auth_protocol: SHA
+ privacy_protocol: AES
+ engine_id: 8000C53F00000
+ snmp_destination: 10.79.32.10:162
+ snmp_version: V3
+""", 'engine_id must be a string containing 10-64 hex characters. Its length must be divisible by 2'),
+ ])
+def test_service_spec_validation_error(y, error_match):
+ data = yaml.safe_load(y)
+ with pytest.raises(SpecValidationError) as err:
+ specObj = ServiceSpec.from_json(data)
+ assert err.match(error_match)