Merge ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master

Proposed by Chad Smith
Status: Merged
Merged at revision: ed8f1b159174715403cb1ffa200ff6d080770152
Proposed branch: ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd
Merge into: cloud-init:master
Diff against target: 1022 lines (+604/-168) (has conflicts)
8 files modified
cloudinit/config/cc_bootcmd.py (+62/-29)
cloudinit/config/cc_ntp.py (+12/-36)
cloudinit/config/cc_resizefs.py (+85/-64)
cloudinit/config/cc_runcmd.py (+4/-1)
cloudinit/config/schema.py (+12/-7)
tests/unittests/test_handler/test_handler_bootcmd.py (+157/-0)
tests/unittests/test_handler/test_handler_resizefs.py (+252/-7)
tests/unittests/test_handler/test_schema.py (+20/-24)
Conflict in cloudinit/config/cc_bootcmd.py
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
cloud-init Commiters Pending
Review via email: mp+330243@code.launchpad.net

Description of the change

schema and docs: Add jsonschema to resizefs and bootcmd modules

Add schema definitions to both cc_resizefs and cc_bootcmd modules. Extend
schema.py to parse and document enumerated json types. Schema definitions
are used to generate module documention and log warnings for schema
infractions.

This branch also does the following:
  - drops vestigial 'resize_rootfs_tmp' option from cc_resizefs. That
    option only created the specified directory and didn't make use of
that directory for any resize operations.
  - Drop yaml.dumps calls from schema documentation generation to avoid
    yaml import costs on module load
  - Add __doc__ = get_schema_doc(schema) definitions it each module to
    supplement python help() calls for cc_runcmd, cc_bootcmd, cc_ntp and
cc_resizefs
  - Add a SCHEMA_EXAMPLES_SPACER_TEMPLATE string to docs for modules which
    contain more than one example

to test:
$ tox -e doc; xdg-open doc/rtd_html/topics/modules.html
$ python3 -m cloudinit.cmd.main devel schema --doc
$ cat > test.cfg <<EOF
#cloud-config
bootcmd: junk
resize_rootfs: invalid
EOF
$ python3 -m cloudinit.cmd.main devel schema -c test.cfg --annotate

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:07195877d396a5a36583b3bd277a91561a606f29
https://jenkins.ubuntu.com/server/job/cloud-init-ci/257/
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/257/rebuild

review: Needs Fixing (continuous-integration)
5562e81... by Chad Smith

add unit test for documentation of jsonschema enum type

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

FAILED: Continuous integration, rev:5562e81e085038086f605489a59e7708f9bf3a43
https://jenkins.ubuntu.com/server/job/cloud-init-ci/258/
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/258/rebuild

review: Needs Fixing (continuous-integration)
7b8bdd7... by Chad Smith

add a simple resizefs example

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

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

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

review: Needs Fixing (continuous-integration)
f86c892... by Chad Smith

flakes

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

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

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

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

FAILED: Continuous integration, rev:f86c8927fd409eb72ddfddfc290f749c73cfd63d
https://jenkins.ubuntu.com/server/job/cloud-init-ci/262/
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/262/rebuild

review: Needs Fixing (continuous-integration)
00cf0f2... by Chad Smith

