diff --git a/.gitignore b/.gitignore index 507daceeef..a4b69e54d3 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ python/vyos/xml_ref/pkg_cache/*_cache.py # We do not use pip Pipfile Pipfile.lock + +# KDE +.directory diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 27ed4c2963..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "files.trimTrailingWhitespace": true, - - // https://code.visualstudio.com/docs/languages/identifiers#_known-language-identifiers - "files.associations": { - "*.j2": "jinja", - "*.xml.i": "xml", - "*.xml.in": "xml", - }, - "[jinja]": { - "editor.wordBasedSuggestions": "off" - }, - // https://code.visualstudio.com/docs/python/settings-reference - "python.analysis.extraPaths": [ - "./python" - ], - // https://help.gitkraken.com/gitlens/gitlens-settings/#autolink-settings - "gitlens.autolinks": [ - { - "prefix": "T", - "url": "https://vyos.dev/T" - } - ], -} diff --git a/Makefile b/Makefile index cc382e2061..dc0e8dadee 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ interface_definitions: $(config_xml_obj) find $(BUILD_DIR)/interface-definitions -type f -name "*.xml" | xargs -I {} $(CURDIR)/scripts/build-command-templates {} $(CURDIR)/schema/interface_definition.rng $(TMPL_DIR) || exit 1 $(CURDIR)/python/vyos/xml_ref/generate_cache.py --xml-dir $(BUILD_DIR)/interface-definitions || exit 1 + $(CURDIR)/python/vyos/xml_ref/update_cache.py || exit 1 # XXX: delete top level node.def's that now live in other packages # IPSec VPN EAP-RADIUS does not support source-address diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index 6ea44a6d46..eda5d10424 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -13,6 +13,23 @@ [-a-zA-Z0-9]+ Container name must be alphanumeric and can contain hyphens + + + image + + + allow-host-networks + network + + + allow-host-networks + network + + + gid + uid + + @@ -89,6 +106,11 @@ + + + value + + @@ -104,6 +126,12 @@ Add a host device to the container + + + source + destination + + @@ -134,6 +162,11 @@ [-_a-zA-Z0-9]+ Environment variable name must be alphanumeric and can contain hyphen and underscores + + + value + + @@ -205,6 +238,11 @@ [a-z0-9](?:[a-z0-9.-]*[a-z0-9])? Label variable name must be alphanumeric and can contain hyphen, dots and underscores + + + value + + @@ -306,6 +344,12 @@ Publish port to the container + + + source + destination + + #include @@ -414,6 +458,12 @@ Mount a volume into the container + + + source + destination + + @@ -498,6 +548,11 @@ Network name #include + + + prefix + + #include @@ -525,6 +580,15 @@ Registry Name + + + authentication + + username + password + + + docker.io quay.io diff --git a/interface-definitions/interfaces_openvpn.xml.in b/interface-definitions/interfaces_openvpn.xml.in index 23cc83e9ab..4e8f7d9dd0 100644 --- a/interface-definitions/interfaces_openvpn.xml.in +++ b/interface-definitions/interfaces_openvpn.xml.in @@ -14,6 +14,133 @@ vtunN OpenVPN interface name + + + mode + + + + client + + remote_host + + + + client + local-port + local-host + dh-params + + + + + site-to-site + + remote-host + + + + site-to-site + ncp-ciphers + + + + + server + tcp-active + + + server + authentication + + + server + remote-host + + + server + remote-port + + + server + + site-to-site + client + + + + + username + password + + + + + start + stop + + + + + + + client + site-to-site + + reject-unconfigured-clients + + + + + + tcp-active + local-port + + + tcp-active + remote-host + + + + + shared_secret_key + + aes128gcm + aes192gcm + aes256gcm + + + + auth-key + crypt-key + + + + + active + + + tcp-passive + + + + + active + + + dh-params + + + + + passive + + + tcp-active + + + + #include diff --git a/python/vyos/config.py b/python/vyos/config.py index cca65f0eb9..a792ed25de 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -64,7 +64,8 @@ import re import json -from typing import Union +from typing import Union, Any +from copy import deepcopy import vyos.configtree from vyos.xml_ref import multi_to_list @@ -79,12 +80,24 @@ class ConfigDict(dict): _from_defaults = {} _dict_kwargs = {} + _raw_conf_dict: dict[str, Any] + _base: list[str] + def from_defaults(self, path: list[str]) -> bool: return from_source(self._from_defaults, path) + @property def kwargs(self) -> dict: return self._dict_kwargs + @property + def raw_conf_dict(self): + return self._raw_conf_dict + + @property + def base(self): + return self._base + def config_dict_merge(src: dict, dest: Union[dict, ConfigDict]) -> ConfigDict: if not isinstance(dest, ConfigDict): dest = ConfigDict(dest) @@ -290,7 +303,7 @@ def get_config_dict(self, path=[], effective=False, key_mangling=None, no_tag_node_value_mangle=False, with_defaults=False, with_recursive_defaults=False, - with_pki=False): + with_pki=False) -> ConfigDict: """ Args: path (str list): Configuration tree path, can be empty @@ -312,6 +325,8 @@ def get_config_dict(self, path=[], effective=False, key_mangling=None, root_dict = self.get_cached_root_dict(effective) conf_dict = get_sub_dict(root_dict, lpath, get_first_key=get_first_key) + raw_conf_dict = deepcopy(conf_dict) + rpath = lpath if get_first_key else lpath[:-1] if not no_multi_convert: @@ -346,6 +361,11 @@ def get_config_dict(self, path=[], effective=False, key_mangling=None, # save optional args for a call to get_config_defaults setattr(conf_dict, '_dict_kwargs', kwargs) + # save args that are reused during verification + setattr(conf_dict, '_raw_conf_dict', raw_conf_dict) + setattr(conf_dict, '_base', rpath) + + return conf_dict def get_config_defaults(self, path=[], effective=False, key_mangling=None, diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 5a353b1106..cbb971e0e7 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -21,6 +21,7 @@ from vyos.utils.dict import dict_search from vyos.utils.process import cmd +from vyos.config import Config, ConfigDict def retrieve_config(path_hash, base_path, config): """ @@ -425,7 +426,7 @@ def get_pppoe_interfaces(conf, vrf=None): return pppoe_interfaces -def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pki=False): +def get_interface_dict(config: Config, base, ifname='', recursive_defaults=True, with_pki=False) -> tuple[str, ConfigDict]: """ Common utility function to retrieve and mangle the interfaces configuration from the CLI input nodes. All interfaces have a common base where value diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 4cb84194aa..390b3a80a7 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -22,10 +22,44 @@ # makes use of it! from vyos import ConfigError +from vyos.config import ConfigDict from vyos.utils.dict import dict_search # pattern re-used in ipsec migration script dynamic_interface_pattern = r'(ppp|pppoe|sstpc|l2tp|ipoe)[0-9]+' +def verify_children(config: ConfigDict): + """ + Common helper function user by all to verify configuration specification + based on XML schema definition + """ + + # the recursive function is encapsulated to keep a minimal public interface + # with a well defined type as input parameter and reduce complexity + # when choosing the most practical types to recurse over + def _verify_children(rpath: list[str], raw_config_dict: dict): + from vyos.xml_ref import is_leaf, is_tag, is_tag_value, xml_child_specification + + # Loop over all tag_node children + if is_tag(rpath) and not is_tag_value(rpath): + for k, v in raw_config_dict.items(): + _verify_children(rpath + [k], v) + return + + # Leaf nodes has no children + if is_leaf(rpath): + return + + xml_specs = xml_child_specification(rpath) + if xml_specs: + xml_specs.verify(rpath, raw_config_dict) + + # Verify all childrens specs recursively + for k, v in raw_config_dict.items(): + _verify_children(rpath + [k], v) + + _verify_children(rpath=config.base, raw_config_dict=config.raw_conf_dict) + + def verify_mtu(config): """ Common helper function used by interface implementations to perform diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py index 2ba3da4e8a..0551b7c648 100644 --- a/python/vyos/xml_ref/__init__.py +++ b/python/vyos/xml_ref/__init__.py @@ -15,6 +15,7 @@ from typing import Optional, Union, TYPE_CHECKING from vyos.xml_ref import definition +from vyos.xml_ref.child_specification import CSChildSpecification if TYPE_CHECKING: from vyos.config import ConfigDict @@ -59,6 +60,9 @@ def owner(path: list) -> str: def priority(path: list) -> str: return load_reference().priority(path) +def xml_child_specification(path: list[str]) -> CSChildSpecification: + return load_reference().xml_child_specification(path) + def cli_defined(path: list, node: str, non_local=False) -> bool: return load_reference().cli_defined(path, node, non_local=non_local) diff --git a/python/vyos/xml_ref/child_specification.py b/python/vyos/xml_ref/child_specification.py new file mode 100644 index 0000000000..1be514757d --- /dev/null +++ b/python/vyos/xml_ref/child_specification.py @@ -0,0 +1,285 @@ +# Copyright 2023 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +""" +This file contains structures matching the XML schema for childSpecification, +intended to make it easier to work with a known structure instead of raw lists and dicts. + +Keeping the public interface to just the CSChildSpecification class makes it easier to do any later refactoring. +""" + +from typing import Any +from vyos.configsession import ConfigSessionError + +class ChildSpecificationError(ValueError): + pass + +class _CSChildName(str): + XML_NAME = "child" + +class _CSValueValue(str): + XML_NAME = "value" + +class _CSDescendant: + XML_NAME = "descendant" + + descendant: dict[_CSChildName, "_CSDescendant"] + child: list[_CSChildName] + value: list[_CSValueValue] + + def __init__(self, descendant_tree) -> None: + self.descendant = {} + self.child = [] + self.value = [] + + self._add(descendant_tree) + + def _add(self, descendant_tree): + for node in descendant_tree: + node_type = node[0] + node_spec = node[1] + match node_type: + case _CSDescendant.XML_NAME: + descendant_name = _CSChildName(node_spec[0]) + descendant_spec_grouping = node_spec[1] + if descendant_name in self.descendant: + self.descendant[descendant_name]._add(descendant_spec_grouping) + else: + self.descendant[descendant_name] = _CSDescendant( + descendant_spec_grouping + ) + case _CSChildName.XML_NAME: + self.child.append(_CSChildName(node_spec)) + case _CSValueValue.XML_NAME: + self.value.append(_CSValueValue(node_spec)) + case _: + raise ChildSpecificationError( + f"Unsupported descendant specification xml name: {node_type}" + ) + + def find_missing( + self, root_path: list[str], conf: dict[str, Any] + ) -> list[list[str]]: + missing = [] + + # Due to is_tag being defined in __init__.py we must import it here to avoid circular dependancies. + # TODO: investiagte overhead impact of importing over and over + from vyos.xml_ref import is_tag + if is_tag(root_path): + for k, v in conf.items(): + path = root_path + [k] + sub = self.find_missing(path, conf[k]) + if sub: + missing.extend(sub) + return missing + + for k, v in self.descendant.items(): + path = root_path + [k] + + if k not in conf: + missing.append(path) + continue + + sub = v.find_missing(path, conf[k]) + if sub: + missing.extend(sub) + + for v in self.child: + if v not in conf: + missing.append(root_path + [v]) + + return missing + + def find_provided( + self, root_path: list[str], conf: dict[str, Any] + ) -> list[list[str]]: + provided = [] + + # Due to is_tag being defined in __init__.py we must import it here to avoid circular dependancies. + # TODO: investiagte overhead impact of importing over and over + from vyos.xml_ref import is_tag + if is_tag(root_path): + for k, v in conf.items(): + path = root_path + [k] + sub = self.find_provided(path, conf[k]) + if sub: + provided.extend(sub) + return provided + + for k, v in self.descendant.items(): + path = root_path + [k] + if k in conf: + sub = v.find_provided(path, conf[k]) + if sub: + provided.extend(sub) + + for v in self.child: + if v in conf: + provided.append(root_path + [v]) + + for v in self.value: + if str(v) == str(conf): + provided.append(root_path + [v]) + + return provided + +class _CSRequiredChildren(_CSDescendant): + XML_KEY = "requiredChildren" + + def verify(self, root_path: list[str], conf: dict[str, Any]): + missing = self.find_missing(root_path, conf) + if len(missing) >= 1: + raise ConfigSessionError(f"Missing required config: [{' '.join(missing[0])}]") + + +class _CSAtLeastOneOf(_CSDescendant): + XML_KEY = "atLeastOneOf" + + def verify(self, root_path: list[str], conf: dict[str, Any]): + provided = self.find_provided(root_path, conf) + if len(provided) <= 0: + missing = self.find_missing(root_path, conf) + s = '], ['.join([' '.join(x) for x in missing]) + raise ConfigSessionError(f"At least one of the following must be configured: [{s}]") + +class _CSMutuallyExclusiveChildren(_CSDescendant): + XML_KEY = "mutuallyExclusiveChildren" + + def verify(self, root_path: list[str], conf: dict[str, Any]): + provided = self.find_provided(root_path, conf) + if len(provided) > 1: + s = '], ['.join([' '.join(x) for x in provided]) + raise ConfigSessionError(f"Only one of the following can be configured at the same time: [{s}]") + +class _CSMutuallyDependantChildren(_CSDescendant): + XML_KEY = "mutuallyDependantChildren" + + def verify(self, root_path: list[str], conf: dict[str, Any]): + provided = self.find_provided(root_path, conf) + if len(provided) > 0: + missing = self.find_missing(root_path, conf) + if len(missing) > 0: + sp = '], ['.join([' '.join(x) for x in provided]) + sm = '], ['.join([' '.join(x) for x in missing]) + raise ConfigSessionError(f"[{sp}] requires configuration of [{sm}]") + +class _CSOneWayDependantChildren: + XML_KEY = "oneWayDependantChildren" + + dependants: list[_CSDescendant] + dependees: list[_CSDescendant] + + def __init__(self, spec) -> None: + self.dependants = [] + self.dependees = [] + + for relationship, grouping in spec.items(): + match relationship: + case "dependants": + self.dependants.append(_CSDescendant(grouping)) + case "dependees": + self.dependees.append(_CSDescendant(grouping)) + case _: + raise ChildSpecificationError( + f"Unsupported one-way-dependancy relationship: {relationship}" + ) + + def verify(self, root_path: list[str], conf: dict[str, Any]): + provided_dependants = [] + for dependant in self.dependants: + provided_dependants.extend(dependant.find_provided(root_path, conf)) + + if len(provided_dependants) > 0: + missing_dependees = [] + for dependee in self.dependees: + missing_dependees.extend(dependee.find_missing(root_path, conf)) + + if len(missing_dependees) > 0: + sp = '], ['.join([' '.join(x) for x in provided_dependants]) + sm = '], ['.join([' '.join(x) for x in missing_dependees]) + raise ConfigSessionError(f"[{sp}] requires configuration of [{sm}]") + +class CSChildSpecification: + required_children: _CSRequiredChildren + at_least_one_of: list[_CSAtLeastOneOf] + mutually_exclusive_children: list[_CSMutuallyExclusiveChildren] + mutually_dependant_children: list[_CSMutuallyDependantChildren] + one_way_dependant_children: list[_CSOneWayDependantChildren] + + def __init__(self, child_specification_from_xml) -> None: + self.required_children = None + self.at_least_one_of = [] + self.mutually_exclusive_children = [] + self.mutually_dependant_children = [] + self.one_way_dependant_children = [] + + if not child_specification_from_xml: + return + + for spec_name, spec in child_specification_from_xml.items(): + match spec_name: + case _CSRequiredChildren.XML_KEY: + self.required_children = _CSRequiredChildren(spec) + case _CSAtLeastOneOf.XML_KEY: + for grouping in spec: + self.at_least_one_of.append(_CSAtLeastOneOf(grouping)) + case _CSMutuallyExclusiveChildren.XML_KEY: + for grouping in spec: + self.mutually_exclusive_children.append( + _CSMutuallyExclusiveChildren(grouping) + ) + case _CSMutuallyDependantChildren.XML_KEY: + for grouping in spec: + self.mutually_dependant_children.append( + _CSMutuallyDependantChildren(grouping) + ) + case _CSOneWayDependantChildren.XML_KEY: + for grouping in spec: + self.one_way_dependant_children.append( + _CSOneWayDependantChildren(grouping) + ) + case _: + raise ChildSpecificationError( + f"Unsupported specification child xml key: {spec_name}" + ) + + def verify(self, rpath: list[str], raw_config_dict: dict): + """verify verifies config based on XML data + + Verifies that configuration fulfills the requirements specified in the + XML schema for the node and its subnodes using the root path and config + dict with unmangled keys. + + Args: + rpath (list[str]): node root path + raw_config_dict (dict): config to verify + + Raises: + ConfigSessionError: If the provided path and config does not fulfill the specifications. + """ + + if self.required_children: + self.required_children.verify(rpath, raw_config_dict) + for spec in self.at_least_one_of: + spec.verify(rpath, raw_config_dict) + for spec in self.mutually_exclusive_children: + spec.verify(rpath, raw_config_dict) + for spec in self.mutually_dependant_children: + spec.verify(rpath, raw_config_dict) + for spec in self.one_way_dependant_children: + spec.verify(rpath, raw_config_dict) + + def __repr__(self): + return str(vars(self)) diff --git a/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py index c85835ffd2..69b463d9b7 100644 --- a/python/vyos/xml_ref/definition.py +++ b/python/vyos/xml_ref/definition.py @@ -14,6 +14,7 @@ # along with this library. If not, see . from typing import Optional, Union, Any, TYPE_CHECKING +from vyos.xml_ref.child_specification import CSChildSpecification # https://peps.python.org/pep-0484/#forward-references # for type 'ConfigDict' @@ -162,6 +163,12 @@ def owner(self, path: list) -> str: def priority(self, path: list) -> str: return self._least_upper_data(path, 'priority') + def xml_child_specification(self, path: list[str]) -> CSChildSpecification: + p = self._get_ref_path(path) + d = self._get_ref_node_data(p, 'child_specification') + return CSChildSpecification(d) + + @staticmethod def _dict_get(d: dict, path: list) -> dict: for i in path: diff --git a/python/vyos/xml_ref/generate_cache.py b/python/vyos/xml_ref/generate_cache.py index 5f3f84deee..e61863b719 100755 --- a/python/vyos/xml_ref/generate_cache.py +++ b/python/vyos/xml_ref/generate_cache.py @@ -18,6 +18,8 @@ import json from argparse import ArgumentParser from argparse import ArgumentTypeError +from argparse import BooleanOptionalAction +from pprint import pformat from os.path import join from os.path import abspath from os.path import dirname @@ -34,7 +36,7 @@ ref_cache = abspath(join(_here, 'cache.py')) node_data_fields = ("node_type", "multi", "valueless", "default_value", - "owner", "priority") + "owner", "priority", "child_specification") def trim_node_data(cache: dict): for k in list(cache): @@ -58,6 +60,8 @@ def main(): parser.add_argument('--package-name', type=non_trivial, default='vyos-1x', help='name of current package') parser.add_argument('--output-path', help='path to generated cache') + parser.add_argument('--add-linebreaks', type=bool, default=False, action=BooleanOptionalAction, + help='add additional linebreaks to the generated cache file, reduces the chance of overwhelming readers during manual inspection of the generated file') args = vars(parser.parse_args()) xml_dir = abspath(args['xml_dir']) @@ -66,6 +70,7 @@ def main(): out_path = args['output_path'] path = out_path if out_path is not None else pkg_cache xml_cache = abspath(join(path, cache_name)) + add_linebreaks = args['add_linebreaks'] try: reference_tree_to_json(xml_dir, xml_tmp) @@ -106,9 +111,15 @@ def main(): version = {"component_version": version} d |= version + s = "" + + if add_linebreaks: + s = pformat(d) + else: + s = str(d) with open(xml_cache, 'w') as f: - f.write(f'reference = {str(d)}') + f.write(f'reference = {s}') print(cache_name) diff --git a/schema/interface_definition.rnc b/schema/interface_definition.rnc index 758d9ce1ca..a44c972629 100644 --- a/schema/interface_definition.rnc +++ b/schema/interface_definition.rnc @@ -18,8 +18,10 @@ # USA # The language of this file is compact form RELAX-NG -# http://relaxng.org/compact-tutorial-20030326.htm +# https://relaxng.org/compact-tutorial-20030326.html # (unless converted to XML, then just RELAX-NG :) +# +# A useful tool to find errors in the generated schemas can be found at https://www.liquid-technologies.com/online-relaxng-validator # Interface definition starts with interfaceDefinition tag that may contain node tags start = element interfaceDefinition @@ -105,6 +107,9 @@ properties = element properties (element secret { empty })? & (element priority { text })? & + # These are meaningful only for tag and node nodes + childSpecification? & + # These are meaningful only for tag nodes (element keepChildOrder { empty })? } @@ -184,3 +189,40 @@ completionHelp = element completionHelp (element path { text })* & (element script { text })* } + + +# childSpecification tags is a declarative way to configure basic +# requirements of node or tagnode children. +# +# Some support for requiring nested descendants are provided, +# this is to increase flexibility when including generic schema +# components that can not add the requirements themselvs +# +# NOTE: keep the specification as close to the target child as +# possible to minimize complexity when reading the schemas +childSpecificationDescendant = ( + ( + (element descendant { nodeNameAttr, childSpecificationDescendant+ })+ | + (element child { text })+ + )+ +) + +childSpecificationDescendantWithValue = ( + ( + (element descendant { nodeNameAttr, childSpecificationDescendantWithValue+ })+ | + (element child { text })+ | + (element value { text })+ + )+ +) + +childSpecification = element childSpecification +{ + (element requiredChildren { childSpecificationDescendant } )? & + (element atLeastOneOf { childSpecificationDescendant })* & + (element mutuallyExclusiveChildren { childSpecificationDescendantWithValue })* & + (element mutuallyDependantChildren { childSpecificationDescendantWithValue })* & + (element oneWayDependantChildren { + element dependants { childSpecificationDescendantWithValue }+ & + element dependees { childSpecificationDescendantWithValue }+ + })* +} diff --git a/schema/interface_definition.rng b/schema/interface_definition.rng index 94a828c3bb..acbdbfccba 100644 --- a/schema/interface_definition.rng +++ b/schema/interface_definition.rng @@ -2,19 +2,19 @@ @@ -142,7 +144,7 @@ Nodes may have properties For simplicity, any property is allowed in any node, but whether they are used or not is implementation-defined - + Leaf nodes may differ in number of values that can be associated with them. By default, a leaf node can have only one value. @@ -150,7 +152,7 @@ "valueless" means it can have no values at all. "hidden" means node visibility can be toggled, eg 'dangerous' commands, "secret" allows a node to hide its value from unprivileged users. - + "priority" is used to influence node processing order for nodes with exact same dependencies and in compatibility modes. --> @@ -205,6 +207,10 @@ + + + + @@ -328,4 +334,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/schema/op-mode-definition.rng b/schema/op-mode-definition.rng index a255aeb730..e6185cb25d 100644 --- a/schema/op-mode-definition.rng +++ b/schema/op-mode-definition.rng @@ -139,10 +139,11 @@ diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py index 3dd97a1751..e4111ec807 100755 --- a/smoketest/scripts/cli/test_container.py +++ b/smoketest/scripts/cli/test_container.py @@ -230,5 +230,70 @@ def test_uid_gid(self): tmp = cmd(f'sudo podman exec -it {cont_name} id -g') self.assertEqual(tmp, gid) + def test_image(self): + cont_name = 'image-test' + + self.cli_set(base_path + ['name', cont_name, 'allow-host-networks']) + + # verify() - image is required + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['name', cont_name, 'image', cont_image]) + + self.cli_commit() + + # verify + pid = 0 + with open(PROCESS_PIDFILE.format(cont_name), 'r') as f: + pid = int(f.read()) + + # Check for running process + self.assertEqual(process_named_running(PROCESS_NAME), pid) + + def test_network_required(self): + cont_name = 'image-test' + + self.cli_set(base_path + ['name', cont_name, 'image', cont_image]) + + # verify() - image is required + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + # at least one of "network" or "allow-host-networks" must be configured + self.cli_set(base_path + ['name', cont_name, 'allow-host-networks']) + self.cli_commit() + + # verify + pid = 0 + with open(PROCESS_PIDFILE.format(cont_name), 'r') as f: + pid = int(f.read()) + + # Check for running process + self.assertEqual(process_named_running(PROCESS_NAME), pid) + + def test_network_or_host_networks(self): + cont_name = 'image-test' + prefix = '192.0.2.0/24' + net_name = 'NET01' + + self.cli_set(base_path + ['name', cont_name, 'image', cont_image]) + self.cli_set(base_path + ['name', cont_name, 'allow-host-networks']) + self.cli_set(base_path + ['name', cont_name, 'network', net_name, 'address', str(ip_interface(prefix).ip)]) + + # verify() - image is required + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path + ['name', cont_name, 'network', net_name]) + self.cli_commit() + + # verify + pid = 0 + with open(PROCESS_PIDFILE.format(cont_name), 'r') as f: + pid = int(f.read()) + + # Check for running process + self.assertEqual(process_named_running(PROCESS_NAME), pid) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_interfaces_openvpn.py b/smoketest/scripts/cli/test_interfaces_openvpn.py index 9ca661e872..da0c2d1de0 100755 --- a/smoketest/scripts/cli/test_interfaces_openvpn.py +++ b/smoketest/scripts/cli/test_interfaces_openvpn.py @@ -628,4 +628,4 @@ def test_openvpn_site2site_interfaces_tun(self): if __name__ == '__main__': - unittest.main(verbosity=2) + unittest.main(verbosity=2, failfast=True) diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index ded370a7ac..cf3887ccbe 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -23,11 +23,11 @@ from json import dumps as json_write from vyos.base import Warning -from vyos.config import Config +from vyos.config import Config, ConfigDict from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdict import is_node_changed -from vyos.configverify import verify_vrf +from vyos.configverify import verify_vrf, verify_children from vyos.ifconfig import Interface from vyos.utils.cpu import get_core_count from vyos.utils.file import write_file @@ -65,14 +65,13 @@ def network_exists(name): # Common functions -def get_config(config=None): +def get_config(config:Config=None) -> ConfigDict: if config: conf = config else: conf = Config() - base = ['container'] - container = conf.get_config_dict(base, key_mangling=('-', '_'), + container = conf.get_config_dict(['container'], key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, with_recursive_defaults=True) @@ -80,7 +79,7 @@ def get_config(config=None): for name in container.get('name', []): # T5047: Any container related configuration changed? We only # wan't to restart the required containers and not all of them ... - tmp = is_node_changed(conf, base + ['name', name]) + tmp = is_node_changed(conf, container.base + ['name', name]) if tmp: if 'container_restart' not in container: container['container_restart'] = [name] @@ -91,32 +90,31 @@ def get_config(config=None): # default_values['registry'] into the tagNode variables if 'registry' not in container: container.update({'registry': {}}) - default_values = default_value(base + ['registry']) + default_values = default_value(container.base + ['registry']) for registry in default_values: tmp = {registry: {}} container['registry'] = dict_merge(tmp, container['registry']) # Delete container network, delete containers - tmp = node_changed(conf, base + ['network']) + tmp = node_changed(conf, container.base + ['network']) if tmp: container.update({'network_remove': tmp}) - tmp = node_changed(conf, base + ['name']) + tmp = node_changed(conf, container.base + ['name']) if tmp: container.update({'container_remove': tmp}) return container - -def verify(container): +def verify(container: ConfigDict): # bail out early - looks like removal from running config if not container: return None + # Validate child config against schema definitions + verify_children(container) + # Add new container if 'name' in container: for name, container_config in container['name'].items(): - # Container image is a mandatory option - if 'image' not in container_config: - raise ConfigError(f'Container image for "{name}" is mandatory!') # Check if requested container image exists locally. If it does not # exist locally - inform the user. This is required as there is a @@ -181,71 +179,26 @@ def verify(container): if 'device' in container_config: for dev, dev_config in container_config['device'].items(): - if 'source' not in dev_config: - raise ConfigError(f'Device "{dev}" has no source path configured!') - - if 'destination' not in dev_config: - raise ConfigError(f'Device "{dev}" has no destination path configured!') - source = dev_config['source'] if not os.path.exists(source): raise ConfigError(f'Device "{dev}" source path "{source}" does not exist!') if 'sysctl' in container_config and 'parameter' in container_config['sysctl']: for var, cfg in container_config['sysctl']['parameter'].items(): - if 'value' not in cfg: - raise ConfigError(f'sysctl parameter {var} has no value assigned!') if var.startswith('net.') and 'allow_host_networks' in container_config: raise ConfigError(f'sysctl parameter {var} cannot be set when using host networking!') - if 'environment' in container_config: - for var, cfg in container_config['environment'].items(): - if 'value' not in cfg: - raise ConfigError(f'Environment variable {var} has no value assigned!') - - if 'label' in container_config: - for var, cfg in container_config['label'].items(): - if 'value' not in cfg: - raise ConfigError(f'Label variable {var} has no value assigned!') - if 'volume' in container_config: for volume, volume_config in container_config['volume'].items(): - if 'source' not in volume_config: - raise ConfigError(f'Volume "{volume}" has no source path configured!') - - if 'destination' not in volume_config: - raise ConfigError(f'Volume "{volume}" has no destination path configured!') - source = volume_config['source'] if not os.path.exists(source): raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!') - if 'port' in container_config: - for tmp in container_config['port']: - if not {'source', 'destination'} <= set(container_config['port'][tmp]): - raise ConfigError(f'Both "source" and "destination" must be specified for a port mapping!') - - # If 'allow-host-networks' or 'network' not set. - if 'allow_host_networks' not in container_config and 'network' not in container_config: - raise ConfigError(f'Must either set "network" or "allow-host-networks" for container "{name}"!') - - # Can not set both allow-host-networks and network at the same time - if {'allow_host_networks', 'network'} <= set(container_config): - raise ConfigError( - f'"allow-host-networks" and "network" for "{name}" cannot be both configured at the same time!') - - # gid cannot be set without uid - if 'gid' in container_config and 'uid' not in container_config: - raise ConfigError(f'Cannot set "gid" without "uid" for container') - # Add new network if 'network' in container: for network, network_config in container['network'].items(): v4_prefix = 0 v6_prefix = 0 - # If ipv4-prefix not defined for user-defined network - if 'prefix' not in network_config: - raise ConfigError(f'prefix for network "{network}" must be defined!') for prefix in network_config['prefix']: if is_ipv4(prefix): @@ -268,13 +221,6 @@ def verify(container): if 'network' in c_config and network in c_config['network']: raise ConfigError(f'Can not remove network "{network}", used by container "{c}"!') - if 'registry' in container: - for registry, registry_config in container['registry'].items(): - if 'authentication' not in registry_config: - continue - if not {'username', 'password'} <= set(registry_config['authentication']): - raise ConfigError('Container registry requires both username and password to be set!') - return None diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index 017010a61a..8996768cb2 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -30,12 +30,14 @@ from vyos.base import DeprecationWarning from vyos.config import Config +from vyos.config import ConfigDict from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_bond_bridge_member +from vyos.configverify import verify_children from vyos.ifconfig import VTunIf from vyos.pki import load_dh_parameters from vyos.pki import load_private_key @@ -77,7 +79,7 @@ secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567') service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf' -def get_config(config=None): +def get_config(config:Config=None) -> ConfigDict: """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag @@ -138,7 +140,7 @@ def is_ec_private_key(pki, cert_name): key = load_private_key(pki_cert['private']['key']) return isinstance(key, ec.EllipticCurvePrivateKey) -def verify_pki(openvpn): +def verify_pki(openvpn: ConfigDict): pki = openvpn['pki'] interface = openvpn['ifname'] mode = openvpn['mode'] @@ -233,7 +235,7 @@ def verify_pki(openvpn): if tls['crypt_key'] not in pki['openvpn']['shared_secret']: raise ConfigError(f'Invalid crypt-key on openvpn interface {interface}') -def verify(openvpn): +def verify(openvpn: ConfigDict): if 'deleted' in openvpn: # remove totp secrets file if totp is not configured if os.path.isfile(otp_file.format(**openvpn)): @@ -242,28 +244,16 @@ def verify(openvpn): verify_bridge_delete(openvpn) return None - if 'mode' not in openvpn: - raise ConfigError('Must specify OpenVPN operation mode!') + # Validate child config against schema definitions + verify_children(openvpn) # # OpenVPN client mode - VERIFY # if openvpn['mode'] == 'client': - if 'local_port' in openvpn: - raise ConfigError('Cannot specify "local-port" in client mode') - - if 'local_host' in openvpn: - raise ConfigError('Cannot specify "local-host" in client mode') - - if 'remote_host' not in openvpn: - raise ConfigError('Must specify "remote-host" in client mode') - if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Protocol "tcp-passive" is not valid in client mode') - if dict_search('tls.dh_params', openvpn): - raise ConfigError('Cannot specify "tls dh-params" in client mode') - # # OpenVPN site-to-site - VERIFY # @@ -326,7 +316,7 @@ def verify(openvpn): if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]: raise ConfigError('Must specify IPv4 "subnet-mask" for local-address') - if dict_search('encryption.ncp_ciphers', openvpn): + if dict_search('encryption.encryption.ncp_ciphers', openvpn): raise ConfigError('NCP ciphers can only be used in client or server mode') else: @@ -339,18 +329,6 @@ def verify(openvpn): # OpenVPN server mode - VERIFY # if openvpn['mode'] == 'server': - if openvpn['protocol'] == 'tcp-active': - raise ConfigError('Protocol "tcp-active" is not valid in server mode') - - if dict_search('authentication.username', openvpn) or dict_search('authentication.password', openvpn): - raise ConfigError('Cannot specify "authentication" in server mode') - - if 'remote_port' in openvpn: - raise ConfigError('Cannot specify "remote-port" in server mode') - - if 'remote_host' in openvpn: - raise ConfigError('Cannot specify "remote-host" in server mode') - tmp = dict_search('server.subnet', openvpn) if tmp: v4_subnets = len([subnet for subnet in tmp if is_ipv4(subnet)]) @@ -383,24 +361,21 @@ def verify(openvpn): raise ConfigError(f'Server client "{client_k}": cannot specify more than 1 IPv4 and 1 IPv6 IP') if dict_search('server.client_ip_pool', openvpn): - if not (dict_search('server.client_ip_pool.start', openvpn) and dict_search('server.client_ip_pool.stop', openvpn)): - raise ConfigError('Server client-ip-pool requires both start and stop addresses') - else: - v4PoolStart = IPv4Address(dict_search('server.client_ip_pool.start', openvpn)) - v4PoolStop = IPv4Address(dict_search('server.client_ip_pool.stop', openvpn)) - if v4PoolStart > v4PoolStop: - raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') - - v4PoolSize = int(v4PoolStop) - int(v4PoolStart) - if v4PoolSize >= 65536: - raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.') - - v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) - for client in (dict_search('client', openvpn) or []): - if client['ip']: - for v4PoolNet in v4PoolNets: - if IPv4Address(client['ip'][0]) in v4PoolNet: - print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.') + v4PoolStart = IPv4Address(dict_search('server.client_ip_pool.start', openvpn)) + v4PoolStop = IPv4Address(dict_search('server.client_ip_pool.stop', openvpn)) + if v4PoolStart > v4PoolStop: + raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') + + v4PoolSize = int(v4PoolStop) - int(v4PoolStart) + if v4PoolSize >= 65536: + raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.') + + v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) + for client in (dict_search('client', openvpn) or []): + if client['ip']: + for v4PoolNet in v4PoolNets: + if IPv4Address(client['ip'][0]) in v4PoolNet: + print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.') # configuring a client_ip_pool will set 'server ... nopool' which is currently incompatible with 'server-ipv6' (probably to be fixed upstream) for subnet in (dict_search('server.subnet', openvpn) or []): if is_ipv6(subnet): @@ -457,9 +432,6 @@ def verify(openvpn): else: # checks for both client and site-to-site go here - if dict_search('server.reject_unconfigured_clients', openvpn): - raise ConfigError('Option reject-unconfigured-clients only supported in server mode') - if 'replace_default_route' in openvpn and 'remote_host' not in openvpn: raise ConfigError('Cannot set "replace-default-route" without "remote-host"') @@ -474,42 +446,16 @@ def verify(openvpn): print('local-host IP address "{local_host}" not assigned' \ ' to any interface'.format(**openvpn)) - # TCP active - if openvpn['protocol'] == 'tcp-active': - if 'local_port' in openvpn: - raise ConfigError('Cannot specify "local-port" with "tcp-active"') - - if 'remote_host' not in openvpn: - raise ConfigError('Must specify "remote-host" with "tcp-active"') - # # TLS/encryption # - if 'shared_secret_key' in openvpn: - if dict_search('encryption.cipher', openvpn) in ['aes128gcm', 'aes192gcm', 'aes256gcm']: - raise ConfigError('GCM encryption with shared-secret-key not supported') - if 'tls' in openvpn: - if {'auth_key', 'crypt_key'} <= set(openvpn['tls']): - raise ConfigError('TLS auth and crypt keys are mutually exclusive') - tmp = dict_search('tls.role', openvpn) if tmp: if openvpn['mode'] in ['client', 'server']: if not dict_search('tls.auth_key', openvpn): raise ConfigError('Cannot specify "tls role" in client-server mode') - if tmp == 'active': - if openvpn['protocol'] == 'tcp-passive': - raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"') - - if dict_search('tls.dh_params', openvpn): - raise ConfigError('Cannot specify "tls dh-params" when "tls role" is "active"') - - elif tmp == 'passive': - if openvpn['protocol'] == 'tcp-active': - raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"') - if 'certificate' in openvpn['tls'] and is_ec_private_key(openvpn['pki'], openvpn['tls']['certificate']): if 'dh_params' in openvpn['tls']: print('Warning: using dh-params and EC keys simultaneously will ' \ @@ -525,25 +471,13 @@ def verify(openvpn): 'plain text over the network!') verify_pki(openvpn) - - # - # Auth user/pass - # - if (dict_search('authentication.username', openvpn) and not - dict_search('authentication.password', openvpn)): - raise ConfigError('Password for authentication is missing') - - if (dict_search('authentication.password', openvpn) and not - dict_search('authentication.username', openvpn)): - raise ConfigError('Username for authentication is missing') - verify_vrf(openvpn) verify_bond_bridge_member(openvpn) verify_mirror_redirect(openvpn) return None -def generate_pki_files(openvpn): +def generate_pki_files(openvpn: ConfigDict): pki = openvpn['pki'] if not pki: return None @@ -627,7 +561,7 @@ def generate_pki_files(openvpn): user=user, group=group, mode=0o600) -def generate(openvpn): +def generate(openvpn: ConfigDict): interface = openvpn['ifname'] directory = os.path.dirname(cfg_file.format(**openvpn)) openvpn['plugin_dir'] = '/usr/lib/openvpn' @@ -690,7 +624,7 @@ def generate(openvpn): return None -def apply(openvpn): +def apply(openvpn: ConfigDict): interface = openvpn['ifname'] # Do some cleanup when OpenVPN is disabled/deleted diff --git a/src/tests/test_xml_ref.py b/src/tests/test_xml_ref.py new file mode 100644 index 0000000000..7cdeb9d3f5 --- /dev/null +++ b/src/tests/test_xml_ref.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import unittest, sys, pathlib +from copy import deepcopy +from vyos.configsession import ConfigSessionError +from vyos.xml_ref.child_specification import CSChildSpecification + +# NOTE: The configuration and XML spec does not follow a real configuration, +# this is a synthetic config intended to test all cases. +RPATH = ["container", "name", "example"] +BASE_CONF = { + "image": "busybox:stable", + "label": { + "username": {"value":"River"}, + "password": {"value":"Song"}, + "proxy-auth-server": {"value": "oauth.example.com"}, + }, + "network": {"home": {"address": "192.168.1.1"}, "corp": {"address": "10.11.12.13"}}, + "rootless": True, + "volume": {"application": {"source": "/opt/app", "destination": "/mnt/app"}}, + "port": {"http": {"source": 8080}}, + "uid": 1337, + "gid": 8008 +} +CHILD_SPEC = CSChildSpecification( + { + "requiredChildren": [ + ["child", "image"], + [ + "descendant", + [ + "label", + [ + ["child", "value"], + ], + ], + ], + ["descendant", ["volume", [["child", "source"]]]], + ], + "atLeastOneOf": [ + [ + ["child", "allow-host-networks"], + ["descendant", ["network", [["child", "address"]]]], + ], + [["child", "rootless"], ["child", "privileged"]], + [["child", "host-name"], ["descendant", ["port", [["child", "source"]]]]], + ], + "mutuallyExclusiveChildren": [ + [["child", "network"], ["child", "allow-host-networks"]], + [ + ["child", "host-name"], + ["descendant", ["port", [["child", "source"]]]], + ], + [ + ["child", "image"], + ["descendant", ["allow-host-networks", [["value", True]]]], + ], + ], + "mutuallyDependantChildren": [ + [["descendant", ["image", [["value", "vyos"]]]], ["descendant", ["port", [["child", "protocol"]]]]], + ], + "oneWayDependantChildren": [ + {"dependants": [["child", "gid"]], "dependees": [["child", "uid"]]} + ], + } +) + +class TestChildSpecification(unittest.TestCase): + def test_all_good(self): + # Verify good config + CHILD_SPEC.verify(RPATH, BASE_CONF) + + def test_required_child(self): + # Verify missing child + tmp_conf = deepcopy(BASE_CONF) + tmp_conf.pop("image") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + # Verify missing descendant + tmp_conf = deepcopy(BASE_CONF) + tmp_conf.pop("label") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + # Verify missing child of descendant + tmp_conf = deepcopy(BASE_CONF) + tmp_conf["label"]["username"] = {"missing":"value"} + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + def test_at_least_one_of(self): + # Verify missing child + tmp_conf = deepcopy(BASE_CONF) + tmp_conf.pop("rootless") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + # Verify missing descendant + tmp_conf = deepcopy(BASE_CONF) + tmp_conf.pop("network") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + # Verify missing child of descendant (tag node) + tmp_conf = deepcopy(BASE_CONF) + for k in tmp_conf["network"]: + tmp_conf["network"][k].pop("address") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + def test_mutually_exclusive_children(self): + # Verify conflicting children + tmp_conf = deepcopy(BASE_CONF) + tmp_conf["allow-host-networks"] = True + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + # Verify conflicting children on different levels + tmp_conf = deepcopy(BASE_CONF) + tmp_conf["host-name"] = "cont1" + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + # Verify conflicting child and value + tmp_conf = deepcopy(BASE_CONF) + tmp_conf["image"] = "kali:airgap" + tmp_conf["allow-host-networks"] = True + tmp_conf.pop("network") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + def test_mutually_dependant_children(self): + # Verify dependance on different levels with value + tmp_conf = deepcopy(BASE_CONF) + tmp_conf["image"] = "vyos" + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + + def test_one_way_dependant_children(self): + # Verify same level dependance + tmp_conf = deepcopy(BASE_CONF) + tmp_conf.pop("gid") + CHILD_SPEC.verify(RPATH, tmp_conf) + tmp_conf = deepcopy(BASE_CONF) + tmp_conf.pop("uid") + with self.assertRaises(ConfigSessionError): + CHILD_SPEC.verify(RPATH, tmp_conf) + +if __name__ == "__main__": + unittest.main(verbosity=2, failfast=True)