Merge ~chad.smith/cloud-init:cc-ntp-schema-validation into cloud-init:master
- Git
- lp:~chad.smith/cloud-init
- cc-ntp-schema-validation
- Merge into master
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) |
Related bugs: |
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/
A cmdline tools/cloudconf
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/
A cmdline tools/cloudconf
LP: #1692916
To test:
# download the attached schemas.tar
wget https:/
tar xf schemas.tar
./tools/
./tools/
for file in `ls schemas`; do echo '------
#run unit tests
tox -- --tests tests/unittests
Server Team CI bot (server-team-bot) wrote : | # |
- f5df731... by Chad Smith
-
add cloudconfig-schema tool
- 9ee8f78... by Chad Smith
-
update requirements to pull in jsonschema
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:7fd77646851
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
Scott Moser (smoser) wrote : | # |
I like it very much.
Some comments inline. comments.
looking at diff i see that tools/cloudconf
tools/cloudconf
tools/cloudconf
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.
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:9ee8f78041a
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
- 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
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:e6aabcd0274
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
- 9aff5a1... by Chad Smith
-
unit test fix for new schema rst format
- 3d188c2... by Chad Smith
-
flake and lints
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:9aff5a184f7
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
- 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
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:d625066059e
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
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_
- 1788d55... by Chad Smith
-
assert SchemaValidatio
nError is an instance of ValueError
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:1788d55154c
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
- de1a75c... by Chad Smith
-
fix whitespace issues with dedented property descriptions
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:de1a75cd63b
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
- a4cf423... by Chad Smith
-
highlight servers and pools key names in autogenerated description
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:a4cf423f7e8
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
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
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:f29875d757f
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:fc42257ec9b
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
- 5d585e9... by Chad Smith
-
fix remaining py2.6 format issue and dedent leading newlines in schema property descriptions
Chad Smith (chad.smith) : | # |
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:5d585e91c02
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
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/
Then it would be executable on any system with cloud-init as:
python3 -m cloudinit.
- 704339e... by Chad Smith
-
move main out of cloudconfig-schema and into cloudinit.
config. schema. Add unit tests for schema.main
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:704339ebcc5
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
Scott Moser (smoser) wrote : | # |
fix the StringIO error and we're good to go.
- af370e1... by Chad Smith
-
use StringIO from six for py2/3 compatibility
- f85cd6c... by Chad Smith
-
missed commit of test_schema
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
- 890d5a3... by Chad Smith
-
unittest byte string
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:890d5a3f793
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
Preview Diff
1 | diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py |
2 | index 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 |
108 | diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py |
109 | new file mode 100644 |
110 | index 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 |
336 | diff --git a/requirements.txt b/requirements.txt |
337 | index 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 |
349 | diff --git a/tests/unittests/helpers.py b/tests/unittests/helpers.py |
350 | index 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] |
373 | diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py |
374 | index 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 |
491 | diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py |
492 | new file mode 100644 |
493 | index 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 |
717 | diff --git a/tools/cloudconfig-schema b/tools/cloudconfig-schema |
718 | new file mode 100755 |
719 | index 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 |
FAILED: Continuous integration, rev:7fd77646851 386a148e1d26d65 5fbe8163f72a14 /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 398/ /jenkins. ubuntu. com/server/ job/cloud- init-ci/ nodes=metal- amd64/398/ console /jenkins. ubuntu. com/server/ job/cloud- init-ci/ nodes=metal- arm64/398/ console /jenkins. ubuntu. com/server/ job/cloud- init-ci/ nodes=metal- ppc64el/ 398/console /jenkins. ubuntu. com/server/ job/cloud- init-ci/ nodes=metal- s390x/398/ console /jenkins. ubuntu. com/server/ job/cloud- init-ci/ nodes=vm- i386/398/ console
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild: /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 398/rebuild
https:/