From 084a55441ba201857a26a6b69fd42d65fd7bae6c Mon Sep 17 00:00:00 2001 From: Danilo Egea Gondolfo Date: Fri, 10 Mar 2023 17:32:46 +0000 Subject: [PATCH 1/5] netplan: add "validate" command Netplan validate can be used to check the configuration before trying to apply it. It's useful when one is making changes in the configuration and want to periodically check if it is still valid without having to call "apply" or "generate". The validate command will load, parse and validate the configuration without saving anything to disk. --- netplan/cli/commands/__init__.py | 2 ++ netplan/cli/commands/validate.py | 52 ++++++++++++++++++++++++++++++++ netplan/cli/core.py | 10 +++++- netplan/meson.build | 4 ++- 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 netplan/cli/commands/validate.py 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/validate.py b/netplan/cli/commands/validate.py new file mode 100644 index 000000000..895b2bc29 --- /dev/null +++ b/netplan/cli/commands/validate.py @@ -0,0 +1,52 @@ +# 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''' + +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): + 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')) From dcba5b2c0d7a087c63b063801d9fa6ed6de567ba Mon Sep 17 00:00:00 2001 From: Danilo Egea Gondolfo Date: Fri, 10 Mar 2023 17:38:40 +0000 Subject: [PATCH 2/5] tests: add some tests for the new validate command Due to the change in netplan.cli.core (which prevents it of raising exception (which were not being handled and resulting in crashes)), a small change was made in the cli tests so the main() method is not called directly anymore. --- netplan/cli/commands/generate.py | 4 +- tests/cli/test_validate.py | 90 ++++++++++++++++++++++++++++++++ tests/test_utils.py | 4 +- 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_validate.py 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/tests/cli/test_validate.py b/tests/cli/test_validate.py new file mode 100644 index 000000000..6c8d0284d --- /dev/null +++ b/tests/cli/test_validate.py @@ -0,0 +1,90 @@ +#!/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): + args = ['validate', '--root-dir', self.workdir.name] + 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 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 From 21c7cfc5955768b10ec14781b2afd8f1ecbc3682 Mon Sep 17 00:00:00 2001 From: Danilo Egea Gondolfo Date: Fri, 10 Mar 2023 17:41:17 +0000 Subject: [PATCH 3/5] misc: update the bash completion script Add the new "netplan validate" command to it. --- netplan.completions | 6 +++++- tools/completely.yaml | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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/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 From 5b5ac6db8c1e6d4ed744aff60d9e9811a366aa71 Mon Sep 17 00:00:00 2001 From: Danilo Egea Gondolfo Date: Mon, 13 Mar 2023 13:16:14 +0000 Subject: [PATCH 4/5] validate: implement support for --debug --debug will show what files are being processed and which ones were shadowed. --- netplan/cli/commands/validate.py | 41 +++++++++++++++++ tests/cli/test_validate.py | 76 +++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/netplan/cli/commands/validate.py b/netplan/cli/commands/validate.py index 895b2bc29..ecfe0297f 100644 --- a/netplan/cli/commands/validate.py +++ b/netplan/cli/commands/validate.py @@ -15,6 +15,9 @@ '''netplan validate command line''' +import glob +import os + from netplan.cli.utils import NetplanCommand import netplan.libnetplan as libnetplan @@ -40,6 +43,44 @@ def run(self): 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() diff --git a/tests/cli/test_validate.py b/tests/cli/test_validate.py index 6c8d0284d..c4da94d6a 100644 --- a/tests/cli/test_validate.py +++ b/tests/cli/test_validate.py @@ -42,9 +42,11 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.workdir.name) - def _validate(self): + def _validate(self, debug=False): args = ['validate', '--root-dir', self.workdir.name] - call_cli(args) + if debug: + args.append('--debug') + return call_cli(args) def test_validate_raises_no_exceptions(self): with open(self.path, 'w') as f: @@ -88,3 +90,73 @@ def test_validate_raises_exception_main_function(self): 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]) From b90224b44b488ca91c5c46256669a6ba79cbb000 Mon Sep 17 00:00:00 2001 From: Danilo Egea Gondolfo Date: Mon, 13 Mar 2023 13:36:18 +0000 Subject: [PATCH 5/5] validate: add the netplan-validate man page --- doc/meson.build | 2 +- doc/netplan-validate.md | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 doc/netplan-validate.md 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)