Merge ~chad.smith/cloud-init:schema-subcommand into cloud-init:master

Proposed by Chad Smith on 2017-08-17
Status: Merged
Merged at revision: cc9762a2d737ead386ffb9f067adc5e543224560
Proposed branch: ~chad.smith/cloud-init:schema-subcommand
Merge into: cloud-init:master
Prerequisite: ~chad.smith/cloud-init:analyze
Diff against target: 902 lines (+541/-91)
9 files modified
cloudinit/cmd/devel/__init__.py (+0/-0)
cloudinit/cmd/devel/parser.py (+26/-0)
cloudinit/cmd/main.py (+14/-7)
cloudinit/config/cc_runcmd.py (+54/-28)
cloudinit/config/schema.py (+165/-34)
doc/rtd/topics/capabilities.rst (+9/-9)
tests/unittests/test_cli.py (+20/-1)
tests/unittests/test_handler/test_handler_runcmd.py (+108/-0)
tests/unittests/test_handler/test_schema.py (+145/-12)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve on 2017-08-23
Scott Moser 2017-08-17 Approve on 2017-08-22
Review via email: mp+329233@code.launchpad.net

Commit Message

schema cli: Add schema subcommand to cloud-init cli and cc_runcmd schema

This branch does a few things:
  - Add 'schema' subcommand to cloud-init CLI for validating
    cloud-config files against strict module jsonschema definitions
  - Add --annotate parameter to 'cloud-init schema' to annotate
    existing cloud-config file content with validation errors
  - Add jsonschema definition to cc_runcmd
  - Add unit test coverage for cc_runcmd
  - Update CLI capabilities documentation

This branch only imports development (and analyze) subparsers when the
specific subcommand is provided on the CLI to avoid adding costly unused
file imports during cloud-init system boot.

The schema command allows a person to quickly validate a cloud-config text
file against cloud-init's known module schemas to avoid costly roundtrips
deploying instances in their cloud of choice. As of this branch, only
cc_ntp and cc_runcmd cloud-config modules define schemas. Schema
validation will ignore all undefined config keys until all modules define
a strict schema.

To perform validation of runcmd and ntp sections of a cloud-config file:
$ cat > cloud.cfg <<EOF
runcmd: bogus
EOF
$ python -m cloudinit.cmd.main schema --config-file cloud.cfg

$ python -m cloudinit.cmd.main schema --config-file cloud.cfg \
  --annotate

Once jsonschema is defined for all ~55 cc modules, we will move this
schema subcommand up as a proper subcommand of the cloud-init CLI.

Description of the Change

schema cli: Add schema subcommand to cloud-init cli and cc_runcmd schema

This branch does a few things:
  - Add 'devel schema' subcommand to cloud-init CLI for validating
    cloud-config files against strict module jsonschema definitions
  - Add --annotate parameter to 'cloud-init devel schema' to annotate
    existing cloud-config file content with validation errors
  - Add jsonschema definition to cc_runcmd
  - Add unit test coverage for cc_runcmd
  - Update CLI capabilities documentation

This branch only imports development (and analyze) subparsers when the
specific subcommand is provided on the CLI to avoid adding costly unused
file imports during cloud-init system boot.

The 'devel' subcommand is intended to be the home for developer tools
which can be run on the commandline on a system with cloud-init installed.
The schema command allows a person to quickly validate a cloud-config text
file against cloud-init's known module schemas to avoid costly roundtrips
deploying instances in their cloud of choice. As of this branch, only
cc_ntp and cc_runcmd cloud-config modules define schemas. Schema
validation will ignore all undefined config keys until all modules define
a strict schema.

To perform validation of runcmd and ntp sections of a cloud-config file:
$ cat > cloud.cfg <<EOF
runcmd: bogus
EOF
$ python -m cloudinit.cmd.main devel schema --config-file cloud.cfg

$ python -m cloudinit.cmd.main devel schema --config-file cloud.cfg \
  --annotate

Once jsonschema is defined for all ~55 cc modules, we will move this
schema subcommand up as a proper subcommand of the cloud-init CLI.

To test:

$ cat > invalid-cloud.cfg <<EOF runcmd: bogus EOF

$ python -m cloudinit.cmd.main devel schema --config-file
invalid-cloud.cfg

# print docs as manpages
$ python -m cloudinit.cmd.main devel schema --doc | rst2man | man -l -

# generate docs and check capabilities topic
tox -e doc

To post a comment you must log in.

FAILED: Continuous integration, rev:a154884b66a2914c7fbb4c6df17a5a9c697e7d7e
https://jenkins.ubuntu.com/server/job/cloud-init-ci/163/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    FAILED: MAAS Compatability Testing

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/163/rebuild

review: Needs Fixing (continuous-integration)

PASSED: Continuous integration, rev:b57e9f11d3e485f5907bc8f5e08e554903560125
https://jenkins.ubuntu.com/server/job/cloud-init-ci/167/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/167/rebuild

review: Approve (continuous-integration)

PASSED: Continuous integration, rev:51be991d3d01da69b04d8f9cf684eddda30f2d62
https://jenkins.ubuntu.com/server/job/cloud-init-ci/170/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/170/rebuild

review: Approve (continuous-integration)
30e8ffa... by Chad Smith on 2017-08-22

add basic annotate argument to schema subcommand which will annotate original cloud-config file with parsed errors

39ccb27... by Chad Smith on 2017-08-22

flakes

Scott Moser (smoser) wrote :

I think we decided with 'analyze' to move not move this to being "under 'devel'", but rther just having the help show 'Devel tool', and having the devel commands later in help output.

