Merge ~chad.smith/cloud-init:cc-ntp-schema-validation into cloud-init:master

Proposed by Chad Smith
Status: Merged
Approved by: Scott Moser
Approved revision: 890d5a3f79324f74b6024bd21856158ca7d181f8
Merged at revision: 0a448dd034883c07f85091dbfc9117de7227eb8d
Proposed branch: ~chad.smith/cloud-init:cc-ntp-schema-validation
Merge into: cloud-init:master
Diff against target: 757 lines (+657/-7)
7 files modified
cloudinit/config/cc_ntp.py (+67/-2)
cloudinit/config/schema.py (+222/-0)
requirements.txt (+3/-0)
tests/unittests/helpers.py (+1/-5)
tests/unittests/test_handler/test_handler_ntp.py (+109/-0)
tests/unittests/test_handler/test_schema.py (+220/-0)
tools/cloudconfig-schema (+35/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Scott Moser Approve
Review via email: mp+324640@code.launchpad.net

Commit message

cc_ntp: Add schema definition and passive schema validation.

cloud-config files are very flexible and permissive. This branch adds a supported schema definition to cc_ntp so that we can properly inform users of potential misconfiguration in their cloud-config yaml.

This first branch adds a jsonsschema definition to the cc_ntp module and validation functions in cloudinit/config/schema which will log warnings about invalid configuration values in the ntp section.

A cmdline tools/cloudconfig-schema is added which can be used in our dev environments to quickly attempt to exercise the ntp schema. Eventually this cloudconfig-schema tool will evolve into a cmdline tool we can provide to users or a validation service which will allow folks to test their cloud-config files without having to attempt deployment to find errors or inconsistencies.

LP: #1692916

Description of the change

cc_ntp: Add schema definition and passive schema validation.

cloud-config files are very flexible and permissive. This branch adds a supported schema definition to cc_ntp so that we can properly inform users of potential misconfiguration in their cloud-config yaml.

This first branch adds a jsonsschema definition to the cc_ntp module and validation functions in cloudinit/config/schema which will log warnings about invalid configuration values in the ntp section.

A cmdline tools/cloudconfig-schema is added which can be used in our dev environments to quickly attempt to exercise the ntp schema. Eventually this cloudconfig-schema tool will evolve into a cmdline tool we can provide to users or a validation service which will allow folks to test their cloud-config files without having to attempt deployment to find errors or inconsistencies.

LP: #1692916

To test:

# download the attached schemas.tar
wget https://bugs.launchpad.net/cloud-init/+bug/1692916/+attachment/4883522/+files/schemas.tar
tar xf schemas.tar
./tools/cloudconfig-schema --doc # to dump rst from the schema definition
./tools/cloudconfig-schema --doc | rst2man | man -l - # manpage
 for file in `ls schemas`; do echo '--------------------------- BEGIN ' $file; cat schemas/$file; echo -e '--------------------------- OUTPUT: '; ./tools/cloudconfig-schema --config-file schemas/$file; echo -e '--------------------------- END ' $file '\n\n'; done;

#run unit tests
tox -- --tests tests/unittests/test_handlers/test_handler_ntp.py

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
f5df731... by Chad Smith

add cloudconfig-schema tool

9ee8f78... by Chad Smith

update requirements to pull in jsonschema

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

I like it very much.

Some comments inline. comments.
looking at diff i see that tools/cloudconfig-schema has some whitespace-end-of-line issues.

tools/cloudconfig-schema needs chmod +x.
tools/cloudconfig-schema: def error() should got to sys.stderr.write(message + "\n") ?
might as well write errors to stderr.

Overall this is wonderful. Thank you.

The jsonschema thing is the only sticking point. we can add a dependency to trunk
and trunk's package build. But I dont yet want to add a runtime dependency on it.
So somehow we have to address that.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
e6aabcd... by Chad Smith

- use sys.stderror for error prints from cloudconfig-schema
- use dedent in schema definition and updated schema doc rendering to strip the newlines
- reformat the RST output to bold key names and render list item types

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
9aff5a1... by Chad Smith

unit test fix for new schema rst format

3d188c2... by Chad Smith

flake and lints

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
0d05dd1... by Chad Smith

python2.6 gets me again w/ format templates

d625066... by Chad Smith

address remaining review comments: s/Invalid schema/Invalid config/, docstring fixups

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

Review comments addressed Scott. Thank you! I tweaked the rst doc layout a bit to make sure we could use structured text within the Config keys doc section. Let's talk about the runtime dependency concern tomorrow to see what we can do. Not having the dependency defined means we'd have to remove the validate_cloudconfig_schema from cc_ntp.handle() and we wouldn't actually attempt to run passive validation on the user-defined cloud-config files so users would not get warnings about potential issues in their cloud-config yaml.

1788d55... by Chad Smith

assert SchemaValidationError is an instance of ValueError

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
de1a75c... by Chad Smith

fix whitespace issues with dedented property descriptions

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
a4cf423... by Chad Smith

highlight servers and pools key names in autogenerated description

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) :
8ebe99e... by Chad Smith