tempfile perms are different in different environments (CentOS sets mode 644 ubuntu 664. drop this aspect of the unit test as we aren't explicitly setting the mode in the module either way

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

PASSED: Continuous integration, rev:00cf0f29c981f6387ba9c115abd6d95bdf052758
https://jenkins.ubuntu.com/server/job/cloud-init-ci/263/
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/263/rebuild

review: Approve (continuous-integration)
1a7aca9... by Chad Smith

pull in smoser's patch for non-yaml.dumps in documentation creation. Update unit tests, fix cc_runcmd, cc_bootcmd, cc_resizefs and cc_ntp modules to set __doc__ = get_schema_doc() for python's builtin help

Revision history for this message
Chad Smith (chad.smith) wrote :

Good suggestions Scott, I pulled in your changes and adapted cc_runcmd, cc_ntp, cc_resizefs and cc_bootcmd to all set __doc__ through get_schema_doc().

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

PASSED: Continuous integration, rev:1a7aca930109d21a1e6b26f7456ceafe6e713447
https://jenkins.ubuntu.com/server/job/cloud-init-ci/275/
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/275/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/config/cc_bootcmd.py b/cloudinit/config/cc_bootcmd.py
2index 9c0476a..d05dd8f 100644
3--- a/cloudinit/config/cc_bootcmd.py
4+++ b/cloudinit/config/cc_bootcmd.py
5@@ -3,45 +3,73 @@
6 #
7 # Author: Scott Moser <scott.moser@canonical.com>
8 # Author: Juerg Haefliger <juerg.haefliger@hp.com>
9+# Author: Chad Smith <chad.smith@canonical.com>
10 #
11 # This file is part of cloud-init. See LICENSE file for license information.
12
13-"""
14-Bootcmd
15--------
16-**Summary:** run commands early in boot process
17-
18-This module runs arbitrary commands very early in the boot process,
19-only slightly after a boothook would run. This is very similar to a
20-boothook, but more user friendly. The environment variable ``INSTANCE_ID``
21-will be set to the current instance id for all run commands. Commands can be
22-specified either as lists or strings. For invocation details, see ``runcmd``.
23-
24-.. note::
25- bootcmd should only be used for things that could not be done later in the
26- boot process.
27-
28-**Internal name:** ``cc_bootcmd``
29-
30-**Module frequency:** per always
31-
32-**Supported distros:** all
33-
34-**Config keys**::
35-
36- bootcmd:
37- - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts
38- - [ cloud-init-per, once, mymkfs, mkfs, /dev/vdb ]
39-"""
40+"""Bootcmd: run arbitrary commands early in the boot process."""
41
42 import os
43+from textwrap import dedent
44
45+from cloudinit.config.schema import (
46+ get_schema_doc, validate_cloudconfig_schema)
47 from cloudinit.settings import PER_ALWAYS
48 from cloudinit import temp_utils
49 from cloudinit import util
50
51 frequency = PER_ALWAYS
52
53+# The schema definition for each cloud-config module is a strict contract for
54+# describing supported configuration parameters for each cloud-config section.
55+# It allows cloud-config to validate and alert users to invalid or ignored
56+# configuration options before actually attempting to deploy with said
57+# configuration.
58+
59+distros = ['all']
60+
61+schema = {
62+ 'id': 'cc_bootcmd',
63+ 'name': 'Bootcmd',
64+ 'title': 'Run arbitrary commands early in the boot process',
65+ 'description': dedent("""\
66+ This module runs arbitrary commands very early in the boot process,
67+ only slightly after a boothook would run. This is very similar to a
68+ boothook, but more user friendly. The environment variable
69+ ``INSTANCE_ID`` will be set to the current instance id for all run
70+ commands. Commands can be specified either as lists or strings. For
71+ invocation details, see ``runcmd``.
72+
73+ .. note::
74+ bootcmd should only be used for things that could not be done later
75+ in the boot process."""),
76+ 'distros': distros,
77+ 'examples': [dedent("""\
78+ bootcmd:
79+ - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts
80+ - [ cloud-init-per, once, mymkfs, mkfs, /dev/vdb ]
81+ """)],
82+ 'frequency': PER_ALWAYS,
83+ 'type': 'object',
84+ 'properties': {
85+ 'bootcmd': {
86+ 'type': 'array',
87+ 'items': {
88+ 'oneOf': [
89+ {'type': 'array', 'items': {'type': 'string'}},
90+ {'type': 'string'}]
91+ },
92+ 'additionalItems': False, # Reject items of non-string non-list
93+ 'additionalProperties': False,
94+ 'minItems': 1,
95+ 'required': [],
96+ 'uniqueItems': True
97+ }
98+ }
99+}
100+
101+__doc__ = get_schema_doc(schema) # Supplement python help()
102+
103
104 def handle(name, cfg, cloud, log, _args):
105
106@@ -50,13 +78,18 @@ def handle(name, cfg, cloud, log, _args):
107 " no 'bootcmd' key in configuration"), name)
108 return
109
110+<<<<<<< cloudinit/config/cc_bootcmd.py
111 with temp_utils.ExtendedTemporaryFile(suffix=".sh") as tmpf:
112+=======
113+ validate_cloudconfig_schema(cfg, schema)
114+ with util.ExtendedTemporaryFile(suffix=".sh") as tmpf:
115+>>>>>>> cloudinit/config/cc_bootcmd.py
116 try:
117 content = util.shellify(cfg["bootcmd"])
118 tmpf.write(util.encode_text(content))
119 tmpf.flush()
120- except Exception:
121- util.logexc(log, "Failed to shellify bootcmd")
122+ except Exception as e:
123+ util.logexc(log, "Failed to shellify bootcmd: %s", str(e))
124 raise
125
126 try:
127diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py
128index a02b4bf..15ae1ec 100644
129--- a/cloudinit/config/cc_ntp.py
130+++ b/cloudinit/config/cc_ntp.py
131@@ -4,39 +4,10 @@
132 #
133 # This file is part of cloud-init. See LICENSE file for license information.
134
135-"""
136-NTP
137----
138-**Summary:** enable and configure ntp
139-
140-Handle ntp configuration. If ntp is not installed on the system and ntp
141-configuration is specified, ntp will be installed. If there is a default ntp
142-config file in the image or one is present in the distro's ntp package, it will
143-be copied to ``/etc/ntp.conf.dist`` before any changes are made. A list of ntp
144-pools and ntp servers can be provided under the ``ntp`` config key. If no ntp
145-servers or pools are provided, 4 pools will be used in the format
146-``{0-3}.{distro}.pool.ntp.org``.
147-
148-**Internal name:** ``cc_ntp``
149-
150-**Module frequency:** per instance
151-
152-**Supported distros:** centos, debian, fedora, opensuse, ubuntu
153-
154-**Config keys**::
155-
156- ntp:
157- pools:
158- - 0.company.pool.ntp.org
159- - 1.company.pool.ntp.org
160- - ntp.myorg.org
161- servers:
162- - my.ntp.server.local
163- - ntp.ubuntu.com
164- - 192.168.23.2
165-"""
166+"""NTP: enable and configure ntp"""
167
168-from cloudinit.config.schema import validate_cloudconfig_schema
169+from cloudinit.config.schema import (
170+ get_schema_doc, validate_cloudconfig_schema)
171 from cloudinit import log as logging
172 from cloudinit.settings import PER_INSTANCE
173 from cloudinit import templater
174@@ -76,10 +47,13 @@ schema = {
175 ``{0-3}.{distro}.pool.ntp.org``."""),
176 'distros': distros,
177 'examples': [
178- {'ntp': {'pools': ['0.company.pool.ntp.org', '1.company.pool.ntp.org',
179- 'ntp.myorg.org'],
180- 'servers': ['my.ntp.server.local', 'ntp.ubuntu.com',
181- '192.168.23.2']}}],
182+ dedent("""\
183+ ntp:
184+ pools: [0.int.pool.ntp.org, 1.int.pool.ntp.org, ntp.myorg.org]
185+ servers:
186+ - ntp.server.local
187+ - ntp.ubuntu.com
188+ - 192.168.23.2""")],
189 'frequency': PER_INSTANCE,
190 'type': 'object',
191 'properties': {
192@@ -117,6 +91,8 @@ schema = {
193 }
194 }
195
196+__doc__ = get_schema_doc(schema) # Supplement python help()
197+
198
199 def handle(name, cfg, cloud, log, _args):
200 """Enable and configure ntp."""
201diff --git a/cloudinit/config/cc_resizefs.py b/cloudinit/config/cc_resizefs.py
202index ceee952..f14d383 100644
203--- a/cloudinit/config/cc_resizefs.py
204+++ b/cloudinit/config/cc_resizefs.py
205@@ -6,31 +6,8 @@
206 #
207 # This file is part of cloud-init. See LICENSE file for license information.
208
209-"""
210-Resizefs
211---------
212-**Summary:** resize filesystem
213+"""Resizefs: cloud-config module which resizes the filesystem"""
214
215-Resize a filesystem to use all avaliable space on partition. This module is
216-useful along with ``cc_growpart`` and will ensure that if the root partition
217-has been resized the root filesystem will be resized along with it. By default,
218-``cc_resizefs`` will resize the root partition and will block the boot process
219-while the resize command is running. Optionally, the resize operation can be
220-performed in the background while cloud-init continues running modules. This
221-can be enabled by setting ``resize_rootfs`` to ``true``. This module can be
222-disabled altogether by setting ``resize_rootfs`` to ``false``.
223-
224-**Internal name:** ``cc_resizefs``
225-
226-**Module frequency:** per always
227-
228-**Supported distros:** all
229-
230-**Config keys**::
231-
232- resize_rootfs: <true/false/"noblock">
233- resize_rootfs_tmp: <directory>
234-"""
235
236 import errno
237 import getopt
238@@ -38,11 +15,47 @@ import os
239 import re
240 import shlex
241 import stat
242+from textwrap import dedent
243
244+from cloudinit.config.schema import (
245+ get_schema_doc, validate_cloudconfig_schema)
246 from cloudinit.settings import PER_ALWAYS
247 from cloudinit import util
248
249+NOBLOCK = "noblock"
250+
251 frequency = PER_ALWAYS
252+distros = ['all']
253+
254+schema = {
255+ 'id': 'cc_resizefs',
256+ 'name': 'Resizefs',
257+ 'title': 'Resize filesystem',
258+ 'description': dedent("""\
259+ Resize a filesystem to use all avaliable space on partition. This
260+ module is useful along with ``cc_growpart`` and will ensure that if the
261+ root partition has been resized the root filesystem will be resized
262+ along with it. By default, ``cc_resizefs`` will resize the root
263+ partition and will block the boot process while the resize command is
264+ running. Optionally, the resize operation can be performed in the
265+ background while cloud-init continues running modules. This can be
266+ enabled by setting ``resize_rootfs`` to ``true``. This module can be
267+ disabled altogether by setting ``resize_rootfs`` to ``false``."""),
268+ 'distros': distros,
269+ 'examples': [
270+ 'resize_rootfs: false # disable root filesystem resize operation'],
271+ 'frequency': PER_ALWAYS,
272+ 'type': 'object',
273+ 'properties': {
274+ 'resize_rootfs': {
275+ 'enum': [True, False, NOBLOCK],
276+ 'description': dedent("""\
277+ Whether to resize the root partition. Default: 'true'""")
278+ }
279+ }
280+}
281+
282+__doc__ = get_schema_doc(schema) # Supplement python help()
283
284
285 def _resize_btrfs(mount_point, devpth):
286@@ -131,8 +144,6 @@ RESIZE_FS_PRECHECK_CMDS = {
287 'ufs': _can_skip_resize_ufs
288 }
289
290-NOBLOCK = "noblock"
291-
292
293 def rootdev_from_cmdline(cmdline):
294 found = None
295@@ -161,71 +172,81 @@ def can_skip_resize(fs_type, resize_what, devpth):
296 return False
297
298
299-def handle(name, cfg, _cloud, log, args):
300- if len(args) != 0:
301- resize_root = args[0]
302- else:
303- resize_root = util.get_cfg_option_str(cfg, "resize_rootfs", True)
304-
305- if not util.translate_bool(resize_root, addons=[NOBLOCK]):
306- log.debug("Skipping module named %s, resizing disabled", name)
307- return
308-
309- # TODO(harlowja) is the directory ok to be used??
310- resize_root_d = util.get_cfg_option_str(cfg, "resize_rootfs_tmp", "/run")
311- util.ensure_dir(resize_root_d)
312+def is_device_path_writable_block(devpath, info, log):
313+ """Return True if devpath is a writable block device.
314
315- # TODO(harlowja): allow what is to be resized to be configurable??
316- resize_what = "/"
317- result = util.get_mount_info(resize_what, log)
318- if not result:
319- log.warn("Could not determine filesystem type of %s", resize_what)
320- return
321-
322- (devpth, fs_type, mount_point) = result
323-
324- info = "dev=%s mnt_point=%s path=%s" % (devpth, mount_point, resize_what)
325- log.debug("resize_info: %s" % info)
326+ @param devpath: Path to the root device we want to resize.
327+ @param info: String representing information about the requested device.
328+ @param log: Logger to which logs will be added upon error.
329
330+ @returns Boolean True if block device is writable
331+ """
332 container = util.is_container()
333
334 # Ensure the path is a block device.
335- if (devpth == "/dev/root" and not os.path.exists(devpth) and
336+ if (devpath == "/dev/root" and not os.path.exists(devpath) and
337 not container):
338- devpth = util.rootdev_from_cmdline(util.get_cmdline())
339- if devpth is None:
340+ devpath = util.rootdev_from_cmdline(util.get_cmdline())
341+ if devpath is None:
342 log.warn("Unable to find device '/dev/root'")
343- return
344- log.debug("Converted /dev/root to '%s' per kernel cmdline", devpth)
345+ return False
346+ log.debug("Converted /dev/root to '%s' per kernel cmdline", devpath)
347
348 try:
349- statret = os.stat(devpth)
350+ statret = os.stat(devpath)
351 except OSError as exc:
352 if container and exc.errno == errno.ENOENT:
353 log.debug("Device '%s' did not exist in container. "
354- "cannot resize: %s", devpth, info)
355+ "cannot resize: %s", devpath, info)
356 elif exc.errno == errno.ENOENT:
357 log.warn("Device '%s' did not exist. cannot resize: %s",
358- devpth, info)
359+ devpath, info)
360 else:
361 raise exc
362- return
363+ return False
364
365- if not os.access(devpth, os.W_OK):
366+ if not os.access(devpath, os.W_OK):
367 if container:
368 log.debug("'%s' not writable in container. cannot resize: %s",
369- devpth, info)
370+ devpath, info)
371 else:
372- log.warn("'%s' not writable. cannot resize: %s", devpth, info)
373+ log.warn("'%s' not writable. cannot resize: %s", devpath, info)
374 return
375
376 if not stat.S_ISBLK(statret.st_mode) and not stat.S_ISCHR(statret.st_mode):
377 if container:
378 log.debug("device '%s' not a block device in container."
379- " cannot resize: %s" % (devpth, info))
380+ " cannot resize: %s" % (devpath, info))
381 else:
382 log.warn("device '%s' not a block device. cannot resize: %s" %
383- (devpth, info))
384+ (devpath, info))
385+ return False
386+ return True
387+
388+
389+def handle(name, cfg, _cloud, log, args):
390+ if len(args) != 0:
391+ resize_root = args[0]
392+ else:
393+ resize_root = util.get_cfg_option_str(cfg, "resize_rootfs", True)
394+ validate_cloudconfig_schema(cfg, schema)
395+ if not util.translate_bool(resize_root, addons=[NOBLOCK]):
396+ log.debug("Skipping module named %s, resizing disabled", name)
397+ return
398+
399+ # TODO(harlowja): allow what is to be resized to be configurable??
400+ resize_what = "/"
401+ result = util.get_mount_info(resize_what, log)
402+ if not result:
403+ log.warn("Could not determine filesystem type of %s", resize_what)
404+ return
405+
406+ (devpth, fs_type, mount_point) = result
407+
408+ info = "dev=%s mnt_point=%s path=%s" % (devpth, mount_point, resize_what)
409+ log.debug("resize_info: %s" % info)
410+
411+ if not is_device_path_writable_block(devpth, info, log):
412 return
413
414 resizer = None
415diff --git a/cloudinit/config/cc_runcmd.py b/cloudinit/config/cc_runcmd.py
416index 7c3ccd4..7f99569 100644
417--- a/cloudinit/config/cc_runcmd.py
418+++ b/cloudinit/config/cc_runcmd.py
419@@ -8,7 +8,8 @@
420
421 """Runcmd: run arbitrary commands at rc.local with output to the console"""
422
423-from cloudinit.config.schema import validate_cloudconfig_schema
424+from cloudinit.config.schema import (
425+ get_schema_doc, validate_cloudconfig_schema)
426 from cloudinit.settings import PER_INSTANCE
427 from cloudinit import util
428
429@@ -67,6 +68,8 @@ schema = {
430 }
431 }
432
433+__doc__ = get_schema_doc(schema) # Supplement python help()
434+
435
436 def handle(name, cfg, cloud, log, _args):
437 if "runcmd" not in cfg:
438diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py
439index 73dd5c2..c17d973 100644
440--- a/cloudinit/config/schema.py
441+++ b/cloudinit/config/schema.py
442@@ -14,6 +14,7 @@ import re
443 import sys
444 import yaml
445
446+_YAML_MAP = {True: 'true', False: 'false', None: 'null'}
447 SCHEMA_UNDEFINED = b'UNDEFINED'
448 CLOUD_CONFIG_HEADER = b'#cloud-config'
449 SCHEMA_DOC_TMPL = """
450@@ -34,6 +35,8 @@ SCHEMA_DOC_TMPL = """
451 {examples}
452 """
453 SCHEMA_PROPERTY_TMPL = '{prefix}**{prop_name}:** ({type}) {description}'
454+SCHEMA_EXAMPLES_HEADER = '\n**Examples**::\n\n'
455+SCHEMA_EXAMPLES_SPACER_TEMPLATE = '\n # --- Example{0} ---'
456
457
458 class SchemaValidationError(ValueError):
459@@ -212,6 +215,9 @@ def _schemapath_for_cloudconfig(config, original_content):
460 def _get_property_type(property_dict):
461 """Return a string representing a property type from a given jsonschema."""
462 property_type = property_dict.get('type', SCHEMA_UNDEFINED)
463+ if property_type == SCHEMA_UNDEFINED and property_dict.get('enum'):
464+ property_type = [
465+ str(_YAML_MAP.get(k, k)) for k in property_dict['enum']]
466 if isinstance(property_type, list):
467 property_type = '/'.join(property_type)
468 items = property_dict.get('items', {})
469@@ -249,15 +255,14 @@ def _get_schema_examples(schema, prefix=''):
470 examples = schema.get('examples')
471 if not examples:
472 return ''
473- rst_content = '\n**Examples**::\n\n'
474- for example in examples:
475- if isinstance(example, str):
476- example_content = example
477- else:
478- example_content = yaml.dump(example, default_flow_style=False)
479+ rst_content = SCHEMA_EXAMPLES_HEADER
480+ for count, example in enumerate(examples):
481 # Python2.6 is missing textwrapper.indent
482- lines = example_content.split('\n')
483+ lines = example.split('\n')
484 indented_lines = [' {0}'.format(line) for line in lines]
485+ if rst_content != SCHEMA_EXAMPLES_HEADER:
486+ indented_lines.insert(
487+ 0, SCHEMA_EXAMPLES_SPACER_TEMPLATE.format(count + 1))
488 rst_content += '\n'.join(indented_lines)
489 return rst_content
490
491diff --git a/tests/unittests/test_handler/test_handler_bootcmd.py b/tests/unittests/test_handler/test_handler_bootcmd.py
492new file mode 100644
493index 0000000..11b8699
494--- /dev/null
495+++ b/tests/unittests/test_handler/test_handler_bootcmd.py
496@@ -0,0 +1,157 @@
497+# This file is part of cloud-init. See LICENSE file for license information.
498+
499+from cloudinit.config import cc_bootcmd
500+from cloudinit.sources import DataSourceNone
501+from cloudinit import (distros, helpers, cloud, util)
502+from ..helpers import CiTestCase, mock, skipIf
503+
504+import logging
505+
506+try:
507+ import jsonschema
508+ assert jsonschema # avoid pyflakes error F401: import unused
509+ _missing_jsonschema_dep = False
510+except ImportError:
511+ _missing_jsonschema_dep = True
512+
513+LOG = logging.getLogger(__name__)
514+
515+
516+class TestBootcmd(CiTestCase):
517+
518+ with_logs = True
519+
520+ def setUp(self):
521+ super(TestBootcmd, self).setUp()
522+ self.subp = util.subp
523+ self.new_root = self.tmp_dir()
524+
525+ def _get_cloud(self, distro):
526+ paths = helpers.Paths({})
527+ cls = distros.fetch(distro)
528+ mydist = cls(distro, {}, paths)
529+ myds = DataSourceNone.DataSourceNone({}, mydist, paths)
530+ paths.datasource = myds
531+ return cloud.Cloud(myds, paths, {}, mydist, None)
532+
533+ def test_handler_skip_if_no_bootcmd(self):
534+ """When the provided config doesn't contain bootcmd, skip it."""
535+ cfg = {}
536+ mycloud = self._get_cloud('ubuntu')
537+ cc_bootcmd.handle('notimportant', cfg, mycloud, LOG, None)
538+ self.assertIn(
539+ "Skipping module named notimportant, no 'bootcmd' key",
540+ self.logs.getvalue())
541+
542+ def test_handler_invalid_command_set(self):
543+ """Commands which can't be converted to shell will raise errors."""
544+ invalid_config = {'bootcmd': 1}
545+ cc = self._get_cloud('ubuntu')
546+ with self.assertRaises(TypeError) as context_manager:
547+ cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
548+ self.assertIn('Failed to shellify bootcmd', self.logs.getvalue())
549+ self.assertEqual(
550+ "'int' object is not iterable",
551+ str(context_manager.exception))
552+
553+ @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
554+ def test_handler_schema_validation_warns_non_array_type(self):
555+ """Schema validation warns of non-array type for bootcmd key.
556+
557+ Schema validation is not strict, so bootcmd attempts to shellify the
558+ invalid content.
559+ """
560+ invalid_config = {'bootcmd': 1}
561+ cc = self._get_cloud('ubuntu')
562+ with self.assertRaises(TypeError):
563+ cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
564+ self.assertIn(
565+ 'Invalid config:\nbootcmd: 1 is not of type \'array\'',
566+ self.logs.getvalue())
567+ self.assertIn('Failed to shellify', self.logs.getvalue())
568+
569+ @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
570+ def test_handler_schema_validation_warns_non_array_item_type(self):
571+ """Schema validation warns of non-array or string bootcmd items.
572+
573+ Schema validation is not strict, so bootcmd attempts to shellify the
574+ invalid content.
575+ """
576+ invalid_config = {
577+ 'bootcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
578+ cc = self._get_cloud('ubuntu')
579+ with self.assertRaises(RuntimeError) as context_manager:
580+ cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
581+ expected_warnings = [
582+ 'bootcmd.1: 20 is not valid under any of the given schemas',
583+ 'bootcmd.3: {\'a\': \'n\'} is not valid under any of the given'
584+ ' schema'
585+ ]
586+ logs = self.logs.getvalue()
587+ for warning in expected_warnings:
588+ self.assertIn(warning, logs)
589+ self.assertIn('Failed to shellify', logs)
590+ self.assertEqual(
591+ 'Unable to shellify type int which is not a list or string',
592+ str(context_manager.exception))
593+
594+ def test_handler_creates_and_runs_bootcmd_script_with_instance_id(self):
595+ """Valid schema runs a bootcmd script with INSTANCE_ID in the env."""
596+ cc = self._get_cloud('ubuntu')
597+ bootcmd_file = self.tmp_path('bootcmd.sh', self.new_root)
598+ out_file = self.tmp_path('bootcmd.out', self.new_root)
599+ valid_config = {'bootcmd': [
600+ 'echo $INSTANCE_ID > {0}'.format(out_file)]}
601+
602+ class FakeExtendedTempFile(object):
603+ def __init__(_self, suffix):
604+ _self.suffix = suffix
605+ path = self.tmp_path('bootcmd' + _self.suffix, self.new_root)
606+ _self.handle = open(path, 'w+b')
607+
608+ def __enter__(_self):
609+ return _self.handle
610+
611+ def __exit__(_self, exc_type, exc_value, traceback):
612+ _self.handle.close()
613+
614+ mock_path = 'cloudinit.config.cc_bootcmd.util.ExtendedTemporaryFile'
615+ with mock.patch(mock_path, FakeExtendedTempFile):
616+ cc_bootcmd.handle('cc_bootcmd', valid_config, cc, LOG, [])
617+ self.assertEqual(
618+ '#!/bin/sh\necho $INSTANCE_ID > {0}\n'.format(out_file),
619+ util.load_file(bootcmd_file))
620+ self.assertEqual('iid-datasource-none\n', util.load_file(out_file))
621+
622+ def test_handler_runs_bootcmd_script_with_error(self):
623+ """When a valid script generates an error, that error is raised."""
624+ cc = self._get_cloud('ubuntu')
625+ bootcmd_file = self.tmp_path('bootcmd.sh', self.new_root)
626+ valid_config = {'bootcmd': ['exit 1']} # Script with error
627+
628+ class FakeExtendedTempFile(object):
629+ def __init__(_self, suffix):
630+ _self.suffix = suffix
631+ path = self.tmp_path('bootcmd' + _self.suffix, self.new_root)
632+ _self.handle = open(path, 'w+b')
633+
634+ def __enter__(_self):
635+ return _self.handle
636+
637+ def __exit__(_self, exc_type, exc_value, traceback):
638+ _self.handle.close()
639+
640+ mock_path = 'cloudinit.config.cc_bootcmd.util.ExtendedTemporaryFile'
641+ with mock.patch(mock_path, FakeExtendedTempFile):
642+ with self.assertRaises(util.ProcessExecutionError) as ctxt_manager:
643+ cc_bootcmd.handle('does-not-matter', valid_config, cc, LOG, [])
644+ self.assertIn(
645+ 'Unexpected error while running command.\n'
646+ "Command: ['/bin/sh', '{0}']".format(bootcmd_file),
647+ str(ctxt_manager.exception))
648+ self.assertIn(
649+ 'Failed to run bootcmd module does-not-matter',
650+ self.logs.getvalue())
651+
652+
653+# vi: ts=4 expandtab
654diff --git a/tests/unittests/test_handler/test_handler_resizefs.py b/tests/unittests/test_handler/test_handler_resizefs.py
655index 52591b8..7ce7a7d 100644
656--- a/tests/unittests/test_handler/test_handler_resizefs.py
657+++ b/tests/unittests/test_handler/test_handler_resizefs.py
658@@ -1,17 +1,29 @@
659 # This file is part of cloud-init. See LICENSE file for license information.
660
661-from cloudinit.config import cc_resizefs
662+from cloudinit.config.cc_resizefs import (
663+ can_skip_resize, handle, is_device_path_writable_block,
664+ rootdev_from_cmdline)
665
666+import logging
667 import textwrap
668-import unittest
669+
670+from ..helpers import CiTestCase, mock, skipIf, util, wrap_and_call
671+
672+
673+LOG = logging.getLogger(__name__)
674+
675
676 try:
677- from unittest import mock
678+ import jsonschema
679+ assert jsonschema # avoid pyflakes error F401: import unused
680+ _missing_jsonschema_dep = False
681 except ImportError:
682- import mock
683+ _missing_jsonschema_dep = True
684+
685
686+class TestResizefs(CiTestCase):
687+ with_logs = True
688
689-class TestResizefs(unittest.TestCase):
690 def setUp(self):
691 super(TestResizefs, self).setUp()
692 self.name = "resizefs"
693@@ -34,7 +46,7 @@ class TestResizefs(unittest.TestCase):
694 58720296 3145728 3 freebsd-swap (1.5G)
695 61866024 1048496 - free - (512M)
696 """)
697- res = cc_resizefs.can_skip_resize(fs_type, resize_what, devpth)
698+ res = can_skip_resize(fs_type, resize_what, devpth)
699 self.assertTrue(res)
700
701 @mock.patch('cloudinit.config.cc_resizefs._get_dumpfs_output')
702@@ -52,8 +64,241 @@ class TestResizefs(unittest.TestCase):
703 => 34 297086 da0 GPT (145M)
704 34 297086 1 freebsd-ufs (145M)
705 """)
706- res = cc_resizefs.can_skip_resize(fs_type, resize_what, devpth)
707+ res = can_skip_resize(fs_type, resize_what, devpth)
708 self.assertTrue(res)
709
710+ def test_handle_noops_on_disabled(self):
711+ """The handle function logs when the configuration disables resize."""
712+ cfg = {'resize_rootfs': False}
713+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
714+ self.assertIn(
715+ 'DEBUG: Skipping module named cc_resizefs, resizing disabled\n',
716+ self.logs.getvalue())
717+
718+ @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
719+ def test_handle_schema_validation_logs_invalid_resize_rootfs_value(self):
720+ """The handle reports json schema violations as a warning.
721+
722+ Invalid values for resize_rootfs result in disabling the module.
723+ """
724+ cfg = {'resize_rootfs': 'junk'}
725+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
726+ logs = self.logs.getvalue()
727+ self.assertIn(
728+ "WARNING: Invalid config:\nresize_rootfs: 'junk' is not one of"
729+ " [True, False, 'noblock']",
730+ logs)
731+ self.assertIn(
732+ 'DEBUG: Skipping module named cc_resizefs, resizing disabled\n',
733+ logs)
734+
735+ @mock.patch('cloudinit.config.cc_resizefs.util.get_mount_info')
736+ def test_handle_warns_on_unknown_mount_info(self, m_get_mount_info):
737+ """handle warns when get_mount_info sees unknown filesystem for /."""
738+ m_get_mount_info.return_value = None
739+ cfg = {'resize_rootfs': True}
740+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
741+ logs = self.logs.getvalue()
742+ self.assertNotIn("WARNING: Invalid config:\nresize_rootfs:", logs)
743+ self.assertIn(
744+ 'WARNING: Could not determine filesystem type of /\n',
745+ logs)
746+ self.assertEqual(
747+ [mock.call('/', LOG)],
748+ m_get_mount_info.call_args_list)
749+
750+ def test_handle_warns_on_undiscoverable_root_path_in_commandline(self):
751+ """handle noops when the root path is not found on the commandline."""
752+ cfg = {'resize_rootfs': True}
753+ exists_mock_path = 'cloudinit.config.cc_resizefs.os.path.exists'
754+
755+ def fake_mount_info(path, log):
756+ self.assertEqual('/', path)
757+ self.assertEqual(LOG, log)
758+ return ('/dev/root', 'ext4', '/')
759+
760+ with mock.patch(exists_mock_path) as m_exists:
761+ m_exists.return_value = False
762+ wrap_and_call(
763+ 'cloudinit.config.cc_resizefs.util',
764+ {'is_container': {'return_value': False},
765+ 'get_mount_info': {'side_effect': fake_mount_info},
766+ 'get_cmdline': {'return_value': 'BOOT_IMAGE=/vmlinuz.efi'}},
767+ handle, 'cc_resizefs', cfg, _cloud=None, log=LOG,
768+ args=[])
769+ logs = self.logs.getvalue()
770+ self.assertIn("WARNING: Unable to find device '/dev/root'", logs)
771+
772+
773+class TestRootDevFromCmdline(CiTestCase):
774+
775+ def test_rootdev_from_cmdline_with_no_root(self):
776+ """Return None from rootdev_from_cmdline when root is not present."""
777+ invalid_cases = [
778+ 'BOOT_IMAGE=/adsf asdfa werasef root adf', 'BOOT_IMAGE=/adsf', '']
779+ for case in invalid_cases:
780+ self.assertIsNone(rootdev_from_cmdline(case))
781+
782+ def test_rootdev_from_cmdline_with_root_startswith_dev(self):
783+ """Return the cmdline root when the path starts with /dev."""
784+ self.assertEqual(
785+ '/dev/this',
786+ rootdev_from_cmdline('asdf root=/dev/this'))
787+
788+ def test_rootdev_from_cmdline_with_root_without_dev_prefix(self):
789+ """Add /dev prefix to cmdline root when the path lacks the prefix."""
790+ self.assertEqual(
791+ '/dev/this',
792+ rootdev_from_cmdline('asdf root=this'))
793+
794+ def test_rootdev_from_cmdline_with_root_with_label(self):
795+ """When cmdline root contains a LABEL, our root is disk/by-label."""
796+ self.assertEqual(
797+ '/dev/disk/by-label/unique',
798+ rootdev_from_cmdline('asdf root=LABEL=unique'))
799+
800+ def test_rootdev_from_cmdline_with_root_with_uuid(self):
801+ """When cmdline root contains a UUID, our root is disk/by-uuid."""
802+ self.assertEqual(
803+ '/dev/disk/by-uuid/adsfdsaf-adsf',
804+ rootdev_from_cmdline('asdf root=UUID=adsfdsaf-adsf'))
805+
806+
807+class TestIsDevicePathWritableBlock(CiTestCase):
808+
809+ with_logs = True
810+
811+ def test_is_device_path_writable_block_warns_missing_cmdline_root(self):
812+ """When root does not exist isn't in the cmdline, log warning."""
813+ info = 'does not matter'
814+
815+ def fake_mount_info(path, log):
816+ self.assertEqual('/', path)
817+ self.assertEqual(LOG, log)
818+ return ('/dev/root', 'ext4', '/')
819+
820+ exists_mock_path = 'cloudinit.config.cc_resizefs.os.path.exists'
821+ with mock.patch(exists_mock_path) as m_exists:
822+ m_exists.return_value = False
823+ is_valid = wrap_and_call(
824+ 'cloudinit.config.cc_resizefs.util',
825+ {'is_container': {'return_value': False},
826+ 'get_mount_info': {'side_effect': fake_mount_info},
827+ 'get_cmdline': {'return_value': 'BOOT_IMAGE=/vmlinuz.efi'}},
828+ is_device_path_writable_block, '/dev/root', info, LOG)
829+ self.assertFalse(is_valid)
830+ logs = self.logs.getvalue()
831+ self.assertIn("WARNING: Unable to find device '/dev/root'", logs)
832+
833+ def test_is_device_path_writable_block_does_not_exist(self):
834+ """When devpath does not exist, a warning is logged."""
835+ info = 'dev=/I/dont/exist mnt_point=/ path=/dev/none'
836+ is_valid = wrap_and_call(
837+ 'cloudinit.config.cc_resizefs.util',
838+ {'is_container': {'return_value': False}},
839+ is_device_path_writable_block, '/I/dont/exist', info, LOG)
840+ self.assertFalse(is_valid)
841+ self.assertIn(
842+ "WARNING: Device '/I/dont/exist' did not exist."
843+ ' cannot resize: %s' % info,
844+ self.logs.getvalue())
845+
846+ def test_is_device_path_writable_block_does_not_exist_in_container(self):
847+ """When devpath does not exist in a container, log a debug message."""
848+ info = 'dev=/I/dont/exist mnt_point=/ path=/dev/none'
849+ is_valid = wrap_and_call(
850+ 'cloudinit.config.cc_resizefs.util',
851+ {'is_container': {'return_value': True}},
852+ is_device_path_writable_block, '/I/dont/exist', info, LOG)
853+ self.assertFalse(is_valid)
854+ self.assertIn(
855+ "DEBUG: Device '/I/dont/exist' did not exist in container."
856+ ' cannot resize: %s' % info,
857+ self.logs.getvalue())
858+
859+ def test_is_device_path_writable_block_raises_oserror(self):
860+ """When unexpected OSError is raises by os.stat it is reraised."""
861+ info = 'dev=/I/dont/exist mnt_point=/ path=/dev/none'
862+ with self.assertRaises(OSError) as context_manager:
863+ wrap_and_call(
864+ 'cloudinit.config.cc_resizefs',
865+ {'util.is_container': {'return_value': True},
866+ 'os.stat': {'side_effect': OSError('Something unexpected')}},
867+ is_device_path_writable_block, '/I/dont/exist', info, LOG)
868+ self.assertEqual(
869+ 'Something unexpected', str(context_manager.exception))
870+
871+ def test_is_device_path_writable_block_readonly(self):
872+ """When root device is readonly, emit a warning and return False."""
873+ fake_devpath = self.tmp_path('dev/readonly')
874+ util.write_file(fake_devpath, '', mode=0o400) # read-only
875+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
876+
877+ exists_mock_path = 'cloudinit.config.cc_resizefs.os.path.exists'
878+ with mock.patch(exists_mock_path) as m_exists:
879+ m_exists.return_value = False
880+ is_valid = wrap_and_call(
881+ 'cloudinit.config.cc_resizefs.util',
882+ {'is_container': {'return_value': False},
883+ 'rootdev_from_cmdline': {'return_value': fake_devpath}},
884+ is_device_path_writable_block, '/dev/root', info, LOG)
885+ self.assertFalse(is_valid)
886+ logs = self.logs.getvalue()
887+ self.assertIn(
888+ "Converted /dev/root to '{0}' per kernel cmdline".format(
889+ fake_devpath),
890+ logs)
891+ self.assertIn(
892+ "WARNING: '{0}' not writable. cannot resize".format(fake_devpath),
893+ logs)
894+
895+ def test_is_device_path_writable_block_readonly_in_container(self):
896+ """When root device is readonly, emit debug log and return False."""
897+ fake_devpath = self.tmp_path('dev/readonly')
898+ util.write_file(fake_devpath, '', mode=0o400) # read-only
899+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
900+
901+ is_valid = wrap_and_call(
902+ 'cloudinit.config.cc_resizefs.util',
903+ {'is_container': {'return_value': True}},
904+ is_device_path_writable_block, fake_devpath, info, LOG)
905+ self.assertFalse(is_valid)
906+ self.assertIn(
907+ "DEBUG: '{0}' not writable in container. cannot resize".format(
908+ fake_devpath),
909+ self.logs.getvalue())
910+
911+ def test_is_device_path_writable_block_non_block(self):
912+ """When device is not a block device, emit warning return False."""
913+ fake_devpath = self.tmp_path('dev/readwrite')
914+ util.write_file(fake_devpath, '', mode=0o600) # read-write
915+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
916+
917+ is_valid = wrap_and_call(
918+ 'cloudinit.config.cc_resizefs.util',
919+ {'is_container': {'return_value': False}},
920+ is_device_path_writable_block, fake_devpath, info, LOG)
921+ self.assertFalse(is_valid)
922+ self.assertIn(
923+ "WARNING: device '{0}' not a block device. cannot resize".format(
924+ fake_devpath),
925+ self.logs.getvalue())
926+
927+ def test_is_device_path_writable_block_non_block_on_container(self):
928+ """When device is non-block device in container, emit debug log."""
929+ fake_devpath = self.tmp_path('dev/readwrite')
930+ util.write_file(fake_devpath, '', mode=0o600) # read-write
931+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
932+
933+ is_valid = wrap_and_call(
934+ 'cloudinit.config.cc_resizefs.util',
935+ {'is_container': {'return_value': True}},
936+ is_device_path_writable_block, fake_devpath, info, LOG)
937+ self.assertFalse(is_valid)
938+ self.assertIn(
939+ "DEBUG: device '{0}' not a block device in container."
940+ ' cannot resize'.format(fake_devpath),
941+ self.logs.getvalue())
942+
943
944 # vi: ts=4 expandtab
945diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
946index 6137e3c..745bb0f 100644
947--- a/tests/unittests/test_handler/test_schema.py
948+++ b/tests/unittests/test_handler/test_schema.py
949@@ -27,7 +27,7 @@ class GetSchemaTest(CiTestCase):
950 """Every cloudconfig module with schema is listed in allOf keyword."""
951 schema = get_schema()
952 self.assertItemsEqual(
953- ['cc_ntp', 'cc_runcmd'],
954+ ['cc_bootcmd', 'cc_ntp', 'cc_resizefs', 'cc_runcmd'],
955 [subschema['id'] for subschema in schema['allOf']])
956 self.assertEqual('cloud-config-schema', schema['id'])
957 self.assertEqual(
958@@ -205,6 +205,17 @@ class GetSchemaDocTest(CiTestCase):
959 '**prop1:** (string/integer) prop-description',
960 get_schema_doc(full_schema))
961
962+ def test_get_schema_doc_handles_enum_types(self):
963+ """get_schema_doc converts enum types to yaml and delimits with '/'."""
964+ full_schema = copy(self.required_schema)
965+ full_schema.update(
966+ {'properties': {
967+ 'prop1': {'enum': [True, False, 'stuff'],
968+ 'description': 'prop-description'}}})
969+ self.assertIn(
970+ '**prop1:** (true/false/stuff) prop-description',
971+ get_schema_doc(full_schema))
972+
973 def test_get_schema_doc_handles_nested_oneof_property_types(self):
974 """get_schema_doc describes array items oneOf declarations in type."""
975 full_schema = copy(self.required_schema)
976@@ -219,29 +230,11 @@ class GetSchemaDocTest(CiTestCase):
977 '**prop1:** (array of (string)/(integer)) prop-description',
978 get_schema_doc(full_schema))
979
980- def test_get_schema_doc_returns_restructured_text_with_examples(self):
981- """get_schema_doc returns indented examples when present in schema."""
982- full_schema = copy(self.required_schema)
983- full_schema.update(
984- {'examples': [{'ex1': [1, 2, 3]}],
985- 'properties': {
986- 'prop1': {'type': 'array', 'description': 'prop-description',
987- 'items': {'type': 'integer'}}}})
988- self.assertIn(
989- dedent("""
990- **Config schema**:
991- **prop1:** (array of integer) prop-description
992-
993- **Examples**::
994-
995- ex1"""),
996- get_schema_doc(full_schema))
997-
998- def test_get_schema_doc_handles_unstructured_examples(self):
999- """get_schema_doc properly indented examples which as just strings."""
1000+ def test_get_schema_doc_handles_string_examples(self):
1001+ """get_schema_doc properly indented examples as a list of strings."""
1002 full_schema = copy(self.required_schema)
1003 full_schema.update(
1004- {'examples': ['My example:\n [don\'t, expand, "this"]'],
1005+ {'examples': ['ex1:\n [don\'t, expand, "this"]', 'ex2: true'],
1006 'properties': {
1007 'prop1': {'type': 'array', 'description': 'prop-description',
1008 'items': {'type': 'integer'}}}})
1009@@ -252,8 +245,11 @@ class GetSchemaDocTest(CiTestCase):
1010
1011 **Examples**::
1012
1013- My example:
1014- [don't, expand, "this"]"""),
1015+ ex1:
1016+ [don't, expand, "this"]
1017+ # --- Example2 ---
1018+ ex2: true
1019+ """),
1020 get_schema_doc(full_schema))
1021
1022 def test_get_schema_doc_raises_key_errors(self):

Subscribers

People subscribed via source and target branches