Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce netplan validate #335

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions doc/netplan-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: netplan-validate
section: 8
author:
- Danilo Egea Gondolfo ([email protected])
...

## 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 /

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth growing generally a machine readable output so callers can invoke this with a --format=json or do we really prefer machine-readable consumers really to just use the libnetplan.State.import_parser_results directly and process the state errors?


## 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)
6 changes: 5 additions & 1 deletion netplan.completions
Original file line number Diff line number Diff line change
Expand Up @@ -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" )
;;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions netplan/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -37,4 +38,5 @@
'NetplanGet',
'NetplanSriovRebind',
'NetplanStatus',
'NetplanValidate',
]
4 changes: 2 additions & 2 deletions netplan/cli/commands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions netplan/cli/commands/validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Copyright (C) 2023 Canonical, Ltd.
# Author: Danilo Egea Gondolfo <[email protected]>
#
# 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 <http://www.gnu.org/licenses/>.

'''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)
10 changes: 9 additions & 1 deletion netplan/cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@

import logging
import os
import sys

from netplan.cli.commands.validate import ValidationException
import netplan.cli.utils as utils


Expand All @@ -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}')
4 changes: 3 additions & 1 deletion netplan/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
162 changes: 162 additions & 0 deletions tests/cli/test_validate.py
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
#
# 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 <http://www.gnu.org/licenses/>.

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])
Loading