auth-generate example documentation from schema definition

f29875d... by Chad Smith

add unit test for ImportError on jsonschema

fc42257... by Chad Smith

lints

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
5d585e9... by Chad Smith

fix remaining py2.6 format issue and dedent leading newlines in schema property descriptions

Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

I have only one comment, and i'm fine with it as it is.
Could we put the main into cloudinit/config/schema.py ?

Then it would be executable on any system with cloud-init as:
  python3 -m cloudinit.config.schema

704339e... by Chad Smith

move main out of cloudconfig-schema and into cloudinit.config.schema. Add unit tests for schema.main

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

fix the StringIO error and we're good to go.

review: Approve
af370e1... by Chad Smith

use StringIO from six for py2/3 compatibility

f85cd6c... by Chad Smith

missed commit of test_schema

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
890d5a3... by Chad Smith

unittest byte string

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py
2index 5cc5453..31ed64e 100644
3--- a/cloudinit/config/cc_ntp.py
4+++ b/cloudinit/config/cc_ntp.py
5@@ -36,6 +36,7 @@ servers or pools are provided, 4 pools will be used in the format
6 - 192.168.23.2
7 """
8
9+from cloudinit.config.schema import validate_cloudconfig_schema
10 from cloudinit import log as logging
11 from cloudinit.settings import PER_INSTANCE
12 from cloudinit import templater
13@@ -43,6 +44,7 @@ from cloudinit import type_utils
14 from cloudinit import util
15
16 import os
17+from textwrap import dedent
18
19 LOG = logging.getLogger(__name__)
20
21@@ -52,21 +54,84 @@ NR_POOL_SERVERS = 4
22 distros = ['centos', 'debian', 'fedora', 'opensuse', 'ubuntu']
23
24
25+# The schema definition for each cloud-config module is a strict contract for
26+# describing supported configuration parameters for each cloud-config section.
27+# It allows cloud-config to validate and alert users to invalid or ignored
28+# configuration options before actually attempting to deploy with said
29+# configuration.
30+
31+schema = {
32+ 'id': 'cc_ntp',
33+ 'name': 'NTP',
34+ 'title': 'enable and configure ntp',
35+ 'description': dedent("""\
36+ Handle ntp configuration. If ntp is not installed on the system and
37+ ntp configuration is specified, ntp will be installed. If there is a
38+ default ntp config file in the image or one is present in the
39+ distro's ntp package, it will be copied to ``/etc/ntp.conf.dist``
40+ before any changes are made. A list of ntp pools and ntp servers can
41+ be provided under the ``ntp`` config key. If no ntp ``servers`` or
42+ ``pools`` are provided, 4 pools will be used in the format
43+ ``{0-3}.{distro}.pool.ntp.org``."""),
44+ 'distros': distros,
45+ 'examples': [
46+ {'ntp': {'pools': ['0.company.pool.ntp.org', '1.company.pool.ntp.org',
47+ 'ntp.myorg.org'],
48+ 'servers': ['my.ntp.server.local', 'ntp.ubuntu.com',
49+ '192.168.23.2']}}],
50+ 'frequency': PER_INSTANCE,
51+ 'type': 'object',
52+ 'properties': {
53+ 'ntp': {
54+ 'type': ['object', 'null'],
55+ 'properties': {
56+ 'pools': {
57+ 'type': 'array',
58+ 'items': {
59+ 'type': 'string',
60+ 'format': 'hostname'
61+ },
62+ 'uniqueItems': True,
63+ 'description': dedent("""\
64+ List of ntp pools. If both pools and servers are
65+ empty, 4 default pool servers will be provided of
66+ the format ``{0-3}.{distro}.pool.ntp.org``.""")
67+ },
68+ 'servers': {
69+ 'type': 'array',
70+ 'items': {
71+ 'type': 'string',
72+ 'format': 'hostname'
73+ },
74+ 'uniqueItems': True,
75+ 'description': dedent("""\
76+ List of ntp servers. If both pools and servers are
77+ empty, 4 default pool servers will be provided with
78+ the format ``{0-3}.{distro}.pool.ntp.org``.""")
79+ }
80+ },
81+ 'required': [],
82+ 'additionalProperties': False
83+ }
84+ }
85+}
86+
87+
88 def handle(name, cfg, cloud, log, _args):
89 """Enable and configure ntp."""
90-
91 if 'ntp' not in cfg:
92 LOG.debug(
93 "Skipping module named %s, not present or disabled by cfg", name)
94 return
95-
96 ntp_cfg = cfg.get('ntp', {})
97
98+ # TODO drop this when validate_cloudconfig_schema is strict=True
99 if not isinstance(ntp_cfg, (dict)):
100 raise RuntimeError(("'ntp' key existed in config,"
101 " but not a dictionary type,"
102 " is a %s %instead"), type_utils.obj_name(ntp_cfg))
103
104+ validate_cloudconfig_schema(cfg, schema)
105 rename_ntp_conf()
106 # ensure when ntp is installed it has a configuration file
107 # to use instead of starting up with packaged defaults
108diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py
109new file mode 100644
110index 0000000..6400f00
111--- /dev/null
112+++ b/cloudinit/config/schema.py
113@@ -0,0 +1,222 @@
114+# This file is part of cloud-init. See LICENSE file for license information.
115+"""schema.py: Set of module functions for processing cloud-config schema."""
116+
117+from __future__ import print_function
118+
119+from cloudinit.util import read_file_or_url
120+
121+import argparse
122+import logging
123+import os
124+import sys
125+import yaml
126+
127+SCHEMA_UNDEFINED = b'UNDEFINED'
128+CLOUD_CONFIG_HEADER = b'#cloud-config'
129+SCHEMA_DOC_TMPL = """
130+{name}
131+---
132+**Summary:** {title}
133+
134+{description}
135+
136+**Internal name:** ``{id}``
137+
138+**Module frequency:** {frequency}
139+
140+**Supported distros:** {distros}
141+
142+**Config schema**:
143+{property_doc}
144+{examples}
145+"""
146+SCHEMA_PROPERTY_TMPL = '{prefix}**{prop_name}:** ({type}) {description}'
147+
148+
149+class SchemaValidationError(ValueError):
150+ """Raised when validating a cloud-config file against a schema."""
151+
152+ def __init__(self, schema_errors=()):
153+ """Init the exception an n-tuple of schema errors.
154+
155+ @param schema_errors: An n-tuple of the format:
156+ ((flat.config.key, msg),)
157+ """
158+ self.schema_errors = schema_errors
159+ error_messages = [
160+ '{0}: {1}'.format(config_key, message)
161+ for config_key, message in schema_errors]
162+ message = "Cloud config schema errors: {0}".format(
163+ ', '.join(error_messages))
164+ super(SchemaValidationError, self).__init__(message)
165+
166+
167+def validate_cloudconfig_schema(config, schema, strict=False):
168+ """Validate provided config meets the schema definition.
169+
170+ @param config: Dict of cloud configuration settings validated against
171+ schema.
172+ @param schema: jsonschema dict describing the supported schema definition
173+ for the cloud config module (config.cc_*).
174+ @param strict: Boolean, when True raise SchemaValidationErrors instead of
175+ logging warnings.
176+
177+ @raises: SchemaValidationError when provided config does not validate
178+ against the provided schema.
179+ """
180+ try:
181+ from jsonschema import Draft4Validator, FormatChecker
182+ except ImportError:
183+ logging.warning(
184+ 'Ignoring schema validation. python-jsonschema is not present')
185+ return
186+ validator = Draft4Validator(schema, format_checker=FormatChecker())
187+ errors = ()
188+ for error in sorted(validator.iter_errors(config), key=lambda e: e.path):
189+ path = '.'.join([str(p) for p in error.path])
190+ errors += ((path, error.message),)
191+ if errors:
192+ if strict:
193+ raise SchemaValidationError(errors)
194+ else:
195+ messages = ['{0}: {1}'.format(k, msg) for k, msg in errors]
196+ logging.warning('Invalid config:\n%s', '\n'.join(messages))
197+
198+
199+def validate_cloudconfig_file(config_path, schema):
200+ """Validate cloudconfig file adheres to a specific jsonschema.
201+
202+ @param config_path: Path to the yaml cloud-config file to parse.
203+ @param schema: Dict describing a valid jsonschema to validate against.
204+
205+ @raises SchemaValidationError containing any of schema_errors encountered.
206+ @raises RuntimeError when config_path does not exist.
207+ """
208+ if not os.path.exists(config_path):
209+ raise RuntimeError('Configfile {0} does not exist'.format(config_path))
210+ content = read_file_or_url('file://{0}'.format(config_path)).contents
211+ if not content.startswith(CLOUD_CONFIG_HEADER):
212+ errors = (
213+ ('header', 'File {0} needs to begin with "{1}"'.format(
214+ config_path, CLOUD_CONFIG_HEADER.decode())),)
215+ raise SchemaValidationError(errors)
216+
217+ try:
218+ cloudconfig = yaml.safe_load(content)
219+ except yaml.parser.ParserError as e:
220+ errors = (
221+ ('format', 'File {0} is not valid yaml. {1}'.format(
222+ config_path, str(e))),)
223+ raise SchemaValidationError(errors)
224+ validate_cloudconfig_schema(
225+ cloudconfig, schema, strict=True)
226+
227+
228+def _get_property_type(property_dict):
229+ """Return a string representing a property type from a given jsonschema."""
230+ property_type = property_dict.get('type', SCHEMA_UNDEFINED)
231+ if isinstance(property_type, list):
232+ property_type = '/'.join(property_type)
233+ item_type = property_dict.get('items', {}).get('type')
234+ if item_type:
235+ property_type = '{0} of {1}'.format(property_type, item_type)
236+ return property_type
237+
238+
239+def _get_property_doc(schema, prefix=' '):
240+ """Return restructured text describing the supported schema properties."""
241+ new_prefix = prefix + ' '
242+ properties = []
243+ for prop_key, prop_config in schema.get('properties', {}).items():
244+ # Define prop_name and dscription for SCHEMA_PROPERTY_TMPL
245+ description = prop_config.get('description', '')
246+ properties.append(SCHEMA_PROPERTY_TMPL.format(
247+ prefix=prefix,
248+ prop_name=prop_key,
249+ type=_get_property_type(prop_config),
250+ description=description.replace('\n', '')))
251+ if 'properties' in prop_config:
252+ properties.append(
253+ _get_property_doc(prop_config, prefix=new_prefix))
254+ return '\n\n'.join(properties)
255+
256+
257+def _get_schema_examples(schema, prefix=''):
258+ """Return restructured text describing the schema examples if present."""
259+ examples = schema.get('examples')
260+ if not examples:
261+ return ''
262+ rst_content = '\n**Examples**::\n\n'
263+ for example in examples:
264+ example_yaml = yaml.dump(example, default_flow_style=False)
265+ # Python2.6 is missing textwrapper.indent
266+ lines = example_yaml.split('\n')
267+ indented_lines = [' {0}'.format(line) for line in lines]
268+ rst_content += '\n'.join(indented_lines)
269+ return rst_content
270+
271+
272+def get_schema_doc(schema):
273+ """Return reStructured text rendering the provided jsonschema.
274+
275+ @param schema: Dict of jsonschema to render.
276+ @raise KeyError: If schema lacks an expected key.
277+ """
278+ schema['property_doc'] = _get_property_doc(schema)
279+ schema['examples'] = _get_schema_examples(schema)
280+ schema['distros'] = ', '.join(schema['distros'])
281+ return SCHEMA_DOC_TMPL.format(**schema)
282+
283+
284+def get_schema(section_key=None):
285+ """Return a dict of jsonschema defined in any cc_* module.
286+
287+ @param: section_key: Optionally limit schema to a specific top-level key.
288+ """
289+ # TODO use util.find_modules in subsequent branch
290+ from cloudinit.config.cc_ntp import schema
291+ return schema
292+
293+
294+def error(message):
295+ print(message, file=sys.stderr)
296+ return 1
297+
298+
299+def get_parser():
300+ """Return a parser for supported cmdline arguments."""
301+ parser = argparse.ArgumentParser()
302+ parser.add_argument('-c', '--config-file',
303+ help='Path of the cloud-config yaml file to validate')
304+ parser.add_argument('-d', '--doc', action="store_true", default=False,
305+ help='Print schema documentation')
306+ parser.add_argument('-k', '--key',
307+ help='Limit validation or docs to a section key')
308+ return parser
309+
310+
311+def main():
312+ """Tool to validate schema of a cloud-config file or print schema docs."""
313+ parser = get_parser()
314+ args = parser.parse_args()
315+ exclusive_args = [args.config_file, args.doc]
316+ if not any(exclusive_args) or all(exclusive_args):
317+ return error('Expected either --config-file argument or --doc')
318+
319+ schema = get_schema()
320+ if args.config_file:
321+ try:
322+ validate_cloudconfig_file(args.config_file, schema)
323+ except (SchemaValidationError, RuntimeError) as e:
324+ return error(str(e))
325+ print("Valid cloud-config file {0}".format(args.config_file))
326+ if args.doc:
327+ print(get_schema_doc(schema))
328+ return 0
329+
330+
331+if __name__ == '__main__':
332+ sys.exit(main())
333+
334+
335+# vi: ts=4 expandtab
336diff --git a/requirements.txt b/requirements.txt
337index 0c4951f..60abab1 100644
338--- a/requirements.txt
339+++ b/requirements.txt
340@@ -36,5 +36,8 @@ requests
341 # For patching pieces of cloud-config together
342 jsonpatch
343
344+# For validating cloud-config sections per schema definitions
345+jsonschema
346+
347 # For Python 2/3 compatibility
348 six
349diff --git a/tests/unittests/helpers.py b/tests/unittests/helpers.py
350index 9ff1599..e78abce 100644
351--- a/tests/unittests/helpers.py
352+++ b/tests/unittests/helpers.py
353@@ -19,10 +19,6 @@ try:
354 from contextlib import ExitStack
355 except ImportError:
356 from contextlib2 import ExitStack
357-try:
358- from cStringIO import StringIO
359-except ImportError:
360- from io import StringIO
361
362 from cloudinit import helpers as ch
363 from cloudinit import util
364@@ -102,7 +98,7 @@ class CiTestCase(TestCase):
365 if self.with_logs:
366 # Create a log handler so unit tests can search expected logs.
367 logger = logging.getLogger()
368- self.logs = StringIO()
369+ self.logs = six.StringIO()
370 handler = logging.StreamHandler(self.logs)
371 self.old_handlers = logger.handlers
372 logger.handlers = [handler]
373diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
374index bc4277b..6cafa63 100644
375--- a/tests/unittests/test_handler/test_handler_ntp.py
376+++ b/tests/unittests/test_handler/test_handler_ntp.py
377@@ -212,4 +212,113 @@ class TestNtp(FilesystemMockingTestCase):
378 'Skipping module named cc_ntp, not present or disabled by cfg\n',
379 self.logs.getvalue())
380
381+ def test_ntp_handler_schema_validation_allows_empty_ntp_config(self):
382+ """Ntp schema validation allows for an empty ntp: configuration."""
383+ invalid_config = {'ntp': {}}
384+ distro = 'ubuntu'
385+ cc = self._get_cloud(distro)
386+ ntp_conf = os.path.join(self.new_root, 'ntp.conf')
387+ with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
388+ stream.write(NTP_TEMPLATE)
389+ with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
390+ cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
391+ self.assertNotIn('Invalid config:', self.logs.getvalue())
392+ with open(ntp_conf) as stream:
393+ content = stream.read()
394+ default_pools = [
395+ "{0}.{1}.pool.ntp.org".format(x, distro)
396+ for x in range(0, cc_ntp.NR_POOL_SERVERS)]
397+ self.assertEqual(
398+ "servers []\npools {0}\n".format(default_pools),
399+ content)
400+
401+ def test_ntp_handler_schema_validation_warns_non_string_item_type(self):
402+ """Ntp schema validation warns of non-strings in pools or servers.
403+
404+ Schema validation is not strict, so ntp config is still be rendered.
405+ """
406+ invalid_config = {'ntp': {'pools': [123], 'servers': ['valid', None]}}
407+ cc = self._get_cloud('ubuntu')
408+ ntp_conf = os.path.join(self.new_root, 'ntp.conf')
409+ with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
410+ stream.write(NTP_TEMPLATE)
411+ with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
412+ cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
413+ self.assertIn(
414+ "Invalid config:\nntp.pools.0: 123 is not of type 'string'\n"
415+ "ntp.servers.1: None is not of type 'string'",
416+ self.logs.getvalue())
417+ with open(ntp_conf) as stream:
418+ content = stream.read()
419+ self.assertEqual("servers ['valid', None]\npools [123]\n", content)
420+
421+ def test_ntp_handler_schema_validation_warns_of_non_array_type(self):
422+ """Ntp schema validation warns of non-array pools or servers types.
423+
424+ Schema validation is not strict, so ntp config is still be rendered.
425+ """
426+ invalid_config = {'ntp': {'pools': 123, 'servers': 'non-array'}}
427+ cc = self._get_cloud('ubuntu')
428+ ntp_conf = os.path.join(self.new_root, 'ntp.conf')
429+ with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
430+ stream.write(NTP_TEMPLATE)
431+ with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
432+ cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
433+ self.assertIn(
434+ "Invalid config:\nntp.pools: 123 is not of type 'array'\n"
435+ "ntp.servers: 'non-array' is not of type 'array'",
436+ self.logs.getvalue())
437+ with open(ntp_conf) as stream:
438+ content = stream.read()
439+ self.assertEqual("servers non-array\npools 123\n", content)
440+
441+ def test_ntp_handler_schema_validation_warns_invalid_key_present(self):
442+ """Ntp schema validation warns of invalid keys present in ntp config.
443+
444+ Schema validation is not strict, so ntp config is still be rendered.
445+ """
446+ invalid_config = {
447+ 'ntp': {'invalidkey': 1, 'pools': ['0.mycompany.pool.ntp.org']}}
448+ cc = self._get_cloud('ubuntu')
449+ ntp_conf = os.path.join(self.new_root, 'ntp.conf')
450+ with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
451+ stream.write(NTP_TEMPLATE)
452+ with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
453+ cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
454+ self.assertIn(
455+ "Invalid config:\nntp: Additional properties are not allowed "
456+ "('invalidkey' was unexpected)",
457+ self.logs.getvalue())
458+ with open(ntp_conf) as stream:
459+ content = stream.read()
460+ self.assertEqual(
461+ "servers []\npools ['0.mycompany.pool.ntp.org']\n",
462+ content)
463+
464+ def test_ntp_handler_schema_validation_warns_of_duplicates(self):
465+ """Ntp schema validation warns of duplicates in servers or pools.
466+
467+ Schema validation is not strict, so ntp config is still be rendered.
468+ """
469+ invalid_config = {
470+ 'ntp': {'pools': ['0.mypool.org', '0.mypool.org'],
471+ 'servers': ['10.0.0.1', '10.0.0.1']}}
472+ cc = self._get_cloud('ubuntu')
473+ ntp_conf = os.path.join(self.new_root, 'ntp.conf')
474+ with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
475+ stream.write(NTP_TEMPLATE)
476+ with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
477+ cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
478+ self.assertIn(
479+ "Invalid config:\nntp.pools: ['0.mypool.org', '0.mypool.org'] has "
480+ "non-unique elements\nntp.servers: ['10.0.0.1', '10.0.0.1'] has "
481+ "non-unique elements",
482+ self.logs.getvalue())
483+ with open(ntp_conf) as stream:
484+ content = stream.read()
485+ self.assertEqual(
486+ "servers ['10.0.0.1', '10.0.0.1']\n"
487+ "pools ['0.mypool.org', '0.mypool.org']\n",
488+ content)
489+
490 # vi: ts=4 expandtab
491diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
492new file mode 100644
493index 0000000..3239e32
494--- /dev/null
495+++ b/tests/unittests/test_handler/test_schema.py
496@@ -0,0 +1,220 @@
497+# This file is part of cloud-init. See LICENSE file for license information.
498+
499+from cloudinit.config.schema import (
500+ CLOUD_CONFIG_HEADER, SchemaValidationError, get_schema_doc,
501+ validate_cloudconfig_file, validate_cloudconfig_schema,
502+ main)
503+from cloudinit.util import write_file
504+
505+from ..helpers import CiTestCase, mock
506+
507+from copy import copy
508+from six import StringIO
509+from textwrap import dedent
510+
511+
512+class SchemaValidationErrorTest(CiTestCase):
513+ """Test validate_cloudconfig_schema"""
514+
515+ def test_schema_validation_error_expects_schema_errors(self):
516+ """SchemaValidationError is initialized from schema_errors."""
517+ errors = (('key.path', 'unexpected key "junk"'),
518+ ('key2.path', '"-123" is not a valid "hostname" format'))
519+ exception = SchemaValidationError(schema_errors=errors)
520+ self.assertIsInstance(exception, Exception)
521+ self.assertEqual(exception.schema_errors, errors)
522+ self.assertEqual(
523+ 'Cloud config schema errors: key.path: unexpected key "junk", '
524+ 'key2.path: "-123" is not a valid "hostname" format',
525+ str(exception))
526+ self.assertTrue(isinstance(exception, ValueError))
527+
528+
529+class ValidateCloudConfigSchemaTest(CiTestCase):
530+ """Tests for validate_cloudconfig_schema."""
531+
532+ with_logs = True
533+
534+ def test_validateconfig_schema_non_strict_emits_warnings(self):
535+ """When strict is False validate_cloudconfig_schema emits warnings."""
536+ schema = {'properties': {'p1': {'type': 'string'}}}
537+ validate_cloudconfig_schema({'p1': -1}, schema, strict=False)
538+ self.assertIn(
539+ "Invalid config:\np1: -1 is not of type 'string'\n",
540+ self.logs.getvalue())
541+
542+ def test_validateconfig_schema_emits_warning_on_missing_jsonschema(self):
543+ """Warning from validate_cloudconfig_schema when missing jsonschema."""
544+ schema = {'properties': {'p1': {'type': 'string'}}}
545+ with mock.patch.dict('sys.modules', **{'jsonschema': ImportError()}):
546+ validate_cloudconfig_schema({'p1': -1}, schema, strict=True)
547+ self.assertIn(
548+ 'Ignoring schema validation. python-jsonschema is not present',
549+ self.logs.getvalue())
550+
551+ def test_validateconfig_schema_strict_raises_errors(self):
552+ """When strict is True validate_cloudconfig_schema raises errors."""
553+ schema = {'properties': {'p1': {'type': 'string'}}}
554+ with self.assertRaises(SchemaValidationError) as context_mgr:
555+ validate_cloudconfig_schema({'p1': -1}, schema, strict=True)
556+ self.assertEqual(
557+ "Cloud config schema errors: p1: -1 is not of type 'string'",
558+ str(context_mgr.exception))
559+
560+ def test_validateconfig_schema_honors_formats(self):
561+ """When strict is True validate_cloudconfig_schema raises errors."""
562+ schema = {
563+ 'properties': {'p1': {'type': 'string', 'format': 'hostname'}}}
564+ with self.assertRaises(SchemaValidationError) as context_mgr:
565+ validate_cloudconfig_schema({'p1': '-1'}, schema, strict=True)
566+ self.assertEqual(
567+ "Cloud config schema errors: p1: '-1' is not a 'hostname'",
568+ str(context_mgr.exception))
569+
570+
571+class ValidateCloudConfigFileTest(CiTestCase):
572+ """Tests for validate_cloudconfig_file."""
573+
574+ def setUp(self):
575+ super(ValidateCloudConfigFileTest, self).setUp()
576+ self.config_file = self.tmp_path('cloudcfg.yaml')
577+
578+ def test_validateconfig_file_error_on_absent_file(self):
579+ """On absent config_path, validate_cloudconfig_file errors."""
580+ with self.assertRaises(RuntimeError) as context_mgr:
581+ validate_cloudconfig_file('/not/here', {})
582+ self.assertEqual(
583+ 'Configfile /not/here does not exist',
584+ str(context_mgr.exception))
585+
586+ def test_validateconfig_file_error_on_invalid_header(self):
587+ """On invalid header, validate_cloudconfig_file errors.
588+
589+ A SchemaValidationError is raised when the file doesn't begin with
590+ CLOUD_CONFIG_HEADER.
591+ """
592+ write_file(self.config_file, '#junk')
593+ with self.assertRaises(SchemaValidationError) as context_mgr:
594+ validate_cloudconfig_file(self.config_file, {})
595+ self.assertEqual(
596+ 'Cloud config schema errors: header: File {0} needs to begin with '
597+ '"{1}"'.format(self.config_file, CLOUD_CONFIG_HEADER.decode()),
598+ str(context_mgr.exception))
599+
600+ def test_validateconfig_file_error_on_non_yaml_format(self):
601+ """On non-yaml format, validate_cloudconfig_file errors."""
602+ write_file(self.config_file, '#cloud-config\n{}}')
603+ with self.assertRaises(SchemaValidationError) as context_mgr:
604+ validate_cloudconfig_file(self.config_file, {})
605+ self.assertIn(
606+ 'schema errors: format: File {0} is not valid yaml.'.format(
607+ self.config_file),
608+ str(context_mgr.exception))
609+
610+ def test_validateconfig_file_sctricty_validates_schema(self):
611+ """validate_cloudconfig_file raises errors on invalid schema."""
612+ schema = {
613+ 'properties': {'p1': {'type': 'string', 'format': 'hostname'}}}
614+ write_file(self.config_file, '#cloud-config\np1: "-1"')
615+ with self.assertRaises(SchemaValidationError) as context_mgr:
616+ validate_cloudconfig_file(self.config_file, schema)
617+ self.assertEqual(
618+ "Cloud config schema errors: p1: '-1' is not a 'hostname'",
619+ str(context_mgr.exception))
620+
621+
622+class GetSchemaDocTest(CiTestCase):
623+ """Tests for get_schema_doc."""
624+
625+ def setUp(self):
626+ super(GetSchemaDocTest, self).setUp()
627+ self.required_schema = {
628+ 'title': 'title', 'description': 'description', 'id': 'id',
629+ 'name': 'name', 'frequency': 'frequency',
630+ 'distros': ['debian', 'rhel']}
631+
632+ def test_get_schema_doc_returns_restructured_text(self):
633+ """get_schema_doc returns restructured text for a cloudinit schema."""
634+ full_schema = copy(self.required_schema)
635+ full_schema.update(
636+ {'properties': {
637+ 'prop1': {'type': 'array', 'description': 'prop-description',
638+ 'items': {'type': 'int'}}}})
639+ self.assertEqual(
640+ dedent("""
641+ name
642+ ---
643+ **Summary:** title
644+
645+ description
646+
647+ **Internal name:** ``id``
648+
649+ **Module frequency:** frequency
650+
651+ **Supported distros:** debian, rhel
652+
653+ **Config schema**:
654+ **prop1:** (array of int) prop-description\n\n"""),
655+ get_schema_doc(full_schema))
656+
657+ def test_get_schema_doc_returns_restructured_text_with_examples(self):
658+ """get_schema_doc returns indented examples when present in schema."""
659+ full_schema = copy(self.required_schema)
660+ full_schema.update(
661+ {'examples': {'ex1': [1, 2, 3]},
662+ 'properties': {
663+ 'prop1': {'type': 'array', 'description': 'prop-description',
664+ 'items': {'type': 'int'}}}})
665+ self.assertIn(
666+ dedent("""
667+ **Config schema**:
668+ **prop1:** (array of int) prop-description
669+
670+ **Examples**::
671+
672+ ex1"""),
673+ get_schema_doc(full_schema))
674+
675+ def test_get_schema_doc_raises_key_errors(self):
676+ """get_schema_doc raises KeyErrors on missing keys."""
677+ for key in self.required_schema:
678+ invalid_schema = copy(self.required_schema)
679+ invalid_schema.pop(key)
680+ with self.assertRaises(KeyError) as context_mgr:
681+ get_schema_doc(invalid_schema)
682+ self.assertEqual("'{0}'".format(key), str(context_mgr.exception))
683+
684+
685+class MainTest(CiTestCase):
686+
687+ def test_main_missing_args(self):
688+ """Main exits non-zero and reports an error on missing parameters."""
689+ with mock.patch('sys.argv', ['mycmd']):
690+ with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr:
691+ self.assertEqual(1, main(), 'Expected non-zero exit code')
692+ self.assertEqual(
693+ 'Expected either --config-file argument or --doc\n',
694+ m_stderr.getvalue())
695+
696+ def test_main_prints_docs(self):
697+ """When --doc parameter is provided, main generates documentation."""
698+ myargs = ['mycmd', '--doc']
699+ with mock.patch('sys.argv', myargs):
700+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
701+ self.assertEqual(0, main(), 'Expected 0 exit code')
702+ self.assertIn('\nNTP\n---\n', m_stdout.getvalue())
703+
704+ def test_main_validates_config_file(self):
705+ """When --config-file parameter is provided, main validates schema."""
706+ myyaml = self.tmp_path('my.yaml')
707+ myargs = ['mycmd', '--config-file', myyaml]
708+ with open(myyaml, 'wb') as stream:
709+ stream.write(b'#cloud-config\nntp:') # shortest ntp schema
710+ with mock.patch('sys.argv', myargs):
711+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
712+ self.assertEqual(0, main(), 'Expected 0 exit code')
713+ self.assertIn(
714+ 'Valid cloud-config file {0}'.format(myyaml), m_stdout.getvalue())
715+
716+# vi: ts=4 expandtab syntax=python
717diff --git a/tools/cloudconfig-schema b/tools/cloudconfig-schema
718new file mode 100755
719index 0000000..32f0d61
720--- /dev/null
721+++ b/tools/cloudconfig-schema
722@@ -0,0 +1,35 @@
723+#!/usr/bin/env python3
724+# This file is part of cloud-init. See LICENSE file for license information.
725+
726+"""cloudconfig-schema
727+
728+Validate existing files against cloud-config schema or provide supported schema
729+documentation.
730+"""
731+
732+import os
733+import sys
734+
735+
736+def call_entry_point(name):
737+ (istr, dot, ent) = name.rpartition('.')
738+ try:
739+ __import__(istr)
740+ except ImportError:
741+ # if that import failed, check dirname(__file__/..)
742+ # to support ./bin/program with modules in .
743+ _tdir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
744+ sys.path.insert(0, _tdir)
745+ try:
746+ __import__(istr)
747+ except ImportError as e:
748+ sys.stderr.write("Unable to find %s: %s\n" % (name, e))
749+ sys.exit(2)
750+
751+ sys.exit(getattr(sys.modules[istr], ent)())
752+
753+
754+if __name__ == '__main__':
755+ call_entry_point("cloudinit.config.schema.main")
756+
757+# vi: ts=4 expandtab syntax=python

Subscribers

People subscribed via source and target branches