diff --git a/doc/meson.build b/doc/meson.build index 5219c179f..57a0cbef8 100644 --- a/doc/meson.build +++ b/doc/meson.build @@ -11,7 +11,7 @@ if pandoc.found() command: [pandoc, '-s', '--metadata', 'title="Netplan reference"', '--toc', '-o', '@OUTPUT@', '@INPUT@'], install: true, install_dir: join_paths(get_option('datadir'), 'doc', 'netplan')) - foreach doc : ['netplan-apply', 'netplan-dbus', 'netplan-generate', 'netplan-get', 'netplan-set', 'netplan-try'] + foreach doc : ['netplan-apply', 'netplan-dbus', 'netplan-generate', 'netplan-get', 'netplan-set', 'netplan-try', 'netplan-validate'] markdown = files(doc + '.md') manpage = doc + '.8' custom_target( diff --git a/doc/netplan-validate.md b/doc/netplan-validate.md new file mode 100644 index 000000000..d4d1426fe --- /dev/null +++ b/doc/netplan-validate.md @@ -0,0 +1,48 @@ +--- +title: netplan-validate +section: 8 +author: +- Danilo Egea Gondolfo (danilo.egea.gondolfo@canonical.com) +... + +## NAME + +netplan-validate - parse and validate your configuration without applying it + +## SYNOPSIS + + **netplan** [--debug] **validate** -h | --help + + **netplan** [--debug] **validate** [--root-dir=ROOT_DIR] + +## DESCRIPTION + +**netplan validate** reads and parses all YAML files from ``/{etc,lib,run}/netplan/*.yaml`` and shows any issues found with them. + +It doesn't generate nor apply the configuration to the running system. + +You can specify ``--debug`` to see what files are processed by Netplan in the order they are parsed. +It will also show what files were shadowed, if any. + +For details of the configuration file format, see **netplan**(5). + +## OPTIONS + + -h, --help +: Print basic help. + + --debug +: Print debugging output during the process. + + --root-dir +: Read YAML files from this root instead of / + +## RETURN VALUE + +On success, no issues were found, 0 is returned to the shell. + +On error, 1 is returned. + +## SEE ALSO + + **netplan**(5), **netplan-generate**(8), **netplan-apply**(8) diff --git a/netplan.completions b/netplan.completions index c544f7dbe..2ad7b12f2 100644 --- a/netplan.completions +++ b/netplan.completions @@ -32,6 +32,10 @@ _netplan_completions() { while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_netplan_completions_filter "-h --help --debug --root-dir --mapping")" -- "$cur" ) ;; + 'validate'*) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_netplan_completions_filter "-h --help --debug --root-dir")" -- "$cur" ) + ;; + 'rebind'*) while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_netplan_completions_filter "-h --help --debug")" -- "$cur" ) ;; @@ -69,7 +73,7 @@ _netplan_completions() { ;; *) - while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_netplan_completions_filter "-h --help --debug help apply generate get info ip set rebind status try")" -- "$cur" ) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_netplan_completions_filter "-h --help --debug help apply generate get info ip set rebind status try validate")" -- "$cur" ) ;; esac diff --git a/netplan/cli/commands/__init__.py b/netplan/cli/commands/__init__.py index 67541869f..721af8977 100644 --- a/netplan/cli/commands/__init__.py +++ b/netplan/cli/commands/__init__.py @@ -25,6 +25,7 @@ from netplan.cli.commands.get import NetplanGet from netplan.cli.commands.sriov_rebind import NetplanSriovRebind from netplan.cli.commands.status import NetplanStatus +from netplan.cli.commands.validate import NetplanValidate __all__ = [ 'NetplanApply', @@ -37,4 +38,5 @@ 'NetplanGet', 'NetplanSriovRebind', 'NetplanStatus', + 'NetplanValidate', ] diff --git a/netplan/cli/commands/generate.py b/netplan/cli/commands/generate.py index 4900d8f5b..f7c645be1 100644 --- a/netplan/cli/commands/generate.py +++ b/netplan/cli/commands/generate.py @@ -68,10 +68,10 @@ def command_generate(self): if res != 0: if res == 130: raise PermissionError( - "failed to communicate with dbus service") + "PermissionError: failed to communicate with dbus service") else: raise RuntimeError( - "failed to communicate with dbus service: error %s" % res) + "RuntimeError: failed to communicate with dbus service: error %s" % res) else: return diff --git a/netplan/cli/commands/validate.py b/netplan/cli/commands/validate.py new file mode 100644 index 000000000..ecfe0297f --- /dev/null +++ b/netplan/cli/commands/validate.py @@ -0,0 +1,93 @@ +# Copyright (C) 2023 Canonical, Ltd. +# Author: Danilo Egea Gondolfo +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3. +# +# 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 . + +'''netplan validate command line''' + +import glob +import os + +from netplan.cli.utils import NetplanCommand +import netplan.libnetplan as libnetplan + + +class ValidationException(Exception): + pass + + +class NetplanValidate(NetplanCommand): + + def __init__(self): + super().__init__(command_id='validate', + description='Load, parse and validate your configuration without applying it', + leaf=True) + + def run(self): + self.parser.add_argument('--root-dir', default='/', + help='Validate configuration files in this root directory instead of /') + + self.func = self.command_validate + + self.parse_args() + self.run_command() + + def command_validate(self): + + if self.debug: + # Replicates the behavior of src/util.c:netplan_parser_load_yaml_hierarchy() + + lib_glob = 'lib/netplan/*.yaml' + etc_glob = 'etc/netplan/*.yaml' + run_glob = 'run/netplan/*.yaml' + + lib_files = glob.glob(lib_glob, root_dir=self.root_dir) + etc_files = glob.glob(etc_glob, root_dir=self.root_dir) + run_files = glob.glob(run_glob, root_dir=self.root_dir) + + # Order of priority: lib -> etc -> run + files = lib_files + etc_files + run_files + files_dict = {} + shadows = [] + + # Shadowing files with the same name and lower priority + for file in files: + basename = os.path.basename(file) + filepath = os.path.join(self.root_dir, file) + + if key := files_dict.get(basename): + shadows.append((key, filepath)) + + files_dict[basename] = filepath + + files = sorted(files_dict.keys()) + if files: + print('Order in which your files are parsed:') + for file in files: + print(files_dict.get(file)) + + if shadows: + print('\nThe following files were shadowed:') + for shadow in shadows: + print(f'{shadow[0]} shadowed by {shadow[1]}') + + try: + # Parse the full, existing YAML config hierarchy + parser = libnetplan.Parser() + parser.load_yaml_hierarchy(self.root_dir) + + # Validate the final parser state + state = libnetplan.State() + state.import_parser_results(parser) + except Exception as e: + raise ValidationException(e) diff --git a/netplan/cli/core.py b/netplan/cli/core.py index 3d6c392d3..bdbe6fbd9 100644 --- a/netplan/cli/core.py +++ b/netplan/cli/core.py @@ -20,7 +20,9 @@ import logging import os +import sys +from netplan.cli.commands.validate import ValidationException import netplan.cli.utils as utils @@ -47,4 +49,10 @@ def main(self): else: logging.basicConfig(level=logging.INFO, format='%(message)s') - self.run_command() + try: + self.run_command() + except ValidationException as e: + logging.warning(f'Validation failed: {e}') + sys.exit(1) + except Exception as e: + logging.warning(f'Command failed: {e}') diff --git a/netplan/meson.build b/netplan/meson.build index 7ebcdeeb4..8e196a7f8 100644 --- a/netplan/meson.build +++ b/netplan/meson.build @@ -39,7 +39,9 @@ commands_sources = files( 'cli/commands/set.py', 'cli/commands/sriov_rebind.py', 'cli/commands/status.py', - 'cli/commands/try_command.py') + 'cli/commands/try_command.py', + 'cli/commands/validate.py', + ) install_data(netplan_sources, install_dir: netplan_module) install_data(cli_sources, install_dir: join_paths(netplan_module, 'cli')) diff --git a/tests/cli/test_validate.py b/tests/cli/test_validate.py new file mode 100644 index 000000000..c4da94d6a --- /dev/null +++ b/tests/cli/test_validate.py @@ -0,0 +1,162 @@ +#!/usr/bin/python3 +# Functional tests of netplan CLI. These are run during "make check" and don't +# touch the system configuration at all. +# +# Copyright (C) 2023 Canonical, Ltd. +# Author: Danilo Egea Gondolfo +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3. +# +# 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 os +import unittest +from unittest.mock import patch +import tempfile +import shutil +import sys + + +from netplan.cli.commands.validate import ValidationException +from netplan.cli.core import Netplan + +from tests.test_utils import call_cli + + +class TestValidate(unittest.TestCase): + '''Test netplan set''' + def setUp(self): + self.workdir = tempfile.TemporaryDirectory(prefix='netplan_') + self.file = '70-netplan-set.yaml' + self.path = os.path.join(self.workdir.name, 'etc', 'netplan', self.file) + os.makedirs(os.path.join(self.workdir.name, 'etc', 'netplan')) + + def tearDown(self): + shutil.rmtree(self.workdir.name) + + def _validate(self, debug=False): + args = ['validate', '--root-dir', self.workdir.name] + if debug: + args.append('--debug') + return call_cli(args) + + def test_validate_raises_no_exceptions(self): + with open(self.path, 'w') as f: + f.write('''network: + ethernets: + eth0: + dhcp4: false''') + + self._validate() + + def test_validate_raises_exception(self): + with open(self.path, 'w') as f: + f.write('''network: + ethernets: + eth0: + dhcp4: nothanks''') + + with self.assertRaises(ValidationException) as e: + self._validate() + self.assertIn('invalid boolean value', str(e.exception)) + + def test_validate_raises_exception_main_function(self): + with open(self.path, 'w') as f: + f.write('''network: + ethernets: + eth0: + dhcp4: nothanks''') + + with patch('logging.warning') as log, patch('sys.exit') as exit_mock: + exit_mock.return_value = 1 + + old_argv = sys.argv + args = ['validate', '--root-dir', self.workdir.name] + sys.argv = [old_argv[0]] + args + + Netplan().main() + + # The idea was to capture stderr here but for some reason + # any attempt to mock sys.stderr didn't work with pytest + args = log.call_args.args + self.assertIn('invalid boolean value', args[0]) + + sys.argv = old_argv + + def test_validate_debug_single_file(self): + with open(self.path, 'w') as f: + f.write('''network: + ethernets: + eth0: + dhcp4: false''') + + output = self._validate(debug=True) + self.assertIn('70-netplan-set.yaml', output) + + def test_validate_debug_with_shadow(self): + with open(self.path, 'w') as f: + f.write('''network: + ethernets: + eth0: + dhcp4: false''') + + path = os.path.join(self.workdir.name, 'run', 'netplan', self.file) + os.makedirs(os.path.join(self.workdir.name, 'run', 'netplan')) + + with open(path, 'w') as f: + f.write('''network: + ethernets: + eth0: + dhcp4: false''') + + output = self._validate(debug=True) + + lines = output.split('\n') + highest_priority_file = lines[1] + self.assertIn('run/netplan/70-netplan-set.yaml', highest_priority_file) + + self.assertIn('The following files were shadowed', output) + + def test_validate_debug_order(self): + os.makedirs(os.path.join(self.workdir.name, 'run', 'netplan')) + os.makedirs(os.path.join(self.workdir.name, 'lib', 'netplan')) + + path = os.path.join(self.workdir.name, 'etc', 'netplan', self.file) + with open(self.path, 'w') as f: + f.write('''network: + ethernets: + eth0: {}''') + + path = os.path.join(self.workdir.name, 'run', 'netplan', '90-config.yaml') + with open(path, 'w') as f: + f.write('''network: + ethernets: + eth0: {}''') + + path = os.path.join(self.workdir.name, 'lib', 'netplan', '99-zzz.yaml') + with open(path, 'w') as f: + f.write('''network: + ethernets: + eth0: {}''') + + path = os.path.join(self.workdir.name, 'run', 'netplan', '00-aaa.yaml') + with open(path, 'w') as f: + f.write('''network: + ethernets: + eth0: {}''') + + output = self._validate(debug=True) + lines = output.split('\n') + + self.assertIn('run/netplan/00-aaa.yaml', lines[1]) + self.assertIn('etc/netplan/70-netplan-set.yaml', lines[2]) + self.assertIn('run/netplan/90-config.yaml', lines[3]) + self.assertIn('lib/netplan/99-zzz.yaml', lines[4]) diff --git a/tests/test_utils.py b/tests/test_utils.py index d5375e1ba..f1c1f63a5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -104,7 +104,9 @@ def call_cli(args): f = io.StringIO() try: with redirect_stdout(f): - Netplan().main() + netplan = Netplan() + netplan.parse_args() + netplan.run_command() return f.getvalue() finally: sys.argv = old_sys_argv diff --git a/tools/completely.yaml b/tools/completely.yaml index ce0dddd5c..40eaafc83 100644 --- a/tools/completely.yaml +++ b/tools/completely.yaml @@ -15,6 +15,7 @@ netplan: - rebind - status - try +- validate netplan help: - -h @@ -89,3 +90,9 @@ netplan try: - --config-file - --timeout - --state + +netplan validate: +- -h +- --help +- --debug +- --root-dir