So, make similar changes to this merge proposal.

And t hen last, if you could drop the FIXME from cloudinit/cmd/main.py

    # FIXME put this under 'devel' subcommand (coming in next branch)

as I think its *not* coming in next branch.

Please also make sure your changes to doc/rtd/topics/debugging.rst are relevant. I suspect maybe they'll fallout if you rebase to master.

review: Needs Fixing
a96682b... by Chad Smith on 2017-08-22

conditionally load parser and devel subcommands

cd0a774... by Chad Smith on 2017-08-22

revert unittest and doc changes from master

FAILED: Continuous integration, rev:a96682bc94d30c0d102880a7317323fe94373b9e
https://jenkins.ubuntu.com/server/job/cloud-init-ci/178/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/178/rebuild

review: Needs Fixing (continuous-integration)
54e9623... by Chad Smith on 2017-08-22

trim cli help content in capabilities to drop 'query' subcommand and add 'analyze' and 'devel'

FAILED: Continuous integration, rev:54e96236793aa8a820241a84b4a66db620f7ba23
https://jenkins.ubuntu.com/server/job/cloud-init-ci/179/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/179/rebuild

review: Needs Fixing (continuous-integration)
bc90905... by Chad Smith on 2017-08-22

rename annotate_errors -> annotated_cloudconfig_file. Add unit tests for annotate

1b1be36... by Chad Smith on 2017-08-22

encode cloudconfig content for annotate_cloudconfig_file in unittests as it expects bytes

FAILED: Continuous integration, rev:1b1be361dd1c876aaece2ded29c82f5fe315a0e0
https://jenkins.ubuntu.com/server/job/cloud-init-ci/180/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    FAILED: MAAS Compatability Testing

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/180/rebuild

review: Needs Fixing (continuous-integration)
32af13a... by Chad Smith on 2017-08-22

py26 unit test fix

FAILED: Continuous integration, rev:32af13a431d0bf44bf474702b52c867d1904b47f
https://jenkins.ubuntu.com/server/job/cloud-init-ci/182/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    FAILED: MAAS Compatability Testing

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/182/rebuild

review: Needs Fixing (continuous-integration)
Scott Moser (smoser) wrote :

c-i stilldoesnt like your branch.
I updated the commit message, please do re-read it though to make sure it still is sane.

assuming you get c-i happy, I approve.

review: Approve
313dd89... by Chad Smith on 2017-08-22

now really fix the py26 str(ctxt_mgr.exception)

PASSED: Continuous integration, rev:313dd8997fe0224a61d78f740bf1f1d0e3ff14ff
https://jenkins.ubuntu.com/server/job/cloud-init-ci/186/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/186/rebuild

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/cmd/devel/__init__.py b/cloudinit/cmd/devel/__init__.py
2new file mode 100644
3index 0000000..e69de29
4--- /dev/null
5+++ b/cloudinit/cmd/devel/__init__.py
6diff --git a/cloudinit/cmd/devel/parser.py b/cloudinit/cmd/devel/parser.py
7new file mode 100644
8index 0000000..acacc4e
9--- /dev/null
10+++ b/cloudinit/cmd/devel/parser.py
11@@ -0,0 +1,26 @@
12+# Copyright (C) 2017 Canonical Ltd.
13+#
14+# This file is part of cloud-init. See LICENSE file for license information.
15+
16+"""Define 'devel' subcommand argument parsers to include in cloud-init cmd."""
17+
18+import argparse
19+from cloudinit.config.schema import (
20+ get_parser as schema_parser, handle_schema_args)
21+
22+
23+def get_parser(parser=None):
24+ if not parser:
25+ parser = argparse.ArgumentParser(
26+ prog='cloudinit-devel',
27+ description='Run development cloud-init tools')
28+ subparsers = parser.add_subparsers(title='Subcommands', dest='subcommand')
29+ subparsers.required = True
30+
31+ parser_schema = subparsers.add_parser(
32+ 'schema', help='Validate cloud-config files or document schema')
33+ # Construct schema subcommand parser
34+ schema_parser(parser_schema)
35+ parser_schema.set_defaults(action=('schema', handle_schema_args))
36+
37+ return parser
38diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
39index 9c0ac86..5b46797 100644
40--- a/cloudinit/cmd/main.py
41+++ b/cloudinit/cmd/main.py
42@@ -705,7 +705,6 @@ def main(sysv_args=None):
43 subparsers.required = True
44
45 # Each action and its sub-options (if any)
46-
47 parser_init = subparsers.add_parser('init',
48 help=('initializes cloud-init and'
49 ' performs initial modules'))
50@@ -762,12 +761,20 @@ def main(sysv_args=None):
51
52 parser_analyze = subparsers.add_parser(
53 'analyze', help='Devel tool: Analyze cloud-init logs and data')
54- if sysv_args and sysv_args[0] == 'analyze':
55- # Only load this parser if analyze is specified to avoid file load cost
56- # FIXME put this under 'devel' subcommand (coming in next branch)
57- from cloudinit.analyze.__main__ import get_parser as analyze_parser
58- # Construct analyze subcommand parser
59- analyze_parser(parser_analyze)
60+
61+ parser_devel = subparsers.add_parser(
62+ 'devel', help='Run development tools')
63+
64+ if sysv_args:
65+ # Only load subparsers if subcommand is specified to avoid load cost
66+ if sysv_args[0] == 'analyze':
67+ from cloudinit.analyze.__main__ import get_parser as analyze_parser
68+ # Construct analyze subcommand parser
69+ analyze_parser(parser_analyze)
70+ if sysv_args[0] == 'devel':
71+ from cloudinit.cmd.devel.parser import get_parser as devel_parser
72+ # Construct devel subcommand parser
73+ devel_parser(parser_devel)
74
75 args = parser.parse_args(args=sysv_args)
76
77diff --git a/cloudinit/config/cc_runcmd.py b/cloudinit/config/cc_runcmd.py
78index dfa8cb3..7c3ccd4 100644
79--- a/cloudinit/config/cc_runcmd.py
80+++ b/cloudinit/config/cc_runcmd.py
81@@ -6,41 +6,66 @@
82 #
83 # This file is part of cloud-init. See LICENSE file for license information.
84
85-"""
86-Runcmd
87-------
88-**Summary:** run commands
89+"""Runcmd: run arbitrary commands at rc.local with output to the console"""
90
91-Run arbitrary commands at a rc.local like level with output to the console.
92-Each item can be either a list or a string. If the item is a list, it will be
93-properly executed as if passed to ``execve()`` (with the first arg as the
94-command). If the item is a string, it will be written to a file and interpreted
95-using ``sh``.
96-
97-.. note::
98- all commands must be proper yaml, so you have to quote any characters yaml
99- would eat (':' can be problematic)
100-
101-**Internal name:** ``cc_runcmd``
102-
103-**Module frequency:** per instance
104+from cloudinit.config.schema import validate_cloudconfig_schema
105+from cloudinit.settings import PER_INSTANCE
106+from cloudinit import util
107
108-**Supported distros:** all
109+import os
110+from textwrap import dedent
111
112-**Config keys**::
113
114- runcmd:
115- - [ ls, -l, / ]
116- - [ sh, -xc, "echo $(date) ': hello world!'" ]
117- - [ sh, -c, echo "=========hello world'=========" ]
118- - ls -l /root
119- - [ wget, "http://example.org", -O, /tmp/index.html ]
120-"""
121+# The schema definition for each cloud-config module is a strict contract for
122+# describing supported configuration parameters for each cloud-config section.
123+# It allows cloud-config to validate and alert users to invalid or ignored
124+# configuration options before actually attempting to deploy with said
125+# configuration.
126
127+distros = ['all']
128
129-import os
130+schema = {
131+ 'id': 'cc_runcmd',
132+ 'name': 'Runcmd',
133+ 'title': 'Run arbitrary commands',
134+ 'description': dedent("""\
135+ Run arbitrary commands at a rc.local like level with output to the
136+ console. Each item can be either a list or a string. If the item is a
137+ list, it will be properly executed as if passed to ``execve()`` (with
138+ the first arg as the command). If the item is a string, it will be
139+ written to a file and interpreted
140+ using ``sh``.
141
142-from cloudinit import util
143+ .. note::
144+ all commands must be proper yaml, so you have to quote any characters
145+ yaml would eat (':' can be problematic)"""),
146+ 'distros': distros,
147+ 'examples': [dedent("""\
148+ runcmd:
149+ - [ ls, -l, / ]
150+ - [ sh, -xc, "echo $(date) ': hello world!'" ]
151+ - [ sh, -c, echo "=========hello world'=========" ]
152+ - ls -l /root
153+ - [ wget, "http://example.org", -O, /tmp/index.html ]
154+ """)],
155+ 'frequency': PER_INSTANCE,
156+ 'type': 'object',
157+ 'properties': {
158+ 'runcmd': {
159+ 'type': 'array',
160+ 'items': {
161+ 'oneOf': [
162+ {'type': 'array', 'items': {'type': 'string'}},
163+ {'type': 'string'}]
164+ },
165+ 'additionalItems': False, # Reject items of non-string non-list
166+ 'additionalProperties': False,
167+ 'minItems': 1,
168+ 'required': [],
169+ 'uniqueItems': True
170+ }
171+ }
172+}
173
174
175 def handle(name, cfg, cloud, log, _args):
176@@ -49,6 +74,7 @@ def handle(name, cfg, cloud, log, _args):
177 " no 'runcmd' key in configuration"), name)
178 return
179
180+ validate_cloudconfig_schema(cfg, schema)
181 out_fn = os.path.join(cloud.get_ipath('scripts'), "runcmd")
182 cmd = cfg["runcmd"]
183 try:
184diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py
185index 6400f00..73dd5c2 100644
186--- a/cloudinit/config/schema.py
187+++ b/cloudinit/config/schema.py
188@@ -3,11 +3,14 @@
189
190 from __future__ import print_function
191
192-from cloudinit.util import read_file_or_url
193+from cloudinit import importer
194+from cloudinit.util import find_modules, read_file_or_url
195
196 import argparse
197+from collections import defaultdict
198 import logging
199 import os
200+import re
201 import sys
202 import yaml
203
204@@ -15,7 +18,7 @@ SCHEMA_UNDEFINED = b'UNDEFINED'
205 CLOUD_CONFIG_HEADER = b'#cloud-config'
206 SCHEMA_DOC_TMPL = """
207 {name}
208----
209+{title_underbar}
210 **Summary:** {title}
211
212 {description}
213@@ -83,11 +86,49 @@ def validate_cloudconfig_schema(config, schema, strict=False):
214 logging.warning('Invalid config:\n%s', '\n'.join(messages))
215
216
217-def validate_cloudconfig_file(config_path, schema):
218+def annotated_cloudconfig_file(cloudconfig, original_content, schema_errors):
219+ """Return contents of the cloud-config file annotated with schema errors.
220+
221+ @param cloudconfig: YAML-loaded object from the original_content.
222+ @param original_content: The contents of a cloud-config file
223+ @param schema_errors: List of tuples from a JSONSchemaValidationError. The
224+ tuples consist of (schemapath, error_message).
225+ """
226+ if not schema_errors:
227+ return original_content
228+ schemapaths = _schemapath_for_cloudconfig(cloudconfig, original_content)
229+ errors_by_line = defaultdict(list)
230+ error_count = 1
231+ error_footer = []
232+ annotated_content = []
233+ for path, msg in schema_errors:
234+ errors_by_line[schemapaths[path]].append(msg)
235+ error_footer.append('# E{0}: {1}'.format(error_count, msg))
236+ error_count += 1
237+ lines = original_content.decode().split('\n')
238+ error_count = 1
239+ for line_number, line in enumerate(lines):
240+ errors = errors_by_line[line_number + 1]
241+ if errors:
242+ error_label = ','.join(
243+ ['E{0}'.format(count + error_count)
244+ for count in range(0, len(errors))])
245+ error_count += len(errors)
246+ annotated_content.append(line + '\t\t# ' + error_label)
247+ else:
248+ annotated_content.append(line)
249+ annotated_content.append(
250+ '# Errors: -------------\n{0}\n\n'.format('\n'.join(error_footer)))
251+ return '\n'.join(annotated_content)
252+
253+
254+def validate_cloudconfig_file(config_path, schema, annotate=False):
255 """Validate cloudconfig file adheres to a specific jsonschema.
256
257 @param config_path: Path to the yaml cloud-config file to parse.
258 @param schema: Dict describing a valid jsonschema to validate against.
259+ @param annotate: Boolean set True to print original config file with error
260+ annotations on the offending lines.
261
262 @raises SchemaValidationError containing any of schema_errors encountered.
263 @raises RuntimeError when config_path does not exist.
264@@ -108,8 +149,64 @@ def validate_cloudconfig_file(config_path, schema):
265 ('format', 'File {0} is not valid yaml. {1}'.format(
266 config_path, str(e))),)
267 raise SchemaValidationError(errors)
268- validate_cloudconfig_schema(
269- cloudconfig, schema, strict=True)
270+
271+ try:
272+ validate_cloudconfig_schema(
273+ cloudconfig, schema, strict=True)
274+ except SchemaValidationError as e:
275+ if annotate:
276+ print(annotated_cloudconfig_file(
277+ cloudconfig, content, e.schema_errors))
278+ raise
279+
280+
281+def _schemapath_for_cloudconfig(config, original_content):
282+ """Return a dictionary mapping schemapath to original_content line number.
283+
284+ @param config: The yaml.loaded config dictionary of a cloud-config file.
285+ @param original_content: The simple file content of the cloud-config file
286+ """
287+ # FIXME Doesn't handle multi-line lists or multi-line strings
288+ content_lines = original_content.decode().split('\n')
289+ schema_line_numbers = {}
290+ list_index = 0
291+ RE_YAML_INDENT = r'^(\s*)'
292+ scopes = []
293+ for line_number, line in enumerate(content_lines):
294+ indent_depth = len(re.match(RE_YAML_INDENT, line).groups()[0])
295+ line = line.strip()
296+ if not line or line.startswith('#'):
297+ continue
298+ if scopes:
299+ previous_depth, path_prefix = scopes[-1]
300+ else:
301+ previous_depth = -1
302+ path_prefix = ''
303+ if line.startswith('- '):
304+ key = str(list_index)
305+ value = line[1:]
306+ list_index += 1
307+ else:
308+ list_index = 0
309+ key, value = line.split(':', 1)
310+ while indent_depth <= previous_depth:
311+ if scopes:
312+ previous_depth, path_prefix = scopes.pop()
313+ else:
314+ previous_depth = -1
315+ path_prefix = ''
316+ if path_prefix:
317+ key = path_prefix + '.' + key
318+ scopes.append((indent_depth, key))
319+ if value:
320+ value = value.strip()
321+ if value.startswith('['):
322+ scopes.append((indent_depth + 2, key + '.0'))
323+ for inner_list_index in range(0, len(yaml.safe_load(value))):
324+ list_key = key + '.' + str(inner_list_index)
325+ schema_line_numbers[list_key] = line_number + 1
326+ schema_line_numbers[key] = line_number + 1
327+ return schema_line_numbers
328
329
330 def _get_property_type(property_dict):
331@@ -117,9 +214,15 @@ def _get_property_type(property_dict):
332 property_type = property_dict.get('type', SCHEMA_UNDEFINED)
333 if isinstance(property_type, list):
334 property_type = '/'.join(property_type)
335- item_type = property_dict.get('items', {}).get('type')
336- if item_type:
337- property_type = '{0} of {1}'.format(property_type, item_type)
338+ items = property_dict.get('items', {})
339+ sub_property_type = items.get('type', '')
340+ # Collect each item type
341+ for sub_item in items.get('oneOf', {}):
342+ if sub_property_type:
343+ sub_property_type += '/'
344+ sub_property_type += '(' + _get_property_type(sub_item) + ')'
345+ if sub_property_type:
346+ return '{0} of {1}'.format(property_type, sub_property_type)
347 return property_type
348
349
350@@ -148,9 +251,12 @@ def _get_schema_examples(schema, prefix=''):
351 return ''
352 rst_content = '\n**Examples**::\n\n'
353 for example in examples:
354- example_yaml = yaml.dump(example, default_flow_style=False)
355+ if isinstance(example, str):
356+ example_content = example
357+ else:
358+ example_content = yaml.dump(example, default_flow_style=False)
359 # Python2.6 is missing textwrapper.indent
360- lines = example_yaml.split('\n')
361+ lines = example_content.split('\n')
362 indented_lines = [' {0}'.format(line) for line in lines]
363 rst_content += '\n'.join(indented_lines)
364 return rst_content
365@@ -165,58 +271,83 @@ def get_schema_doc(schema):
366 schema['property_doc'] = _get_property_doc(schema)
367 schema['examples'] = _get_schema_examples(schema)
368 schema['distros'] = ', '.join(schema['distros'])
369+ # Need an underbar of the same length as the name
370+ schema['title_underbar'] = re.sub(r'.', '-', schema['name'])
371 return SCHEMA_DOC_TMPL.format(**schema)
372
373
374-def get_schema(section_key=None):
375- """Return a dict of jsonschema defined in any cc_* module.
376+FULL_SCHEMA = None
377
378- @param: section_key: Optionally limit schema to a specific top-level key.
379- """
380- # TODO use util.find_modules in subsequent branch
381- from cloudinit.config.cc_ntp import schema
382- return schema
383+
384+def get_schema():
385+ """Return jsonschema coalesced from all cc_* cloud-config module."""
386+ global FULL_SCHEMA
387+ if FULL_SCHEMA:
388+ return FULL_SCHEMA
389+ full_schema = {
390+ '$schema': 'http://json-schema.org/draft-04/schema#',
391+ 'id': 'cloud-config-schema', 'allOf': []}
392+
393+ configs_dir = os.path.dirname(os.path.abspath(__file__))
394+ potential_handlers = find_modules(configs_dir)
395+ for (fname, mod_name) in potential_handlers.items():
396+ mod_locs, looked_locs = importer.find_module(
397+ mod_name, ['cloudinit.config'], ['schema'])
398+ if mod_locs:
399+ mod = importer.import_module(mod_locs[0])
400+ full_schema['allOf'].append(mod.schema)
401+ FULL_SCHEMA = full_schema
402+ return full_schema
403
404
405 def error(message):
406 print(message, file=sys.stderr)
407- return 1
408+ sys.exit(1)
409
410
411-def get_parser():
412+def get_parser(parser=None):
413 """Return a parser for supported cmdline arguments."""
414- parser = argparse.ArgumentParser()
415+ if not parser:
416+ parser = argparse.ArgumentParser(
417+ prog='cloudconfig-schema',
418+ description='Validate cloud-config files or document schema')
419 parser.add_argument('-c', '--config-file',
420 help='Path of the cloud-config yaml file to validate')
421 parser.add_argument('-d', '--doc', action="store_true", default=False,
422 help='Print schema documentation')
423- parser.add_argument('-k', '--key',
424- help='Limit validation or docs to a section key')
425+ parser.add_argument('--annotate', action="store_true", default=False,
426+ help='Annotate existing cloud-config file with errors')
427 return parser
428
429
430-def main():
431- """Tool to validate schema of a cloud-config file or print schema docs."""
432- parser = get_parser()
433- args = parser.parse_args()
434+def handle_schema_args(name, args):
435+ """Handle provided schema args and perform the appropriate actions."""
436 exclusive_args = [args.config_file, args.doc]
437 if not any(exclusive_args) or all(exclusive_args):
438- return error('Expected either --config-file argument or --doc')
439-
440- schema = get_schema()
441+ error('Expected either --config-file argument or --doc')
442+ full_schema = get_schema()
443 if args.config_file:
444 try:
445- validate_cloudconfig_file(args.config_file, schema)
446+ validate_cloudconfig_file(
447+ args.config_file, full_schema, args.annotate)
448 except (SchemaValidationError, RuntimeError) as e:
449- return error(str(e))
450- print("Valid cloud-config file {0}".format(args.config_file))
451+ if not args.annotate:
452+ error(str(e))
453+ else:
454+ print("Valid cloud-config file {0}".format(args.config_file))
455 if args.doc:
456- print(get_schema_doc(schema))
457+ for subschema in full_schema['allOf']:
458+ print(get_schema_doc(subschema))
459+
460+
461+def main():
462+ """Tool to validate schema of a cloud-config file or print schema docs."""
463+ parser = get_parser()
464+ handle_schema_args('cloudconfig-schema', parser.parse_args())
465 return 0
466
467
468 if __name__ == '__main__':
469 sys.exit(main())
470
471-
472 # vi: ts=4 expandtab
473diff --git a/doc/rtd/topics/capabilities.rst b/doc/rtd/topics/capabilities.rst
474index b8034b0..31eaba5 100644
475--- a/doc/rtd/topics/capabilities.rst
476+++ b/doc/rtd/topics/capabilities.rst
477@@ -51,15 +51,6 @@ described in this document.
478 usage: cloud-init [-h] [--version] [--file FILES] [--debug] [--force]
479 {init,modules,query,single,dhclient-hook,features} ...
480
481- positional arguments:
482- {init,modules,query,single,dhclient-hook,features}
483- init initializes cloud-init and performs initial modules
484- modules activates modules using a given configuration key
485- query query information stored in cloud-init
486- single run a single module
487- dhclient-hook run the dhclient hookto record network info
488- features list defined features
489-
490 optional arguments:
491 -h, --help show this help message and exit
492 --version, -v show program's version number and exit
493@@ -69,6 +60,15 @@ described in this document.
494 --force force running even if no datasource is found (use at
495 your own risk)
496
497+ Subcommands:
498+ {init,modules,single,dhclient-hook,features,analyze,devel}
499+ init initializes cloud-init and performs initial modules
500+ modules activates modules using a given configuration key
501+ single run a single module
502+ dhclient-hook run the dhclient hookto record network info
503+ features list defined features
504+ analyze Devel tool: Analyze cloud-init logs and data
505+ devel Run development tools
506
507 % cloud-init features
508 NETWORK_CONFIG_V1
509diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
510index 7780f16..2449880 100644
511--- a/tests/unittests/test_cli.py
512+++ b/tests/unittests/test_cli.py
513@@ -46,7 +46,7 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):
514 self._call_main()
515 error = self.stderr.getvalue()
516 expected_subcommands = ['analyze', 'init', 'modules', 'single',
517- 'dhclient-hook', 'features']
518+ 'dhclient-hook', 'features', 'devel']
519 for subcommand in expected_subcommands:
520 self.assertIn(subcommand, error)
521
522@@ -79,6 +79,25 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):
523 for subcommand in expected_subcommands:
524 self.assertIn(subcommand, error)
525
526+ def test_devel_subcommand_parser(self):
527+ """The subcommand cloud-init devel calls the correct subparser."""
528+ self._call_main(['cloud-init', 'devel'])
529+ # These subcommands only valid for cloud-init schema script
530+ expected_subcommands = ['schema']
531+ error = self.stderr.getvalue()
532+ for subcommand in expected_subcommands:
533+ self.assertIn(subcommand, error)
534+
535+ @mock.patch('cloudinit.config.schema.handle_schema_args')
536+ def test_wb_devel_schema_subcommand_parser(self, m_schema):
537+ """The subcommand cloud-init schema calls the correct subparser."""
538+ exit_code = self._call_main(['cloud-init', 'devel', 'schema'])
539+ self.assertEqual(1, exit_code)
540+ # Known whitebox output from schema subcommand
541+ self.assertEqual(
542+ 'Expected either --config-file argument or --doc\n',
543+ self.stderr.getvalue())
544+
545 @mock.patch('cloudinit.cmd.main.main_single')
546 def test_single_subcommand(self, m_main_single):
547 """The subcommand 'single' calls main_single with valid args."""
548diff --git a/tests/unittests/test_handler/test_handler_runcmd.py b/tests/unittests/test_handler/test_handler_runcmd.py
549new file mode 100644
550index 0000000..7880ee7
551--- /dev/null
552+++ b/tests/unittests/test_handler/test_handler_runcmd.py
553@@ -0,0 +1,108 @@
554+# This file is part of cloud-init. See LICENSE file for license information.
555+
556+from cloudinit.config import cc_runcmd
557+from cloudinit.sources import DataSourceNone
558+from cloudinit import (distros, helpers, cloud, util)
559+from ..helpers import FilesystemMockingTestCase, skipIf
560+
561+import logging
562+import os
563+import stat
564+
565+try:
566+ import jsonschema
567+ assert jsonschema # avoid pyflakes error F401: import unused
568+ _missing_jsonschema_dep = False
569+except ImportError:
570+ _missing_jsonschema_dep = True
571+
572+LOG = logging.getLogger(__name__)
573+
574+
575+class TestRuncmd(FilesystemMockingTestCase):
576+
577+ with_logs = True
578+
579+ def setUp(self):
580+ super(TestRuncmd, self).setUp()
581+ self.subp = util.subp
582+ self.new_root = self.tmp_dir()
583+
584+ def _get_cloud(self, distro):
585+ self.patchUtils(self.new_root)
586+ paths = helpers.Paths({'scripts': self.new_root})
587+ cls = distros.fetch(distro)
588+ mydist = cls(distro, {}, paths)
589+ myds = DataSourceNone.DataSourceNone({}, mydist, paths)
590+ paths.datasource = myds
591+ return cloud.Cloud(myds, paths, {}, mydist, None)
592+
593+ def test_handler_skip_if_no_runcmd(self):
594+ """When the provided config doesn't contain runcmd, skip it."""
595+ cfg = {}
596+ mycloud = self._get_cloud('ubuntu')
597+ cc_runcmd.handle('notimportant', cfg, mycloud, LOG, None)
598+ self.assertIn(
599+ "Skipping module named notimportant, no 'runcmd' key",
600+ self.logs.getvalue())
601+
602+ def test_handler_invalid_command_set(self):
603+ """Commands which can't be converted to shell will raise errors."""
604+ invalid_config = {'runcmd': 1}
605+ cc = self._get_cloud('ubuntu')
606+ cc_runcmd.handle('cc_runcmd', invalid_config, cc, LOG, [])
607+ self.assertIn(
608+ 'Failed to shellify 1 into file'
609+ ' /var/lib/cloud/instances/iid-datasource-none/scripts/runcmd',
610+ self.logs.getvalue())
611+
612+ @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
613+ def test_handler_schema_validation_warns_non_array_type(self):
614+ """Schema validation warns of non-array type for runcmd key.
615+
616+ Schema validation is not strict, so runcmd attempts to shellify the
617+ invalid content.
618+ """
619+ invalid_config = {'runcmd': 1}
620+ cc = self._get_cloud('ubuntu')
621+ cc_runcmd.handle('cc_runcmd', invalid_config, cc, LOG, [])
622+ self.assertIn(
623+ 'Invalid config:\nruncmd: 1 is not of type \'array\'',
624+ self.logs.getvalue())
625+ self.assertIn('Failed to shellify', self.logs.getvalue())
626+
627+ @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
628+ def test_handler_schema_validation_warns_non_array_item_type(self):
629+ """Schema validation warns of non-array or string runcmd items.
630+
631+ Schema validation is not strict, so runcmd attempts to shellify the
632+ invalid content.
633+ """
634+ invalid_config = {
635+ 'runcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
636+ cc = self._get_cloud('ubuntu')
637+ cc_runcmd.handle('cc_runcmd', invalid_config, cc, LOG, [])
638+ expected_warnings = [
639+ 'runcmd.1: 20 is not valid under any of the given schemas',
640+ 'runcmd.3: {\'a\': \'n\'} is not valid under any of the given'
641+ ' schema'
642+ ]
643+ logs = self.logs.getvalue()
644+ for warning in expected_warnings:
645+ self.assertIn(warning, logs)
646+ self.assertIn('Failed to shellify', logs)
647+
648+ def test_handler_write_valid_runcmd_schema_to_file(self):
649+ """Valid runcmd schema is written to a runcmd shell script."""
650+ valid_config = {'runcmd': [['ls', '/']]}
651+ cc = self._get_cloud('ubuntu')
652+ cc_runcmd.handle('cc_runcmd', valid_config, cc, LOG, [])
653+ runcmd_file = os.path.join(
654+ self.new_root,
655+ 'var/lib/cloud/instances/iid-datasource-none/scripts/runcmd')
656+ self.assertEqual("#!/bin/sh\n'ls' '/'\n", util.load_file(runcmd_file))
657+ file_stat = os.stat(runcmd_file)
658+ self.assertEqual(0o700, stat.S_IMODE(file_stat.st_mode))
659+
660+
661+# vi: ts=4 expandtab
662diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
663index eda4802..640f11d 100644
664--- a/tests/unittests/test_handler/test_schema.py
665+++ b/tests/unittests/test_handler/test_schema.py
666@@ -1,9 +1,9 @@
667 # This file is part of cloud-init. See LICENSE file for license information.
668
669 from cloudinit.config.schema import (
670- CLOUD_CONFIG_HEADER, SchemaValidationError, get_schema_doc,
671- validate_cloudconfig_file, validate_cloudconfig_schema,
672- main)
673+ CLOUD_CONFIG_HEADER, SchemaValidationError, annotated_cloudconfig_file,
674+ get_schema_doc, get_schema, validate_cloudconfig_file,
675+ validate_cloudconfig_schema, main)
676 from cloudinit.util import write_file
677
678 from ..helpers import CiTestCase, mock, skipIf
679@@ -11,6 +11,7 @@ from ..helpers import CiTestCase, mock, skipIf
680 from copy import copy
681 from six import StringIO
682 from textwrap import dedent
683+from yaml import safe_load
684
685 try:
686 import jsonschema
687@@ -20,6 +21,29 @@ except ImportError:
688 _missing_jsonschema_dep = True
689
690
691+class GetSchemaTest(CiTestCase):
692+
693+ def test_get_schema_coalesces_known_schema(self):
694+ """Every cloudconfig module with schema is listed in allOf keyword."""
695+ schema = get_schema()
696+ self.assertItemsEqual(
697+ ['cc_ntp', 'cc_runcmd'],
698+ [subschema['id'] for subschema in schema['allOf']])
699+ self.assertEqual('cloud-config-schema', schema['id'])
700+ self.assertEqual(
701+ 'http://json-schema.org/draft-04/schema#',
702+ schema['$schema'])
703+ # FULL_SCHEMA is updated by the get_schema call
704+ from cloudinit.config.schema import FULL_SCHEMA
705+ self.assertItemsEqual(['id', '$schema', 'allOf'], FULL_SCHEMA.keys())
706+
707+ def test_get_schema_returns_global_when_set(self):
708+ """When FULL_SCHEMA global is already set, get_schema returns it."""
709+ m_schema_path = 'cloudinit.config.schema.FULL_SCHEMA'
710+ with mock.patch(m_schema_path, {'here': 'iam'}):
711+ self.assertEqual({'here': 'iam'}, get_schema())
712+
713+
714 class SchemaValidationErrorTest(CiTestCase):
715 """Test validate_cloudconfig_schema"""
716
717@@ -151,11 +175,11 @@ class GetSchemaDocTest(CiTestCase):
718 full_schema.update(
719 {'properties': {
720 'prop1': {'type': 'array', 'description': 'prop-description',
721- 'items': {'type': 'int'}}}})
722+ 'items': {'type': 'integer'}}}})
723 self.assertEqual(
724 dedent("""
725 name
726- ---
727+ ----
728 **Summary:** title
729
730 description
731@@ -167,27 +191,71 @@ class GetSchemaDocTest(CiTestCase):
732 **Supported distros:** debian, rhel
733
734 **Config schema**:
735- **prop1:** (array of int) prop-description\n\n"""),
736+ **prop1:** (array of integer) prop-description\n\n"""),
737+ get_schema_doc(full_schema))
738+
739+ def test_get_schema_doc_handles_multiple_types(self):
740+ """get_schema_doc delimits multiple property types with a '/'."""
741+ full_schema = copy(self.required_schema)
742+ full_schema.update(
743+ {'properties': {
744+ 'prop1': {'type': ['string', 'integer'],
745+ 'description': 'prop-description'}}})
746+ self.assertIn(
747+ '**prop1:** (string/integer) prop-description',
748+ get_schema_doc(full_schema))
749+
750+ def test_get_schema_doc_handles_nested_oneof_property_types(self):
751+ """get_schema_doc describes array items oneOf declarations in type."""
752+ full_schema = copy(self.required_schema)
753+ full_schema.update(
754+ {'properties': {
755+ 'prop1': {'type': 'array',
756+ 'items': {
757+ 'oneOf': [{'type': 'string'},
758+ {'type': 'integer'}]},
759+ 'description': 'prop-description'}}})
760+ self.assertIn(
761+ '**prop1:** (array of (string)/(integer)) prop-description',
762 get_schema_doc(full_schema))
763
764 def test_get_schema_doc_returns_restructured_text_with_examples(self):
765 """get_schema_doc returns indented examples when present in schema."""
766 full_schema = copy(self.required_schema)
767 full_schema.update(
768- {'examples': {'ex1': [1, 2, 3]},
769+ {'examples': [{'ex1': [1, 2, 3]}],
770 'properties': {
771 'prop1': {'type': 'array', 'description': 'prop-description',
772- 'items': {'type': 'int'}}}})
773+ 'items': {'type': 'integer'}}}})
774 self.assertIn(
775 dedent("""
776 **Config schema**:
777- **prop1:** (array of int) prop-description
778+ **prop1:** (array of integer) prop-description
779
780 **Examples**::
781
782 ex1"""),
783 get_schema_doc(full_schema))
784
785+ def test_get_schema_doc_handles_unstructured_examples(self):
786+ """get_schema_doc properly indented examples which as just strings."""
787+ full_schema = copy(self.required_schema)
788+ full_schema.update(
789+ {'examples': ['My example:\n [don\'t, expand, "this"]'],
790+ 'properties': {
791+ 'prop1': {'type': 'array', 'description': 'prop-description',
792+ 'items': {'type': 'integer'}}}})
793+ self.assertIn(
794+ dedent("""
795+ **Config schema**:
796+ **prop1:** (array of integer) prop-description
797+
798+ **Examples**::
799+
800+ My example:
801+ [don't, expand, "this"]"""),
802+ get_schema_doc(full_schema))
803+
804 def test_get_schema_doc_raises_key_errors(self):
805 """get_schema_doc raises KeyErrors on missing keys."""
806 for key in self.required_schema:
807@@ -198,13 +266,78 @@ class GetSchemaDocTest(CiTestCase):
808 self.assertIn(key, str(context_mgr.exception))
809
810
811+class AnnotatedCloudconfigFileTest(CiTestCase):
812+ maxDiff = None
813+
814+ def test_annotated_cloudconfig_file_no_schema_errors(self):
815+ """With no schema_errors, print the original content."""
816+ content = b'ntp:\n pools: [ntp1.pools.com]\n'
817+ self.assertEqual(
818+ content,
819+ annotated_cloudconfig_file({}, content, schema_errors=[]))
820+
821+ def test_annotated_cloudconfig_file_schema_annotates_and_adds_footer(self):
822+ """With schema_errors, error lines are annotated and a footer added."""
823+ content = dedent("""\
824+ #cloud-config
825+ # comment
826+ ntp:
827+ pools: [-99, 75]
828+ """).encode()
829+ expected = dedent("""\
830+ #cloud-config
831+ # comment
832+ ntp: # E1
833+ pools: [-99, 75] # E2,E3
834+
835+ # Errors: -------------
836+ # E1: Some type error
837+ # E2: -99 is not a string
838+ # E3: 75 is not a string
839+
840+ """)
841+ parsed_config = safe_load(content[13:])
842+ schema_errors = [
843+ ('ntp', 'Some type error'), ('ntp.pools.0', '-99 is not a string'),
844+ ('ntp.pools.1', '75 is not a string')]
845+ self.assertEqual(
846+ expected,
847+ annotated_cloudconfig_file(parsed_config, content, schema_errors))
848+
849+ def test_annotated_cloudconfig_file_annotates_separate_line_items(self):
850+ """Errors are annotated for lists with items on separate lines."""
851+ content = dedent("""\
852+ #cloud-config
853+ # comment
854+ ntp:
855+ pools:
856+ - -99
857+ - 75
858+ """).encode()
859+ expected = dedent("""\
860+ ntp:
861+ pools:
862+ - -99 # E1
863+ - 75 # E2
864+ """)
865+ parsed_config = safe_load(content[13:])
866+ schema_errors = [
867+ ('ntp.pools.0', '-99 is not a string'),
868+ ('ntp.pools.1', '75 is not a string')]
869+ self.assertIn(
870+ expected,
871+ annotated_cloudconfig_file(parsed_config, content, schema_errors))
872+
873+
874 class MainTest(CiTestCase):
875
876 def test_main_missing_args(self):
877 """Main exits non-zero and reports an error on missing parameters."""
878 with mock.patch('sys.argv', ['mycmd']):
879 with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr:
880- self.assertEqual(1, main(), 'Expected non-zero exit code')
881+ with self.assertRaises(SystemExit) as context_manager:
882+ main()
883+ self.assertEqual('1', str(context_manager.exception))
884 self.assertEqual(
885 'Expected either --config-file argument or --doc\n',
886 m_stderr.getvalue())
887@@ -216,13 +349,13 @@ class MainTest(CiTestCase):
888 with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
889 self.assertEqual(0, main(), 'Expected 0 exit code')
890 self.assertIn('\nNTP\n---\n', m_stdout.getvalue())
891+ self.assertIn('\nRuncmd\n------\n', m_stdout.getvalue())
892
893 def test_main_validates_config_file(self):
894 """When --config-file parameter is provided, main validates schema."""
895 myyaml = self.tmp_path('my.yaml')
896 myargs = ['mycmd', '--config-file', myyaml]
897- with open(myyaml, 'wb') as stream:
898- stream.write(b'#cloud-config\nntp:') # shortest ntp schema
899+ write_file(myyaml, b'#cloud-config\nntp:') # shortest ntp schema
900 with mock.patch('sys.argv', myargs):
901 with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
902 self.assertEqual(0, main(), 'Expected 0 exit code')

Subscribers

People subscribed via source and target branches