Merge ~raharper/cloud-init:ubuntu/devel/newupstream-20180914 into cloud-init:ubuntu/devel

Proposed by Ryan Harper
Status: Merged
Merged at revision: d418088c4cb2176ee4e1e73e3a3aa7f91e8436e1
Proposed branch: ~raharper/cloud-init:ubuntu/devel/newupstream-20180914
Merge into: cloud-init:ubuntu/devel
Diff against target: 1911 lines (+1094/-115)
32 files modified
bash_completion/cloud-init (+2/-0)
cloudinit/cmd/devel/__init__.py (+25/-0)
cloudinit/cmd/devel/parser.py (+4/-1)
cloudinit/cmd/devel/render.py (+90/-0)
cloudinit/cmd/devel/tests/test_render.py (+101/-0)
cloudinit/cmd/main.py (+15/-1)
cloudinit/handlers/__init__.py (+8/-3)
cloudinit/handlers/boot_hook.py (+5/-7)
cloudinit/handlers/cloud_config.py (+5/-10)
cloudinit/handlers/jinja_template.py (+137/-0)
cloudinit/handlers/shell_script.py (+3/-6)
cloudinit/handlers/upstart_job.py (+3/-6)
cloudinit/helpers.py (+4/-0)
cloudinit/log.py (+10/-2)
cloudinit/net/__init__.py (+7/-0)
cloudinit/net/tests/test_init.py (+8/-3)
cloudinit/sources/__init__.py (+31/-16)
cloudinit/sources/helpers/openstack.py (+18/-1)
cloudinit/sources/tests/test_init.py (+61/-14)
cloudinit/stages.py (+14/-8)
cloudinit/templater.py (+25/-3)
cloudinit/tests/helpers.py (+9/-0)
debian/changelog (+11/-0)
doc/rtd/topics/capabilities.rst (+12/-3)
doc/rtd/topics/datasources.rst (+47/-0)
doc/rtd/topics/format.rst (+17/-4)
tests/cloud_tests/testcases/base.py (+4/-4)
tests/unittests/test_builtin_handlers.py (+302/-22)
tests/unittests/test_datasource/test_openstack.py (+91/-1)
tests/unittests/test_handler/test_handler_etc_hosts.py (+1/-0)
tests/unittests/test_handler/test_handler_ntp.py (+1/-0)
tests/unittests/test_templating.py (+23/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
cloud-init Commiters Pending
Review via email: mp+354969@code.launchpad.net

Commit message

cloud-init (18.3-44-g84bf2482-0ubuntu1) cosmic; urgency=medium

  * New upstream snapshot.
    - bash_completion/cloud-init: fix shell syntax error.
    - EphemeralIPv4Network: Be more explicit when adding default route.
    - OpenStack: support reading of newer versions of metdata.
    - OpenStack: fix bug causing 'latest' version to be used from network.
    - user-data: jinja template to render instance-data.json in cloud-config

 -- Ryan Harper <email address hidden> Fri, 14 Sep 2018 14:06:29 -0500

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

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

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/320/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/bash_completion/cloud-init b/bash_completion/cloud-init
2index f38164b..6d01bf3 100644
3--- a/bash_completion/cloud-init
4+++ b/bash_completion/cloud-init
5@@ -62,6 +62,8 @@ _cloudinit_complete()
6 net-convert)
7 COMPREPLY=($(compgen -W "--help --network-data --kind --directory --output-kind" -- $cur_word))
8 ;;
9+ render)
10+ COMPREPLY=($(compgen -W "--help --instance-data --debug" -- $cur_word));;
11 schema)
12 COMPREPLY=($(compgen -W "--help --config-file --doc --annotate" -- $cur_word))
13 ;;
14diff --git a/cloudinit/cmd/devel/__init__.py b/cloudinit/cmd/devel/__init__.py
15index e69de29..3ae28b6 100644
16--- a/cloudinit/cmd/devel/__init__.py
17+++ b/cloudinit/cmd/devel/__init__.py
18@@ -0,0 +1,25 @@
19+# This file is part of cloud-init. See LICENSE file for license information.
20+
21+"""Common cloud-init devel commandline utility functions."""
22+
23+
24+import logging
25+
26+from cloudinit import log
27+from cloudinit.stages import Init
28+
29+
30+def addLogHandlerCLI(logger, log_level):
31+ """Add a commandline logging handler to emit messages to stderr."""
32+ formatter = logging.Formatter('%(levelname)s: %(message)s')
33+ log.setupBasicLogging(log_level, formatter=formatter)
34+ return logger
35+
36+
37+def read_cfg_paths():
38+ """Return a Paths object based on the system configuration on disk."""
39+ init = Init(ds_deps=[])
40+ init.read_cfg()
41+ return init.paths
42+
43+# vi: ts=4 expandtab
44diff --git a/cloudinit/cmd/devel/parser.py b/cloudinit/cmd/devel/parser.py
45index 40a4b01..99a234c 100644
46--- a/cloudinit/cmd/devel/parser.py
47+++ b/cloudinit/cmd/devel/parser.py
48@@ -8,6 +8,7 @@ import argparse
49 from cloudinit.config import schema
50
51 from . import net_convert
52+from . import render
53
54
55 def get_parser(parser=None):
56@@ -22,7 +23,9 @@ def get_parser(parser=None):
57 ('schema', 'Validate cloud-config files for document schema',
58 schema.get_parser, schema.handle_schema_args),
59 (net_convert.NAME, net_convert.__doc__,
60- net_convert.get_parser, net_convert.handle_args)
61+ net_convert.get_parser, net_convert.handle_args),
62+ (render.NAME, render.__doc__,
63+ render.get_parser, render.handle_args)
64 ]
65 for (subcmd, helpmsg, get_parser, handler) in subcmds:
66 parser = subparsers.add_parser(subcmd, help=helpmsg)
67diff --git a/cloudinit/cmd/devel/render.py b/cloudinit/cmd/devel/render.py
68new file mode 100755
69index 0000000..e85933d
70--- /dev/null
71+++ b/cloudinit/cmd/devel/render.py
72@@ -0,0 +1,90 @@
73+# This file is part of cloud-init. See LICENSE file for license information.
74+
75+"""Debug jinja template rendering of user-data."""
76+
77+import argparse
78+import os
79+import sys
80+
81+from cloudinit.handlers.jinja_template import render_jinja_payload_from_file
82+from cloudinit import log
83+from cloudinit.sources import INSTANCE_JSON_FILE
84+from cloudinit import util
85+from . import addLogHandlerCLI, read_cfg_paths
86+
87+NAME = 'render'
88+DEFAULT_INSTANCE_DATA = '/run/cloud-init/instance-data.json'
89+
90+LOG = log.getLogger(NAME)
91+
92+
93+def get_parser(parser=None):
94+ """Build or extend and arg parser for jinja render utility.
95+
96+ @param parser: Optional existing ArgumentParser instance representing the
97+ subcommand which will be extended to support the args of this utility.
98+
99+ @returns: ArgumentParser with proper argument configuration.
100+ """
101+ if not parser:
102+ parser = argparse.ArgumentParser(prog=NAME, description=__doc__)
103+ parser.add_argument(
104+ 'user_data', type=str, help='Path to the user-data file to render')
105+ parser.add_argument(
106+ '-i', '--instance-data', type=str,
107+ help=('Optional path to instance-data.json file. Defaults to'
108+ ' /run/cloud-init/instance-data.json'))
109+ parser.add_argument('-d', '--debug', action='store_true', default=False,
110+ help='Add verbose messages during template render')
111+ return parser
112+
113+
114+def handle_args(name, args):
115+ """Render the provided user-data template file using instance-data values.
116+
117+ Also setup CLI log handlers to report to stderr since this is a development
118+ utility which should be run by a human on the CLI.
119+
120+ @return 0 on success, 1 on failure.
121+ """
122+ addLogHandlerCLI(LOG, log.DEBUG if args.debug else log.WARNING)
123+ if not args.instance_data:
124+ paths = read_cfg_paths()
125+ instance_data_fn = os.path.join(
126+ paths.run_dir, INSTANCE_JSON_FILE)
127+ else:
128+ instance_data_fn = args.instance_data
129+ try:
130+ with open(instance_data_fn) as stream:
131+ instance_data = stream.read()
132+ instance_data = util.load_json(instance_data)
133+ except IOError:
134+ LOG.error('Missing instance-data.json file: %s', instance_data_fn)
135+ return 1
136+ try:
137+ with open(args.user_data) as stream:
138+ user_data = stream.read()
139+ except IOError:
140+ LOG.error('Missing user-data file: %s', args.user_data)
141+ return 1
142+ rendered_payload = render_jinja_payload_from_file(
143+ payload=user_data, payload_fn=args.user_data,
144+ instance_data_file=instance_data_fn,
145+ debug=True if args.debug else False)
146+ if not rendered_payload:
147+ LOG.error('Unable to render user-data file: %s', args.user_data)
148+ return 1
149+ sys.stdout.write(rendered_payload)
150+ return 0
151+
152+
153+def main():
154+ args = get_parser().parse_args()
155+ return(handle_args(NAME, args))
156+
157+
158+if __name__ == '__main__':
159+ sys.exit(main())
160+
161+
162+# vi: ts=4 expandtab
163diff --git a/cloudinit/cmd/devel/tests/test_render.py b/cloudinit/cmd/devel/tests/test_render.py
164new file mode 100644
165index 0000000..fc5d2c0
166--- /dev/null
167+++ b/cloudinit/cmd/devel/tests/test_render.py
168@@ -0,0 +1,101 @@
169+# This file is part of cloud-init. See LICENSE file for license information.
170+
171+from six import StringIO
172+import os
173+
174+from collections import namedtuple
175+from cloudinit.cmd.devel import render
176+from cloudinit.helpers import Paths
177+from cloudinit.sources import INSTANCE_JSON_FILE
178+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJinja
179+from cloudinit.util import ensure_dir, write_file
180+
181+
182+class TestRender(CiTestCase):
183+
184+ with_logs = True
185+
186+ args = namedtuple('renderargs', 'user_data instance_data debug')
187+
188+ def setUp(self):
189+ super(TestRender, self).setUp()
190+ self.tmp = self.tmp_dir()
191+
192+ def test_handle_args_error_on_missing_user_data(self):
193+ """When user_data file path does not exist, log an error."""
194+ absent_file = self.tmp_path('user-data', dir=self.tmp)
195+ instance_data = self.tmp_path('instance-data', dir=self.tmp)
196+ write_file(instance_data, '{}')
197+ args = self.args(
198+ user_data=absent_file, instance_data=instance_data, debug=False)
199+ with mock.patch('sys.stderr', new_callable=StringIO):
200+ self.assertEqual(1, render.handle_args('anyname', args))
201+ self.assertIn(
202+ 'Missing user-data file: %s' % absent_file,
203+ self.logs.getvalue())
204+
205+ def test_handle_args_error_on_missing_instance_data(self):
206+ """When instance_data file path does not exist, log an error."""
207+ user_data = self.tmp_path('user-data', dir=self.tmp)
208+ absent_file = self.tmp_path('instance-data', dir=self.tmp)
209+ args = self.args(
210+ user_data=user_data, instance_data=absent_file, debug=False)
211+ with mock.patch('sys.stderr', new_callable=StringIO):
212+ self.assertEqual(1, render.handle_args('anyname', args))
213+ self.assertIn(
214+ 'Missing instance-data.json file: %s' % absent_file,
215+ self.logs.getvalue())
216+
217+ def test_handle_args_defaults_instance_data(self):
218+ """When no instance_data argument, default to configured run_dir."""
219+ user_data = self.tmp_path('user-data', dir=self.tmp)
220+ run_dir = self.tmp_path('run_dir', dir=self.tmp)
221+ ensure_dir(run_dir)
222+ paths = Paths({'run_dir': run_dir})
223+ self.add_patch('cloudinit.cmd.devel.render.read_cfg_paths', 'm_paths')
224+ self.m_paths.return_value = paths
225+ args = self.args(
226+ user_data=user_data, instance_data=None, debug=False)
227+ with mock.patch('sys.stderr', new_callable=StringIO):
228+ self.assertEqual(1, render.handle_args('anyname', args))
229+ json_file = os.path.join(run_dir, INSTANCE_JSON_FILE)
230+ self.assertIn(
231+ 'Missing instance-data.json file: %s' % json_file,
232+ self.logs.getvalue())
233+
234+ @skipUnlessJinja()
235+ def test_handle_args_renders_instance_data_vars_in_template(self):
236+ """If user_data file is a jinja template render instance-data vars."""
237+ user_data = self.tmp_path('user-data', dir=self.tmp)
238+ write_file(user_data, '##template: jinja\nrendering: {{ my_var }}')
239+ instance_data = self.tmp_path('instance-data', dir=self.tmp)
240+ write_file(instance_data, '{"my-var": "jinja worked"}')
241+ args = self.args(
242+ user_data=user_data, instance_data=instance_data, debug=True)
243+ with mock.patch('sys.stderr', new_callable=StringIO) as m_console_err:
244+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
245+ self.assertEqual(0, render.handle_args('anyname', args))
246+ self.assertIn(
247+ 'DEBUG: Converted jinja variables\n{', self.logs.getvalue())
248+ self.assertIn(
249+ 'DEBUG: Converted jinja variables\n{', m_console_err.getvalue())
250+ self.assertEqual('rendering: jinja worked', m_stdout.getvalue())
251+
252+ @skipUnlessJinja()
253+ def test_handle_args_warns_and_gives_up_on_invalid_jinja_operation(self):
254+ """If user_data file has invalid jinja operations log warnings."""
255+ user_data = self.tmp_path('user-data', dir=self.tmp)
256+ write_file(user_data, '##template: jinja\nrendering: {{ my-var }}')
257+ instance_data = self.tmp_path('instance-data', dir=self.tmp)
258+ write_file(instance_data, '{"my-var": "jinja worked"}')
259+ args = self.args(
260+ user_data=user_data, instance_data=instance_data, debug=True)
261+ with mock.patch('sys.stderr', new_callable=StringIO):
262+ self.assertEqual(1, render.handle_args('anyname', args))
263+ self.assertIn(
264+ 'WARNING: Ignoring jinja template for %s: Undefined jinja'
265+ ' variable: "my-var". Jinja tried subtraction. Perhaps you meant'
266+ ' "my_var"?' % user_data,
267+ self.logs.getvalue())
268+
269+# vi: ts=4 expandtab
270diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
271index 4ea4fe7..0eee583 100644
272--- a/cloudinit/cmd/main.py
273+++ b/cloudinit/cmd/main.py
274@@ -348,6 +348,7 @@ def main_init(name, args):
275 LOG.debug("[%s] barreling on in force mode without datasource",
276 mode)
277
278+ _maybe_persist_instance_data(init)
279 # Stage 6
280 iid = init.instancify()
281 LOG.debug("[%s] %s will now be targeting instance id: %s. new=%s",
282@@ -490,6 +491,7 @@ def main_modules(action_name, args):
283 print_exc(msg)
284 if not args.force:
285 return [(msg)]
286+ _maybe_persist_instance_data(init)
287 # Stage 3
288 mods = stages.Modules(init, extract_fns(args), reporter=args.reporter)
289 # Stage 4
290@@ -541,6 +543,7 @@ def main_single(name, args):
291 " likely bad things to come!"))
292 if not args.force:
293 return 1
294+ _maybe_persist_instance_data(init)
295 # Stage 3
296 mods = stages.Modules(init, extract_fns(args), reporter=args.reporter)
297 mod_args = args.module_args
298@@ -688,6 +691,15 @@ def status_wrapper(name, args, data_d=None, link_d=None):
299 return len(v1[mode]['errors'])
300
301
302+def _maybe_persist_instance_data(init):
303+ """Write instance-data.json file if absent and datasource is restored."""
304+ if init.ds_restored:
305+ instance_data_file = os.path.join(
306+ init.paths.run_dir, sources.INSTANCE_JSON_FILE)
307+ if not os.path.exists(instance_data_file):
308+ init.datasource.persist_instance_data()
309+
310+
311 def _maybe_set_hostname(init, stage, retry_stage):
312 """Call set-hostname if metadata, vendordata or userdata provides it.
313
314@@ -887,6 +899,8 @@ def main(sysv_args=None):
315 if __name__ == '__main__':
316 if 'TZ' not in os.environ:
317 os.environ['TZ'] = ":/etc/localtime"
318- main(sys.argv)
319+ return_value = main(sys.argv)
320+ if return_value:
321+ sys.exit(return_value)
322
323 # vi: ts=4 expandtab
324diff --git a/cloudinit/handlers/__init__.py b/cloudinit/handlers/__init__.py
325index c3576c0..0db75af 100644
326--- a/cloudinit/handlers/__init__.py
327+++ b/cloudinit/handlers/__init__.py
328@@ -41,7 +41,7 @@ PART_HANDLER_FN_TMPL = 'part-handler-%03d'
329 # For parts without filenames
330 PART_FN_TPL = 'part-%03d'
331
332-# Different file beginnings to there content type
333+# Different file beginnings to their content type
334 INCLUSION_TYPES_MAP = {
335 '#include': 'text/x-include-url',
336 '#include-once': 'text/x-include-once-url',
337@@ -52,6 +52,7 @@ INCLUSION_TYPES_MAP = {
338 '#cloud-boothook': 'text/cloud-boothook',
339 '#cloud-config-archive': 'text/cloud-config-archive',
340 '#cloud-config-jsonp': 'text/cloud-config-jsonp',
341+ '## template: jinja': 'text/jinja2',
342 }
343
344 # Sorted longest first
345@@ -69,9 +70,13 @@ class Handler(object):
346 def __repr__(self):
347 return "%s: [%s]" % (type_utils.obj_name(self), self.list_types())
348
349- @abc.abstractmethod
350 def list_types(self):
351- raise NotImplementedError()
352+ # Each subclass must define the supported content prefixes it handles.
353+ if not hasattr(self, 'prefixes'):
354+ raise NotImplementedError('Missing prefixes subclass attribute')
355+ else:
356+ return [INCLUSION_TYPES_MAP[prefix]
357+ for prefix in getattr(self, 'prefixes')]
358
359 @abc.abstractmethod
360 def handle_part(self, *args, **kwargs):
361diff --git a/cloudinit/handlers/boot_hook.py b/cloudinit/handlers/boot_hook.py
362index 057b4db..dca50a4 100644
363--- a/cloudinit/handlers/boot_hook.py
364+++ b/cloudinit/handlers/boot_hook.py
365@@ -17,10 +17,13 @@ from cloudinit import util
366 from cloudinit.settings import (PER_ALWAYS)
367
368 LOG = logging.getLogger(__name__)
369-BOOTHOOK_PREFIX = "#cloud-boothook"
370
371
372 class BootHookPartHandler(handlers.Handler):
373+
374+ # The content prefixes this handler understands.
375+ prefixes = ['#cloud-boothook']
376+
377 def __init__(self, paths, datasource, **_kwargs):
378 handlers.Handler.__init__(self, PER_ALWAYS)
379 self.boothook_dir = paths.get_ipath("boothooks")
380@@ -28,16 +31,11 @@ class BootHookPartHandler(handlers.Handler):
381 if datasource:
382 self.instance_id = datasource.get_instance_id()
383
384- def list_types(self):
385- return [
386- handlers.type_from_starts_with(BOOTHOOK_PREFIX),
387- ]
388-
389 def _write_part(self, payload, filename):
390 filename = util.clean_filename(filename)
391 filepath = os.path.join(self.boothook_dir, filename)
392 contents = util.strip_prefix_suffix(util.dos2unix(payload),
393- prefix=BOOTHOOK_PREFIX)
394+ prefix=self.prefixes[0])
395 util.write_file(filepath, contents.lstrip(), 0o700)
396 return filepath
397
398diff --git a/cloudinit/handlers/cloud_config.py b/cloudinit/handlers/cloud_config.py
399index 178a5b9..99bf0e6 100644
400--- a/cloudinit/handlers/cloud_config.py
401+++ b/cloudinit/handlers/cloud_config.py
402@@ -42,14 +42,12 @@ DEF_MERGERS = mergers.string_extract_mergers('dict(replace)+list()+str()')
403 CLOUD_PREFIX = "#cloud-config"
404 JSONP_PREFIX = "#cloud-config-jsonp"
405
406-# The file header -> content types this module will handle.
407-CC_TYPES = {
408- JSONP_PREFIX: handlers.type_from_starts_with(JSONP_PREFIX),
409- CLOUD_PREFIX: handlers.type_from_starts_with(CLOUD_PREFIX),
410-}
411-
412
413 class CloudConfigPartHandler(handlers.Handler):
414+
415+ # The content prefixes this handler understands.
416+ prefixes = [CLOUD_PREFIX, JSONP_PREFIX]
417+
418 def __init__(self, paths, **_kwargs):
419 handlers.Handler.__init__(self, PER_ALWAYS, version=3)
420 self.cloud_buf = None
421@@ -58,9 +56,6 @@ class CloudConfigPartHandler(handlers.Handler):
422 self.cloud_fn = paths.get_ipath(_kwargs["cloud_config_path"])
423 self.file_names = []
424
425- def list_types(self):
426- return list(CC_TYPES.values())
427-
428 def _write_cloud_config(self):
429 if not self.cloud_fn:
430 return
431@@ -138,7 +133,7 @@ class CloudConfigPartHandler(handlers.Handler):
432 # First time through, merge with an empty dict...
433 if self.cloud_buf is None or not self.file_names:
434 self.cloud_buf = {}
435- if ctype == CC_TYPES[JSONP_PREFIX]:
436+ if ctype == handlers.INCLUSION_TYPES_MAP[JSONP_PREFIX]:
437 self._merge_patch(payload)
438 else:
439 self._merge_part(payload, headers)
440diff --git a/cloudinit/handlers/jinja_template.py b/cloudinit/handlers/jinja_template.py
441new file mode 100644
442index 0000000..3fa4097
443--- /dev/null
444+++ b/cloudinit/handlers/jinja_template.py
445@@ -0,0 +1,137 @@
446+# This file is part of cloud-init. See LICENSE file for license information.
447+
448+import os
449+import re
450+
451+try:
452+ from jinja2.exceptions import UndefinedError as JUndefinedError
453+except ImportError:
454+ # No jinja2 dependency
455+ JUndefinedError = Exception
456+
457+from cloudinit import handlers
458+from cloudinit import log as logging
459+from cloudinit.sources import INSTANCE_JSON_FILE
460+from cloudinit.templater import render_string, MISSING_JINJA_PREFIX
461+from cloudinit.util import b64d, load_file, load_json, json_dumps
462+
463+from cloudinit.settings import PER_ALWAYS
464+
465+LOG = logging.getLogger(__name__)
466+
467+
468+class JinjaTemplatePartHandler(handlers.Handler):
469+
470+ prefixes = ['## template: jinja']
471+
472+ def __init__(self, paths, **_kwargs):
473+ handlers.Handler.__init__(self, PER_ALWAYS, version=3)
474+ self.paths = paths
475+ self.sub_handlers = {}
476+ for handler in _kwargs.get('sub_handlers', []):
477+ for ctype in handler.list_types():
478+ self.sub_handlers[ctype] = handler
479+
480+ def handle_part(self, data, ctype, filename, payload, frequency, headers):
481+ if ctype in handlers.CONTENT_SIGNALS:
482+ return
483+ jinja_json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
484+ rendered_payload = render_jinja_payload_from_file(
485+ payload, filename, jinja_json_file)
486+ if not rendered_payload:
487+ return
488+ subtype = handlers.type_from_starts_with(rendered_payload)
489+ sub_handler = self.sub_handlers.get(subtype)
490+ if not sub_handler:
491+ LOG.warning(
492+ 'Ignoring jinja template for %s. Could not find supported'
493+ ' sub-handler for type %s', filename, subtype)
494+ return
495+ if sub_handler.handler_version == 3:
496+ sub_handler.handle_part(
497+ data, ctype, filename, rendered_payload, frequency, headers)
498+ elif sub_handler.handler_version == 2:
499+ sub_handler.handle_part(
500+ data, ctype, filename, rendered_payload, frequency)
501+
502+
503+def render_jinja_payload_from_file(
504+ payload, payload_fn, instance_data_file, debug=False):
505+ """Render a jinja template payload sourcing variables from jinja_vars_path.
506+
507+ @param payload: String of jinja template content. Should begin with
508+ ## template: jinja\n.
509+ @param payload_fn: String representing the filename from which the payload
510+ was read used in error reporting. Generally in part-handling this is
511+ 'part-##'.
512+ @param instance_data_file: A path to a json file containing variables that
513+ will be used as jinja template variables.
514+
515+ @return: A string of jinja-rendered content with the jinja header removed.
516+ Returns None on error.
517+ """
518+ instance_data = {}
519+ rendered_payload = None
520+ if not os.path.exists(instance_data_file):
521+ raise RuntimeError(
522+ 'Cannot render jinja template vars. Instance data not yet'
523+ ' present at %s' % instance_data_file)
524+ instance_data = load_json(load_file(instance_data_file))
525+ rendered_payload = render_jinja_payload(
526+ payload, payload_fn, instance_data, debug)
527+ if not rendered_payload:
528+ return None
529+ return rendered_payload
530+
531+
532+def render_jinja_payload(payload, payload_fn, instance_data, debug=False):
533+ instance_jinja_vars = convert_jinja_instance_data(
534+ instance_data,
535+ decode_paths=instance_data.get('base64-encoded-keys', []))
536+ if debug:
537+ LOG.debug('Converted jinja variables\n%s',
538+ json_dumps(instance_jinja_vars))
539+ try:
540+ rendered_payload = render_string(payload, instance_jinja_vars)
541+ except (TypeError, JUndefinedError) as e:
542+ LOG.warning(
543+ 'Ignoring jinja template for %s: %s', payload_fn, str(e))
544+ return None
545+ warnings = [
546+ "'%s'" % var.replace(MISSING_JINJA_PREFIX, '')
547+ for var in re.findall(
548+ r'%s[^\s]+' % MISSING_JINJA_PREFIX, rendered_payload)]
549+ if warnings:
550+ LOG.warning(
551+ "Could not render jinja template variables in file '%s': %s",
552+ payload_fn, ', '.join(warnings))
553+ return rendered_payload
554+
555+
556+def convert_jinja_instance_data(data, prefix='', sep='/', decode_paths=()):
557+ """Process instance-data.json dict for use in jinja templates.
558+
559+ Replace hyphens with underscores for jinja templates and decode any
560+ base64_encoded_keys.
561+ """
562+ result = {}
563+ decode_paths = [path.replace('-', '_') for path in decode_paths]
564+ for key, value in sorted(data.items()):
565+ if '-' in key:
566+ # Standardize keys for use in #cloud-config/shell templates
567+ key = key.replace('-', '_')
568+ key_path = '{0}{1}{2}'.format(prefix, sep, key) if prefix else key
569+ if key_path in decode_paths:
570+ value = b64d(value)
571+ if isinstance(value, dict):
572+ result[key] = convert_jinja_instance_data(
573+ value, key_path, sep=sep, decode_paths=decode_paths)
574+ if re.match(r'v\d+', key):
575+ # Copy values to top-level aliases
576+ for subkey, subvalue in result[key].items():
577+ result[subkey] = subvalue
578+ else:
579+ result[key] = value
580+ return result
581+
582+# vi: ts=4 expandtab
583diff --git a/cloudinit/handlers/shell_script.py b/cloudinit/handlers/shell_script.py
584index e4945a2..214714b 100644
585--- a/cloudinit/handlers/shell_script.py
586+++ b/cloudinit/handlers/shell_script.py
587@@ -17,21 +17,18 @@ from cloudinit import util
588 from cloudinit.settings import (PER_ALWAYS)
589
590 LOG = logging.getLogger(__name__)
591-SHELL_PREFIX = "#!"
592
593
594 class ShellScriptPartHandler(handlers.Handler):
595+
596+ prefixes = ['#!']
597+
598 def __init__(self, paths, **_kwargs):
599 handlers.Handler.__init__(self, PER_ALWAYS)
600 self.script_dir = paths.get_ipath_cur('scripts')
601 if 'script_path' in _kwargs:
602 self.script_dir = paths.get_ipath_cur(_kwargs['script_path'])
603
604- def list_types(self):
605- return [
606- handlers.type_from_starts_with(SHELL_PREFIX),
607- ]
608-
609 def handle_part(self, data, ctype, filename, payload, frequency):
610 if ctype in handlers.CONTENT_SIGNALS:
611 # TODO(harlowja): maybe delete existing things here
612diff --git a/cloudinit/handlers/upstart_job.py b/cloudinit/handlers/upstart_job.py
613index dc33876..83fb072 100644
614--- a/cloudinit/handlers/upstart_job.py
615+++ b/cloudinit/handlers/upstart_job.py
616@@ -18,19 +18,16 @@ from cloudinit import util
617 from cloudinit.settings import (PER_INSTANCE)
618
619 LOG = logging.getLogger(__name__)
620-UPSTART_PREFIX = "#upstart-job"
621
622
623 class UpstartJobPartHandler(handlers.Handler):
624+
625+ prefixes = ['#upstart-job']
626+
627 def __init__(self, paths, **_kwargs):
628 handlers.Handler.__init__(self, PER_INSTANCE)
629 self.upstart_dir = paths.upstart_conf_d
630
631- def list_types(self):
632- return [
633- handlers.type_from_starts_with(UPSTART_PREFIX),
634- ]
635-
636 def handle_part(self, data, ctype, filename, payload, frequency):
637 if ctype in handlers.CONTENT_SIGNALS:
638 return
639diff --git a/cloudinit/helpers.py b/cloudinit/helpers.py
640index 1979cd9..3cc1fb1 100644
641--- a/cloudinit/helpers.py
642+++ b/cloudinit/helpers.py
643@@ -449,4 +449,8 @@ class DefaultingConfigParser(RawConfigParser):
644 contents = '\n'.join([header, contents, ''])
645 return contents
646
647+
648+def identity(object):
649+ return object
650+
651 # vi: ts=4 expandtab
652diff --git a/cloudinit/log.py b/cloudinit/log.py
653index 1d75c9f..5ae312b 100644
654--- a/cloudinit/log.py
655+++ b/cloudinit/log.py
656@@ -38,10 +38,18 @@ DEF_CON_FORMAT = '%(asctime)s - %(filename)s[%(levelname)s]: %(message)s'
657 logging.Formatter.converter = time.gmtime
658
659
660-def setupBasicLogging(level=DEBUG):
661+def setupBasicLogging(level=DEBUG, formatter=None):
662+ if not formatter:
663+ formatter = logging.Formatter(DEF_CON_FORMAT)
664 root = logging.getLogger()
665+ for handler in root.handlers:
666+ if hasattr(handler, 'stream') and hasattr(handler.stream, 'name'):
667+ if handler.stream.name == '<stderr>':
668+ handler.setLevel(level)
669+ return
670+ # Didn't have an existing stderr handler; create a new handler
671 console = logging.StreamHandler(sys.stderr)
672- console.setFormatter(logging.Formatter(DEF_CON_FORMAT))
673+ console.setFormatter(formatter)
674 console.setLevel(level)
675 root.addHandler(console)
676 root.setLevel(level)
677diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
678index 3ffde52..5e87bca 100644
679--- a/cloudinit/net/__init__.py
680+++ b/cloudinit/net/__init__.py
681@@ -698,6 +698,13 @@ class EphemeralIPv4Network(object):
682 self.interface, out.strip())
683 return
684 util.subp(
685+ ['ip', '-4', 'route', 'add', self.router, 'dev', self.interface,
686+ 'src', self.ip], capture=True)
687+ self.cleanup_cmds.insert(
688+ 0,
689+ ['ip', '-4', 'route', 'del', self.router, 'dev', self.interface,
690+ 'src', self.ip])
691+ util.subp(
692 ['ip', '-4', 'route', 'add', 'default', 'via', self.router,
693 'dev', self.interface], capture=True)
694 self.cleanup_cmds.insert(
695diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
696index 8b444f1..58e0a59 100644
697--- a/cloudinit/net/tests/test_init.py
698+++ b/cloudinit/net/tests/test_init.py
699@@ -515,12 +515,17 @@ class TestEphemeralIPV4Network(CiTestCase):
700 capture=True),
701 mock.call(
702 ['ip', 'route', 'show', '0.0.0.0/0'], capture=True),
703+ mock.call(['ip', '-4', 'route', 'add', '192.168.2.1',
704+ 'dev', 'eth0', 'src', '192.168.2.2'], capture=True),
705 mock.call(
706 ['ip', '-4', 'route', 'add', 'default', 'via',
707 '192.168.2.1', 'dev', 'eth0'], capture=True)]
708- expected_teardown_calls = [mock.call(
709- ['ip', '-4', 'route', 'del', 'default', 'dev', 'eth0'],
710- capture=True)]
711+ expected_teardown_calls = [
712+ mock.call(['ip', '-4', 'route', 'del', 'default', 'dev', 'eth0'],
713+ capture=True),
714+ mock.call(['ip', '-4', 'route', 'del', '192.168.2.1',
715+ 'dev', 'eth0', 'src', '192.168.2.2'], capture=True),
716+ ]
717
718 with net.EphemeralIPv4Network(**params):
719 self.assertEqual(expected_setup_calls, m_subp.call_args_list)
720diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
721index 41fde9b..a775f1a 100644
722--- a/cloudinit/sources/__init__.py
723+++ b/cloudinit/sources/__init__.py
724@@ -58,22 +58,27 @@ class InvalidMetaDataException(Exception):
725 pass
726
727
728-def process_base64_metadata(metadata, key_path=''):
729- """Strip ci-b64 prefix and return metadata with base64-encoded-keys set."""
730+def process_instance_metadata(metadata, key_path=''):
731+ """Process all instance metadata cleaning it up for persisting as json.
732+
733+ Strip ci-b64 prefix and catalog any 'base64_encoded_keys' as a list
734+
735+ @return Dict copy of processed metadata.
736+ """
737 md_copy = copy.deepcopy(metadata)
738- md_copy['base64-encoded-keys'] = []
739+ md_copy['base64_encoded_keys'] = []
740 for key, val in metadata.items():
741 if key_path:
742 sub_key_path = key_path + '/' + key
743 else:
744 sub_key_path = key
745 if isinstance(val, str) and val.startswith('ci-b64:'):
746- md_copy['base64-encoded-keys'].append(sub_key_path)
747+ md_copy['base64_encoded_keys'].append(sub_key_path)
748 md_copy[key] = val.replace('ci-b64:', '')
749 if isinstance(val, dict):
750- return_val = process_base64_metadata(val, sub_key_path)
751- md_copy['base64-encoded-keys'].extend(
752- return_val.pop('base64-encoded-keys'))
753+ return_val = process_instance_metadata(val, sub_key_path)
754+ md_copy['base64_encoded_keys'].extend(
755+ return_val.pop('base64_encoded_keys'))
756 md_copy[key] = return_val
757 return md_copy
758
759@@ -180,15 +185,24 @@ class DataSource(object):
760 """
761 self._dirty_cache = True
762 return_value = self._get_data()
763- json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
764 if not return_value:
765 return return_value
766+ self.persist_instance_data()
767+ return return_value
768+
769+ def persist_instance_data(self):
770+ """Process and write INSTANCE_JSON_FILE with all instance metadata.
771
772+ Replace any hyphens with underscores in key names for use in template
773+ processing.
774+
775+ @return True on successful write, False otherwise.
776+ """
777 instance_data = {
778 'ds': {
779- 'meta-data': self.metadata,
780- 'user-data': self.get_userdata_raw(),
781- 'vendor-data': self.get_vendordata_raw()}}
782+ 'meta_data': self.metadata,
783+ 'user_data': self.get_userdata_raw(),
784+ 'vendor_data': self.get_vendordata_raw()}}
785 if hasattr(self, 'network_json'):
786 network_json = getattr(self, 'network_json')
787 if network_json != UNSET:
788@@ -202,16 +216,17 @@ class DataSource(object):
789 try:
790 # Process content base64encoding unserializable values
791 content = util.json_dumps(instance_data)
792- # Strip base64: prefix and return base64-encoded-keys
793- processed_data = process_base64_metadata(json.loads(content))
794+ # Strip base64: prefix and set base64_encoded_keys list.
795+ processed_data = process_instance_metadata(json.loads(content))
796 except TypeError as e:
797 LOG.warning('Error persisting instance-data.json: %s', str(e))
798- return return_value
799+ return False
800 except UnicodeDecodeError as e:
801 LOG.warning('Error persisting instance-data.json: %s', str(e))
802- return return_value
803+ return False
804+ json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
805 write_json(json_file, processed_data, mode=0o600)
806- return return_value
807+ return True
808
809 def _get_data(self):
810 """Walk metadata sources, process crawled data and save attributes."""
811diff --git a/cloudinit/sources/helpers/openstack.py b/cloudinit/sources/helpers/openstack.py
812index 8f9c144..d6f39a1 100644
813--- a/cloudinit/sources/helpers/openstack.py
814+++ b/cloudinit/sources/helpers/openstack.py
815@@ -38,21 +38,38 @@ KEY_COPIES = (
816 ('local-hostname', 'hostname', False),
817 ('instance-id', 'uuid', True),
818 )
819+
820+# Versions and names taken from nova source nova/api/metadata/base.py
821 OS_LATEST = 'latest'
822 OS_FOLSOM = '2012-08-10'
823 OS_GRIZZLY = '2013-04-04'
824 OS_HAVANA = '2013-10-17'
825 OS_LIBERTY = '2015-10-15'
826+# NEWTON_ONE adds 'devices' to md (sriov-pf-passthrough-neutron-port-vlan)
827+OS_NEWTON_ONE = '2016-06-30'
828+# NEWTON_TWO adds vendor_data2.json (vendordata-reboot)
829+OS_NEWTON_TWO = '2016-10-06'
830+# OS_OCATA adds 'vif' field to devices (sriov-pf-passthrough-neutron-port-vlan)
831+OS_OCATA = '2017-02-22'
832+# OS_ROCKY adds a vf_trusted field to devices (sriov-trusted-vfs)
833+OS_ROCKY = '2018-08-27'
834+
835+
836 # keep this in chronological order. new supported versions go at the end.
837 OS_VERSIONS = (
838 OS_FOLSOM,
839 OS_GRIZZLY,
840 OS_HAVANA,
841 OS_LIBERTY,
842+ OS_NEWTON_ONE,
843+ OS_NEWTON_TWO,
844+ OS_OCATA,
845+ OS_ROCKY,
846 )
847
848 PHYSICAL_TYPES = (
849 None,
850+ 'bgpovs', # not present in OpenStack upstream but used on OVH cloud.
851 'bridge',
852 'dvs',
853 'ethernet',
854@@ -439,7 +456,7 @@ class MetadataReader(BaseReader):
855 return self._versions
856 found = []
857 version_path = self._path_join(self.base_path, "openstack")
858- content = self._path_read(version_path)
859+ content = self._path_read(version_path, decode=True)
860 for line in content.splitlines():
861 line = line.strip()
862 if not line:
863diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
864index 9e939c1..8299af2 100644
865--- a/cloudinit/sources/tests/test_init.py
866+++ b/cloudinit/sources/tests/test_init.py
867@@ -20,10 +20,12 @@ class DataSourceTestSubclassNet(DataSource):
868 dsname = 'MyTestSubclass'
869 url_max_wait = 55
870
871- def __init__(self, sys_cfg, distro, paths, custom_userdata=None):
872+ def __init__(self, sys_cfg, distro, paths, custom_userdata=None,
873+ get_data_retval=True):
874 super(DataSourceTestSubclassNet, self).__init__(
875 sys_cfg, distro, paths)
876 self._custom_userdata = custom_userdata
877+ self._get_data_retval = get_data_retval
878
879 def _get_cloud_name(self):
880 return 'SubclassCloudName'
881@@ -37,7 +39,7 @@ class DataSourceTestSubclassNet(DataSource):
882 else:
883 self.userdata_raw = 'userdata_raw'
884 self.vendordata_raw = 'vendordata_raw'
885- return True
886+ return self._get_data_retval
887
888
889 class InvalidDataSourceTestSubclassNet(DataSource):
890@@ -264,7 +266,18 @@ class TestDataSource(CiTestCase):
891 self.assertEqual('fqdnhostname.domain.com',
892 datasource.get_hostname(fqdn=True))
893
894- def test_get_data_write_json_instance_data(self):
895+ def test_get_data_does_not_write_instance_data_on_failure(self):
896+ """get_data does not write INSTANCE_JSON_FILE on get_data False."""
897+ tmp = self.tmp_dir()
898+ datasource = DataSourceTestSubclassNet(
899+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}),
900+ get_data_retval=False)
901+ self.assertFalse(datasource.get_data())
902+ json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
903+ self.assertFalse(
904+ os.path.exists(json_file), 'Found unexpected file %s' % json_file)
905+
906+ def test_get_data_writes_json_instance_data_on_success(self):
907 """get_data writes INSTANCE_JSON_FILE to run_dir as readonly root."""
908 tmp = self.tmp_dir()
909 datasource = DataSourceTestSubclassNet(
910@@ -273,7 +286,7 @@ class TestDataSource(CiTestCase):
911 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
912 content = util.load_file(json_file)
913 expected = {
914- 'base64-encoded-keys': [],
915+ 'base64_encoded_keys': [],
916 'v1': {
917 'availability-zone': 'myaz',
918 'cloud-name': 'subclasscloudname',
919@@ -281,11 +294,12 @@ class TestDataSource(CiTestCase):
920 'local-hostname': 'test-subclass-hostname',
921 'region': 'myregion'},
922 'ds': {
923- 'meta-data': {'availability_zone': 'myaz',
924+ 'meta_data': {'availability_zone': 'myaz',
925 'local-hostname': 'test-subclass-hostname',
926 'region': 'myregion'},
927- 'user-data': 'userdata_raw',
928- 'vendor-data': 'vendordata_raw'}}
929+ 'user_data': 'userdata_raw',
930+ 'vendor_data': 'vendordata_raw'}}
931+ self.maxDiff = None
932 self.assertEqual(expected, util.load_json(content))
933 file_stat = os.stat(json_file)
934 self.assertEqual(0o600, stat.S_IMODE(file_stat.st_mode))
935@@ -296,7 +310,7 @@ class TestDataSource(CiTestCase):
936 datasource = DataSourceTestSubclassNet(
937 self.sys_cfg, self.distro, Paths({'run_dir': tmp}),
938 custom_userdata={'key1': 'val1', 'key2': {'key2.1': self.paths}})
939- self.assertTrue(datasource.get_data())
940+ datasource.get_data()
941 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
942 content = util.load_file(json_file)
943 expected_userdata = {
944@@ -306,7 +320,40 @@ class TestDataSource(CiTestCase):
945 " 'cloudinit.helpers.Paths'>"}}
946 instance_json = util.load_json(content)
947 self.assertEqual(
948- expected_userdata, instance_json['ds']['user-data'])
949+ expected_userdata, instance_json['ds']['user_data'])
950+
951+ def test_persist_instance_data_writes_ec2_metadata_when_set(self):
952+ """When ec2_metadata class attribute is set, persist to json."""
953+ tmp = self.tmp_dir()
954+ datasource = DataSourceTestSubclassNet(
955+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
956+ datasource.ec2_metadata = UNSET
957+ datasource.get_data()
958+ json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
959+ instance_data = util.load_json(util.load_file(json_file))
960+ self.assertNotIn('ec2_metadata', instance_data['ds'])
961+ datasource.ec2_metadata = {'ec2stuff': 'is good'}
962+ datasource.persist_instance_data()
963+ instance_data = util.load_json(util.load_file(json_file))
964+ self.assertEqual(
965+ {'ec2stuff': 'is good'},
966+ instance_data['ds']['ec2_metadata'])
967+
968+ def test_persist_instance_data_writes_network_json_when_set(self):
969+ """When network_data.json class attribute is set, persist to json."""
970+ tmp = self.tmp_dir()
971+ datasource = DataSourceTestSubclassNet(
972+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
973+ datasource.get_data()
974+ json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
975+ instance_data = util.load_json(util.load_file(json_file))
976+ self.assertNotIn('network_json', instance_data['ds'])
977+ datasource.network_json = {'network_json': 'is good'}
978+ datasource.persist_instance_data()
979+ instance_data = util.load_json(util.load_file(json_file))
980+ self.assertEqual(
981+ {'network_json': 'is good'},
982+ instance_data['ds']['network_json'])
983
984 @skipIf(not six.PY3, "json serialization on <= py2.7 handles bytes")
985 def test_get_data_base64encodes_unserializable_bytes(self):
986@@ -320,11 +367,11 @@ class TestDataSource(CiTestCase):
987 content = util.load_file(json_file)
988 instance_json = util.load_json(content)
989 self.assertEqual(
990- ['ds/user-data/key2/key2.1'],
991- instance_json['base64-encoded-keys'])
992+ ['ds/user_data/key2/key2.1'],
993+ instance_json['base64_encoded_keys'])
994 self.assertEqual(
995 {'key1': 'val1', 'key2': {'key2.1': 'EjM='}},
996- instance_json['ds']['user-data'])
997+ instance_json['ds']['user_data'])
998
999 @skipIf(not six.PY2, "json serialization on <= py2.7 handles bytes")
1000 def test_get_data_handles_bytes_values(self):
1001@@ -337,10 +384,10 @@ class TestDataSource(CiTestCase):
1002 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
1003 content = util.load_file(json_file)
1004 instance_json = util.load_json(content)
1005- self.assertEqual([], instance_json['base64-encoded-keys'])
1006+ self.assertEqual([], instance_json['base64_encoded_keys'])
1007 self.assertEqual(
1008 {'key1': 'val1', 'key2': {'key2.1': '\x123'}},
1009- instance_json['ds']['user-data'])
1010+ instance_json['ds']['user_data'])
1011
1012 @skipIf(not six.PY2, "Only python2 hits UnicodeDecodeErrors on non-utf8")
1013 def test_non_utf8_encoding_logs_warning(self):
1014diff --git a/cloudinit/stages.py b/cloudinit/stages.py
1015index 8874d40..ef5c699 100644
1016--- a/cloudinit/stages.py
1017+++ b/cloudinit/stages.py
1018@@ -17,10 +17,11 @@ from cloudinit.settings import (
1019 from cloudinit import handlers
1020
1021 # Default handlers (used if not overridden)
1022-from cloudinit.handlers import boot_hook as bh_part
1023-from cloudinit.handlers import cloud_config as cc_part
1024-from cloudinit.handlers import shell_script as ss_part
1025-from cloudinit.handlers import upstart_job as up_part
1026+from cloudinit.handlers.boot_hook import BootHookPartHandler
1027+from cloudinit.handlers.cloud_config import CloudConfigPartHandler
1028+from cloudinit.handlers.jinja_template import JinjaTemplatePartHandler
1029+from cloudinit.handlers.shell_script import ShellScriptPartHandler
1030+from cloudinit.handlers.upstart_job import UpstartJobPartHandler
1031
1032 from cloudinit.event import EventType
1033
1034@@ -413,12 +414,17 @@ class Init(object):
1035 'datasource': self.datasource,
1036 })
1037 # TODO(harlowja) Hmmm, should we dynamically import these??
1038+ cloudconfig_handler = CloudConfigPartHandler(**opts)
1039+ shellscript_handler = ShellScriptPartHandler(**opts)
1040 def_handlers = [
1041- cc_part.CloudConfigPartHandler(**opts),
1042- ss_part.ShellScriptPartHandler(**opts),
1043- bh_part.BootHookPartHandler(**opts),
1044- up_part.UpstartJobPartHandler(**opts),
1045+ cloudconfig_handler,
1046+ shellscript_handler,
1047+ BootHookPartHandler(**opts),
1048+ UpstartJobPartHandler(**opts),
1049 ]
1050+ opts.update(
1051+ {'sub_handlers': [cloudconfig_handler, shellscript_handler]})
1052+ def_handlers.append(JinjaTemplatePartHandler(**opts))
1053 return def_handlers
1054
1055 def _default_userdata_handlers(self):
1056diff --git a/cloudinit/templater.py b/cloudinit/templater.py
1057index 7e7acb8..b668674 100644
1058--- a/cloudinit/templater.py
1059+++ b/cloudinit/templater.py
1060@@ -13,6 +13,7 @@
1061 import collections
1062 import re
1063
1064+
1065 try:
1066 from Cheetah.Template import Template as CTemplate
1067 CHEETAH_AVAILABLE = True
1068@@ -20,23 +21,44 @@ except (ImportError, AttributeError):
1069 CHEETAH_AVAILABLE = False
1070
1071 try:
1072- import jinja2
1073+ from jinja2.runtime import implements_to_string
1074 from jinja2 import Template as JTemplate
1075+ from jinja2 import DebugUndefined as JUndefined
1076 JINJA_AVAILABLE = True
1077 except (ImportError, AttributeError):
1078+ from cloudinit.helpers import identity
1079+ implements_to_string = identity
1080 JINJA_AVAILABLE = False
1081+ JUndefined = object
1082
1083 from cloudinit import log as logging
1084 from cloudinit import type_utils as tu
1085 from cloudinit import util
1086
1087+
1088 LOG = logging.getLogger(__name__)
1089 TYPE_MATCHER = re.compile(r"##\s*template:(.*)", re.I)
1090 BASIC_MATCHER = re.compile(r'\$\{([A-Za-z0-9_.]+)\}|\$([A-Za-z0-9_.]+)')
1091+MISSING_JINJA_PREFIX = u'CI_MISSING_JINJA_VAR/'
1092+
1093+
1094+@implements_to_string # Needed for python2.7. Otherwise cached super.__str__
1095+class UndefinedJinjaVariable(JUndefined):
1096+ """Class used to represent any undefined jinja template varible."""
1097+
1098+ def __str__(self):
1099+ return u'%s%s' % (MISSING_JINJA_PREFIX, self._undefined_name)
1100+
1101+ def __sub__(self, other):
1102+ other = str(other).replace(MISSING_JINJA_PREFIX, '')
1103+ raise TypeError(
1104+ 'Undefined jinja variable: "{this}-{other}". Jinja tried'
1105+ ' subtraction. Perhaps you meant "{this}_{other}"?'.format(
1106+ this=self._undefined_name, other=other))
1107
1108
1109 def basic_render(content, params):
1110- """This does simple replacement of bash variable like templates.
1111+ """This does sumple replacement of bash variable like templates.
1112
1113 It identifies patterns like ${a} or $a and can also identify patterns like
1114 ${a.b} or $a.b which will look for a key 'b' in the dictionary rooted
1115@@ -82,7 +104,7 @@ def detect_template(text):
1116 # keep_trailing_newline is in jinja2 2.7+, not 2.6
1117 add = "\n" if content.endswith("\n") else ""
1118 return JTemplate(content,
1119- undefined=jinja2.StrictUndefined,
1120+ undefined=UndefinedJinjaVariable,
1121 trim_blocks=True).render(**params) + add
1122
1123 if text.find("\n") != -1:
1124diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
1125index 42f56c2..2eb7b0c 100644
1126--- a/cloudinit/tests/helpers.py
1127+++ b/cloudinit/tests/helpers.py
1128@@ -32,6 +32,7 @@ from cloudinit import cloud
1129 from cloudinit import distros
1130 from cloudinit import helpers as ch
1131 from cloudinit.sources import DataSourceNone
1132+from cloudinit.templater import JINJA_AVAILABLE
1133 from cloudinit import util
1134
1135 _real_subp = util.subp
1136@@ -518,6 +519,14 @@ def skipUnlessJsonSchema():
1137 _missing_jsonschema_dep, "No python-jsonschema dependency present.")
1138
1139
1140+def skipUnlessJinja():
1141+ return skipIf(not JINJA_AVAILABLE, "No jinja dependency present.")
1142+
1143+
1144+def skipIfJinja():
1145+ return skipIf(JINJA_AVAILABLE, "Jinja dependency present.")
1146+
1147+
1148 # older versions of mock do not have the useful 'assert_not_called'
1149 if not hasattr(mock.Mock, 'assert_not_called'):
1150 def __mock_assert_not_called(mmock):
1151diff --git a/debian/changelog b/debian/changelog
1152index 6b1a35f..4ad8877 100644
1153--- a/debian/changelog
1154+++ b/debian/changelog
1155@@ -1,3 +1,14 @@
1156+cloud-init (18.3-44-g84bf2482-0ubuntu1) cosmic; urgency=medium
1157+
1158+ * New upstream snapshot.
1159+ - bash_completion/cloud-init: fix shell syntax error.
1160+ - EphemeralIPv4Network: Be more explicit when adding default route.
1161+ - OpenStack: support reading of newer versions of metdata.
1162+ - OpenStack: fix bug causing 'latest' version to be used from network.
1163+ - user-data: jinja template to render instance-data.json in cloud-config
1164+
1165+ -- Ryan Harper <ryan.harper@canonical.com> Fri, 14 Sep 2018 14:06:29 -0500
1166+
1167 cloud-init (18.3-39-g757247f9-0ubuntu1) cosmic; urgency=medium
1168
1169 * New upstream snapshot.
1170diff --git a/doc/rtd/topics/capabilities.rst b/doc/rtd/topics/capabilities.rst
1171index 3e2c9e3..2d8e253 100644
1172--- a/doc/rtd/topics/capabilities.rst
1173+++ b/doc/rtd/topics/capabilities.rst
1174@@ -16,13 +16,15 @@ User configurability
1175
1176 `Cloud-init`_ 's behavior can be configured via user-data.
1177
1178- User-data can be given by the user at instance launch time.
1179+ User-data can be given by the user at instance launch time. See
1180+ :ref:`user_data_formats` for acceptable user-data content.
1181+
1182
1183 This is done via the ``--user-data`` or ``--user-data-file`` argument to
1184 ec2-run-instances for example.
1185
1186-* Check your local clients documentation for how to provide a `user-data`
1187- string or `user-data` file for usage by cloud-init on instance creation.
1188+* Check your local client's documentation for how to provide a `user-data`
1189+ string or `user-data` file to cloud-init on instance creation.
1190
1191
1192 Feature detection
1193@@ -166,6 +168,13 @@ likely be promoted to top-level subcommands when stable.
1194 validation is work in progress and supports a subset of cloud-config
1195 modules.
1196
1197+ * ``cloud-init devel render``: Use cloud-init's jinja template render to
1198+ process **#cloud-config** or **custom-scripts**, injecting any variables
1199+ from ``/run/cloud-init/instance-data.json``. It accepts a user-data file
1200+ containing the jinja template header ``## template: jinja`` and renders
1201+ that content with any instance-data.json variables present.
1202+
1203+
1204 .. _cli_clean:
1205
1206 cloud-init clean
1207diff --git a/doc/rtd/topics/datasources.rst b/doc/rtd/topics/datasources.rst
1208index 8303458..14432e6 100644
1209--- a/doc/rtd/topics/datasources.rst
1210+++ b/doc/rtd/topics/datasources.rst
1211@@ -18,6 +18,8 @@ single way to access the different cloud systems methods to provide this data
1212 through the typical usage of subclasses.
1213
1214
1215+.. _instance_metadata:
1216+
1217 instance-data
1218 -------------
1219 For reference, cloud-init stores all the metadata, vendordata and userdata
1220@@ -110,6 +112,51 @@ Below is an instance-data.json example from an OpenStack instance:
1221 }
1222 }
1223
1224+
1225+As of cloud-init v. 18.4, any values present in
1226+``/run/cloud-init/instance-data.json`` can be used in cloud-init user data
1227+scripts or cloud config data. This allows consumers to use cloud-init's
1228+vendor-neutral, standardized metadata keys as well as datasource-specific
1229+content for any scripts or cloud-config modules they are using.
1230+
1231+To use instance-data.json values in scripts and **#config-config** files the
1232+user-data will need to contain the following header as the first line **## template: jinja**. Cloud-init will source all variables defined in
1233+``/run/cloud-init/instance-data.json`` and allow scripts or cloud-config files
1234+to reference those paths. Below are two examples::
1235+
1236+ * Cloud config calling home with the ec2 public hostname and avaliability-zone
1237+ ```
1238+ ## template: jinja
1239+ #cloud-config
1240+ runcmd:
1241+ - echo 'EC2 public hostname allocated to instance: {{ ds.meta_data.public_hostname }}' > /tmp/instance_metadata
1242+ - echo 'EC2 avaiability zone: {{ v1.availability_zone }}' >> /tmp/instance_metadata
1243+ - curl -X POST -d '{"hostname": "{{ds.meta_data.public_hostname }}", "availability-zone": "{{ v1.availability_zone }}"}' https://example.com.com
1244+ ```
1245+
1246+ * Custom user script performing different operations based on region
1247+ ```
1248+ ## template: jinja
1249+ #!/bin/bash
1250+ {% if v1.region == 'us-east-2' -%}
1251+ echo 'Installing custom proxies for {{ v1.region }}
1252+ sudo apt-get install my-xtra-fast-stack
1253+ {%- endif %}
1254+ ...
1255+
1256+ ```
1257+
1258+.. note::
1259+ Trying to reference jinja variables that don't exist in
1260+ instance-data.json will result in warnings in ``/var/log/cloud-init.log``
1261+ and the following string in your rendered user-data:
1262+ ``CI_MISSING_JINJA_VAR/<your_varname>``.
1263+
1264+.. note::
1265+ To save time designing your user-data for a specific cloud's
1266+ instance-data.json, use the 'render' cloud-init command on an
1267+ instance booted on your favorite cloud. See :ref:`cli_devel` for more
1268+ information.
1269
1270
1271 Datasource API
1272diff --git a/doc/rtd/topics/format.rst b/doc/rtd/topics/format.rst
1273index 1b0ff36..15234d2 100644
1274--- a/doc/rtd/topics/format.rst
1275+++ b/doc/rtd/topics/format.rst
1276@@ -1,6 +1,8 @@
1277-*******
1278-Formats
1279-*******
1280+.. _user_data_formats:
1281+
1282+*****************
1283+User-Data Formats
1284+*****************
1285
1286 User data that will be acted upon by cloud-init must be in one of the following types.
1287
1288@@ -65,6 +67,11 @@ Typically used by those who just want to execute a shell script.
1289
1290 Begins with: ``#!`` or ``Content-Type: text/x-shellscript`` when using a MIME archive.
1291
1292+.. note::
1293+ New in cloud-init v. 18.4: User-data scripts can also render cloud instance
1294+ metadata variables using jinja templating. See
1295+ :ref:`instance_metadata` for more information.
1296+
1297 Example
1298 -------
1299
1300@@ -103,12 +110,18 @@ These things include:
1301 - certain ssh keys should be imported
1302 - *and many more...*
1303
1304-**Note:** The file must be valid yaml syntax.
1305+.. note::
1306+ This file must be valid yaml syntax.
1307
1308 See the :ref:`yaml_examples` section for a commented set of examples of supported cloud config formats.
1309
1310 Begins with: ``#cloud-config`` or ``Content-Type: text/cloud-config`` when using a MIME archive.
1311
1312+.. note::
1313+ New in cloud-init v. 18.4: Cloud config dta can also render cloud instance
1314+ metadata variables using jinja templating. See
1315+ :ref:`instance_metadata` for more information.
1316+
1317 Upstart Job
1318 ===========
1319
1320diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
1321index 696db8d..2745827 100644
1322--- a/tests/cloud_tests/testcases/base.py
1323+++ b/tests/cloud_tests/testcases/base.py
1324@@ -168,7 +168,7 @@ class CloudTestCase(unittest.TestCase):
1325 ' OS: %s not bionic or newer' % self.os_name)
1326 instance_data = json.loads(out)
1327 self.assertEqual(
1328- ['ds/user-data'], instance_data['base64-encoded-keys'])
1329+ ['ds/user_data'], instance_data['base64_encoded_keys'])
1330 ds = instance_data.get('ds', {})
1331 v1_data = instance_data.get('v1', {})
1332 metadata = ds.get('meta-data', {})
1333@@ -214,8 +214,8 @@ class CloudTestCase(unittest.TestCase):
1334 instance_data = json.loads(out)
1335 v1_data = instance_data.get('v1', {})
1336 self.assertEqual(
1337- ['ds/user-data', 'ds/vendor-data'],
1338- sorted(instance_data['base64-encoded-keys']))
1339+ ['ds/user_data', 'ds/vendor_data'],
1340+ sorted(instance_data['base64_encoded_keys']))
1341 self.assertEqual('nocloud', v1_data['cloud-name'])
1342 self.assertIsNone(
1343 v1_data['availability-zone'],
1344@@ -249,7 +249,7 @@ class CloudTestCase(unittest.TestCase):
1345 instance_data = json.loads(out)
1346 v1_data = instance_data.get('v1', {})
1347 self.assertEqual(
1348- ['ds/user-data'], instance_data['base64-encoded-keys'])
1349+ ['ds/user_data'], instance_data['base64_encoded_keys'])
1350 self.assertEqual('nocloud', v1_data['cloud-name'])
1351 self.assertIsNone(
1352 v1_data['availability-zone'],
1353diff --git a/tests/unittests/test_builtin_handlers.py b/tests/unittests/test_builtin_handlers.py
1354index 9751ed9..abe820e 100644
1355--- a/tests/unittests/test_builtin_handlers.py
1356+++ b/tests/unittests/test_builtin_handlers.py
1357@@ -2,27 +2,34 @@
1358
1359 """Tests of the built-in user data handlers."""
1360
1361+import copy
1362 import os
1363 import shutil
1364 import tempfile
1365+from textwrap import dedent
1366
1367-try:
1368- from unittest import mock
1369-except ImportError:
1370- import mock
1371
1372-from cloudinit.tests import helpers as test_helpers
1373+from cloudinit.tests.helpers import (
1374+ FilesystemMockingTestCase, CiTestCase, mock, skipUnlessJinja)
1375
1376 from cloudinit import handlers
1377 from cloudinit import helpers
1378 from cloudinit import util
1379
1380-from cloudinit.handlers import upstart_job
1381+from cloudinit.handlers.cloud_config import CloudConfigPartHandler
1382+from cloudinit.handlers.jinja_template import (
1383+ JinjaTemplatePartHandler, convert_jinja_instance_data,
1384+ render_jinja_payload)
1385+from cloudinit.handlers.shell_script import ShellScriptPartHandler
1386+from cloudinit.handlers.upstart_job import UpstartJobPartHandler
1387
1388 from cloudinit.settings import (PER_ALWAYS, PER_INSTANCE)
1389
1390
1391-class TestBuiltins(test_helpers.FilesystemMockingTestCase):
1392+class TestUpstartJobPartHandler(FilesystemMockingTestCase):
1393+
1394+ mpath = 'cloudinit.handlers.upstart_job.'
1395+
1396 def test_upstart_frequency_no_out(self):
1397 c_root = tempfile.mkdtemp()
1398 self.addCleanup(shutil.rmtree, c_root)
1399@@ -32,14 +39,13 @@ class TestBuiltins(test_helpers.FilesystemMockingTestCase):
1400 'cloud_dir': c_root,
1401 'upstart_dir': up_root,
1402 })
1403- freq = PER_ALWAYS
1404- h = upstart_job.UpstartJobPartHandler(paths)
1405+ h = UpstartJobPartHandler(paths)
1406 # No files should be written out when
1407 # the frequency is ! per-instance
1408 h.handle_part('', handlers.CONTENT_START,
1409 None, None, None)
1410 h.handle_part('blah', 'text/upstart-job',
1411- 'test.conf', 'blah', freq)
1412+ 'test.conf', 'blah', frequency=PER_ALWAYS)
1413 h.handle_part('', handlers.CONTENT_END,
1414 None, None, None)
1415 self.assertEqual(0, len(os.listdir(up_root)))
1416@@ -48,7 +54,6 @@ class TestBuiltins(test_helpers.FilesystemMockingTestCase):
1417 # files should be written out when frequency is ! per-instance
1418 new_root = tempfile.mkdtemp()
1419 self.addCleanup(shutil.rmtree, new_root)
1420- freq = PER_INSTANCE
1421
1422 self.patchOS(new_root)
1423 self.patchUtils(new_root)
1424@@ -56,22 +61,297 @@ class TestBuiltins(test_helpers.FilesystemMockingTestCase):
1425 'upstart_dir': "/etc/upstart",
1426 })
1427
1428- upstart_job.SUITABLE_UPSTART = True
1429 util.ensure_dir("/run")
1430 util.ensure_dir("/etc/upstart")
1431
1432- with mock.patch.object(util, 'subp') as mockobj:
1433- h = upstart_job.UpstartJobPartHandler(paths)
1434- h.handle_part('', handlers.CONTENT_START,
1435- None, None, None)
1436- h.handle_part('blah', 'text/upstart-job',
1437- 'test.conf', 'blah', freq)
1438- h.handle_part('', handlers.CONTENT_END,
1439- None, None, None)
1440+ with mock.patch(self.mpath + 'SUITABLE_UPSTART', return_value=True):
1441+ with mock.patch.object(util, 'subp') as m_subp:
1442+ h = UpstartJobPartHandler(paths)
1443+ h.handle_part('', handlers.CONTENT_START,
1444+ None, None, None)
1445+ h.handle_part('blah', 'text/upstart-job',
1446+ 'test.conf', 'blah', frequency=PER_INSTANCE)
1447+ h.handle_part('', handlers.CONTENT_END,
1448+ None, None, None)
1449
1450- self.assertEqual(len(os.listdir('/etc/upstart')), 1)
1451+ self.assertEqual(len(os.listdir('/etc/upstart')), 1)
1452
1453- mockobj.assert_called_once_with(
1454+ m_subp.assert_called_once_with(
1455 ['initctl', 'reload-configuration'], capture=False)
1456
1457+
1458+class TestJinjaTemplatePartHandler(CiTestCase):
1459+
1460+ with_logs = True
1461+
1462+ mpath = 'cloudinit.handlers.jinja_template.'
1463+
1464+ def setUp(self):
1465+ super(TestJinjaTemplatePartHandler, self).setUp()
1466+ self.tmp = self.tmp_dir()
1467+ self.run_dir = os.path.join(self.tmp, 'run_dir')
1468+ util.ensure_dir(self.run_dir)
1469+ self.paths = helpers.Paths({
1470+ 'cloud_dir': self.tmp, 'run_dir': self.run_dir})
1471+
1472+ def test_jinja_template_part_handler_defaults(self):
1473+ """On init, paths are saved and subhandler types are empty."""
1474+ h = JinjaTemplatePartHandler(self.paths)
1475+ self.assertEqual(['## template: jinja'], h.prefixes)
1476+ self.assertEqual(3, h.handler_version)
1477+ self.assertEqual(self.paths, h.paths)
1478+ self.assertEqual({}, h.sub_handlers)
1479+
1480+ def test_jinja_template_part_handler_looks_up_sub_handler_types(self):
1481+ """When sub_handlers are passed, init lists types of subhandlers."""
1482+ script_handler = ShellScriptPartHandler(self.paths)
1483+ cloudconfig_handler = CloudConfigPartHandler(self.paths)
1484+ h = JinjaTemplatePartHandler(
1485+ self.paths, sub_handlers=[script_handler, cloudconfig_handler])
1486+ self.assertItemsEqual(
1487+ ['text/cloud-config', 'text/cloud-config-jsonp',
1488+ 'text/x-shellscript'],
1489+ h.sub_handlers)
1490+
1491+ def test_jinja_template_part_handler_looks_up_subhandler_types(self):
1492+ """When sub_handlers are passed, init lists types of subhandlers."""
1493+ script_handler = ShellScriptPartHandler(self.paths)
1494+ cloudconfig_handler = CloudConfigPartHandler(self.paths)
1495+ h = JinjaTemplatePartHandler(
1496+ self.paths, sub_handlers=[script_handler, cloudconfig_handler])
1497+ self.assertItemsEqual(
1498+ ['text/cloud-config', 'text/cloud-config-jsonp',
1499+ 'text/x-shellscript'],
1500+ h.sub_handlers)
1501+
1502+ def test_jinja_template_handle_noop_on_content_signals(self):
1503+ """Perform no part handling when content type is CONTENT_SIGNALS."""
1504+ script_handler = ShellScriptPartHandler(self.paths)
1505+
1506+ h = JinjaTemplatePartHandler(
1507+ self.paths, sub_handlers=[script_handler])
1508+ with mock.patch.object(script_handler, 'handle_part') as m_handle_part:
1509+ h.handle_part(
1510+ data='data', ctype=handlers.CONTENT_START, filename='part-1',
1511+ payload='## template: jinja\n#!/bin/bash\necho himom',
1512+ frequency='freq', headers='headers')
1513+ m_handle_part.assert_not_called()
1514+
1515+ @skipUnlessJinja()
1516+ def test_jinja_template_handle_subhandler_v2_with_clean_payload(self):
1517+ """Call version 2 subhandler.handle_part with stripped payload."""
1518+ script_handler = ShellScriptPartHandler(self.paths)
1519+ self.assertEqual(2, script_handler.handler_version)
1520+
1521+ # Create required instance-data.json file
1522+ instance_json = os.path.join(self.run_dir, 'instance-data.json')
1523+ instance_data = {'topkey': 'echo himom'}
1524+ util.write_file(instance_json, util.json_dumps(instance_data))
1525+ h = JinjaTemplatePartHandler(
1526+ self.paths, sub_handlers=[script_handler])
1527+ with mock.patch.object(script_handler, 'handle_part') as m_part:
1528+ # ctype with leading '!' not in handlers.CONTENT_SIGNALS
1529+ h.handle_part(
1530+ data='data', ctype="!" + handlers.CONTENT_START,
1531+ filename='part01',
1532+ payload='## template: jinja \t \n#!/bin/bash\n{{ topkey }}',
1533+ frequency='freq', headers='headers')
1534+ m_part.assert_called_once_with(
1535+ 'data', '!__begin__', 'part01', '#!/bin/bash\necho himom', 'freq')
1536+
1537+ @skipUnlessJinja()
1538+ def test_jinja_template_handle_subhandler_v3_with_clean_payload(self):
1539+ """Call version 3 subhandler.handle_part with stripped payload."""
1540+ cloudcfg_handler = CloudConfigPartHandler(self.paths)
1541+ self.assertEqual(3, cloudcfg_handler.handler_version)
1542+
1543+ # Create required instance-data.json file
1544+ instance_json = os.path.join(self.run_dir, 'instance-data.json')
1545+ instance_data = {'topkey': {'sub': 'runcmd: [echo hi]'}}
1546+ util.write_file(instance_json, util.json_dumps(instance_data))
1547+ h = JinjaTemplatePartHandler(
1548+ self.paths, sub_handlers=[cloudcfg_handler])
1549+ with mock.patch.object(cloudcfg_handler, 'handle_part') as m_part:
1550+ # ctype with leading '!' not in handlers.CONTENT_SIGNALS
1551+ h.handle_part(
1552+ data='data', ctype="!" + handlers.CONTENT_END,
1553+ filename='part01',
1554+ payload='## template: jinja\n#cloud-config\n{{ topkey.sub }}',
1555+ frequency='freq', headers='headers')
1556+ m_part.assert_called_once_with(
1557+ 'data', '!__end__', 'part01', '#cloud-config\nruncmd: [echo hi]',
1558+ 'freq', 'headers')
1559+
1560+ def test_jinja_template_handle_errors_on_missing_instance_data_json(self):
1561+ """If instance-data is absent, raise an error from handle_part."""
1562+ script_handler = ShellScriptPartHandler(self.paths)
1563+ h = JinjaTemplatePartHandler(
1564+ self.paths, sub_handlers=[script_handler])
1565+ with self.assertRaises(RuntimeError) as context_manager:
1566+ h.handle_part(
1567+ data='data', ctype="!" + handlers.CONTENT_START,
1568+ filename='part01',
1569+ payload='## template: jinja \n#!/bin/bash\necho himom',
1570+ frequency='freq', headers='headers')
1571+ script_file = os.path.join(script_handler.script_dir, 'part01')
1572+ self.assertEqual(
1573+ 'Cannot render jinja template vars. Instance data not yet present'
1574+ ' at {}/instance-data.json'.format(
1575+ self.run_dir), str(context_manager.exception))
1576+ self.assertFalse(
1577+ os.path.exists(script_file),
1578+ 'Unexpected file created %s' % script_file)
1579+
1580+ @skipUnlessJinja()
1581+ def test_jinja_template_handle_renders_jinja_content(self):
1582+ """When present, render jinja variables from instance-data.json."""
1583+ script_handler = ShellScriptPartHandler(self.paths)
1584+ instance_json = os.path.join(self.run_dir, 'instance-data.json')
1585+ instance_data = {'topkey': {'subkey': 'echo himom'}}
1586+ util.write_file(instance_json, util.json_dumps(instance_data))
1587+ h = JinjaTemplatePartHandler(
1588+ self.paths, sub_handlers=[script_handler])
1589+ h.handle_part(
1590+ data='data', ctype="!" + handlers.CONTENT_START,
1591+ filename='part01',
1592+ payload=(
1593+ '## template: jinja \n'
1594+ '#!/bin/bash\n'
1595+ '{{ topkey.subkey|default("nosubkey") }}'),
1596+ frequency='freq', headers='headers')
1597+ script_file = os.path.join(script_handler.script_dir, 'part01')
1598+ self.assertNotIn(
1599+ 'Instance data not yet present at {}/instance-data.json'.format(
1600+ self.run_dir),
1601+ self.logs.getvalue())
1602+ self.assertEqual(
1603+ '#!/bin/bash\necho himom', util.load_file(script_file))
1604+
1605+ @skipUnlessJinja()
1606+ def test_jinja_template_handle_renders_jinja_content_missing_keys(self):
1607+ """When specified jinja variable is undefined, log a warning."""
1608+ script_handler = ShellScriptPartHandler(self.paths)
1609+ instance_json = os.path.join(self.run_dir, 'instance-data.json')
1610+ instance_data = {'topkey': {'subkey': 'echo himom'}}
1611+ util.write_file(instance_json, util.json_dumps(instance_data))
1612+ h = JinjaTemplatePartHandler(
1613+ self.paths, sub_handlers=[script_handler])
1614+ h.handle_part(
1615+ data='data', ctype="!" + handlers.CONTENT_START,
1616+ filename='part01',
1617+ payload='## template: jinja \n#!/bin/bash\n{{ goodtry }}',
1618+ frequency='freq', headers='headers')
1619+ script_file = os.path.join(script_handler.script_dir, 'part01')
1620+ self.assertTrue(
1621+ os.path.exists(script_file),
1622+ 'Missing expected file %s' % script_file)
1623+ self.assertIn(
1624+ "WARNING: Could not render jinja template variables in file"
1625+ " 'part01': 'goodtry'\n",
1626+ self.logs.getvalue())
1627+
1628+
1629+class TestConvertJinjaInstanceData(CiTestCase):
1630+
1631+ def test_convert_instance_data_hyphens_to_underscores(self):
1632+ """Replace hyphenated keys with underscores in instance-data."""
1633+ data = {'hyphenated-key': 'hyphenated-val',
1634+ 'underscore_delim_key': 'underscore_delimited_val'}
1635+ expected_data = {'hyphenated_key': 'hyphenated-val',
1636+ 'underscore_delim_key': 'underscore_delimited_val'}
1637+ self.assertEqual(
1638+ expected_data,
1639+ convert_jinja_instance_data(data=data))
1640+
1641+ def test_convert_instance_data_promotes_versioned_keys_to_top_level(self):
1642+ """Any versioned keys are promoted as top-level keys
1643+
1644+ This provides any cloud-init standardized keys up at a top-level to
1645+ allow ease of reference for users. Intsead of v1.availability_zone,
1646+ the name availability_zone can be used in templates.
1647+ """
1648+ data = {'ds': {'dskey1': 1, 'dskey2': 2},
1649+ 'v1': {'v1key1': 'v1.1'},
1650+ 'v2': {'v2key1': 'v2.1'}}
1651+ expected_data = copy.deepcopy(data)
1652+ expected_data.update({'v1key1': 'v1.1', 'v2key1': 'v2.1'})
1653+
1654+ converted_data = convert_jinja_instance_data(data=data)
1655+ self.assertItemsEqual(
1656+ ['ds', 'v1', 'v2', 'v1key1', 'v2key1'], converted_data.keys())
1657+ self.assertEqual(
1658+ expected_data,
1659+ converted_data)
1660+
1661+ def test_convert_instance_data_most_recent_version_of_promoted_keys(self):
1662+ """The most-recent versioned key value is promoted to top-level."""
1663+ data = {'v1': {'key1': 'old v1 key1', 'key2': 'old v1 key2'},
1664+ 'v2': {'key1': 'newer v2 key1', 'key3': 'newer v2 key3'},
1665+ 'v3': {'key1': 'newest v3 key1'}}
1666+ expected_data = copy.deepcopy(data)
1667+ expected_data.update(
1668+ {'key1': 'newest v3 key1', 'key2': 'old v1 key2',
1669+ 'key3': 'newer v2 key3'})
1670+
1671+ converted_data = convert_jinja_instance_data(data=data)
1672+ self.assertEqual(
1673+ expected_data,
1674+ converted_data)
1675+
1676+ def test_convert_instance_data_decodes_decode_paths(self):
1677+ """Any decode_paths provided are decoded by convert_instance_data."""
1678+ data = {'key1': {'subkey1': 'aGkgbW9t'}, 'key2': 'aGkgZGFk'}
1679+ expected_data = copy.deepcopy(data)
1680+ expected_data['key1']['subkey1'] = 'hi mom'
1681+
1682+ converted_data = convert_jinja_instance_data(
1683+ data=data, decode_paths=('key1/subkey1',))
1684+ self.assertEqual(
1685+ expected_data,
1686+ converted_data)
1687+
1688+
1689+class TestRenderJinjaPayload(CiTestCase):
1690+
1691+ with_logs = True
1692+
1693+ @skipUnlessJinja()
1694+ def test_render_jinja_payload_logs_jinja_vars_on_debug(self):
1695+ """When debug is True, log jinja varables available."""
1696+ payload = (
1697+ '## template: jinja\n#!/bin/sh\necho hi from {{ v1.hostname }}')
1698+ instance_data = {'v1': {'hostname': 'foo'}, 'instance-id': 'iid'}
1699+ expected_log = dedent("""\
1700+ DEBUG: Converted jinja variables
1701+ {
1702+ "hostname": "foo",
1703+ "instance_id": "iid",
1704+ "v1": {
1705+ "hostname": "foo"
1706+ }
1707+ }
1708+ """)
1709+ self.assertEqual(
1710+ render_jinja_payload(
1711+ payload=payload, payload_fn='myfile',
1712+ instance_data=instance_data, debug=True),
1713+ '#!/bin/sh\necho hi from foo')
1714+ self.assertEqual(expected_log, self.logs.getvalue())
1715+
1716+ @skipUnlessJinja()
1717+ def test_render_jinja_payload_replaces_missing_variables_and_warns(self):
1718+ """Warn on missing jinja variables and replace the absent variable."""
1719+ payload = (
1720+ '## template: jinja\n#!/bin/sh\necho hi from {{ NOTHERE }}')
1721+ instance_data = {'v1': {'hostname': 'foo'}, 'instance-id': 'iid'}
1722+ self.assertEqual(
1723+ render_jinja_payload(
1724+ payload=payload, payload_fn='myfile',
1725+ instance_data=instance_data),
1726+ '#!/bin/sh\necho hi from CI_MISSING_JINJA_VAR/NOTHERE')
1727+ expected_log = (
1728+ 'WARNING: Could not render jinja template variables in file'
1729+ " 'myfile': 'NOTHERE'")
1730+ self.assertIn(expected_log, self.logs.getvalue())
1731+
1732 # vi: ts=4 expandtab
1733diff --git a/tests/unittests/test_datasource/test_openstack.py b/tests/unittests/test_datasource/test_openstack.py
1734index 6e1e971..a731f1e 100644
1735--- a/tests/unittests/test_datasource/test_openstack.py
1736+++ b/tests/unittests/test_datasource/test_openstack.py
1737@@ -12,7 +12,7 @@ import re
1738 from cloudinit.tests import helpers as test_helpers
1739
1740 from six.moves.urllib.parse import urlparse
1741-from six import StringIO
1742+from six import StringIO, text_type
1743
1744 from cloudinit import helpers
1745 from cloudinit import settings
1746@@ -555,4 +555,94 @@ class TestDetectOpenStack(test_helpers.CiTestCase):
1747 m_proc_env.assert_called_with(1)
1748
1749
1750+class TestMetadataReader(test_helpers.HttprettyTestCase):
1751+ """Test the MetadataReader."""
1752+ burl = 'http://169.254.169.254/'
1753+ md_base = {
1754+ 'availability_zone': 'myaz1',
1755+ 'hostname': 'sm-foo-test.novalocal',
1756+ "keys": [{"data": PUBKEY, "name": "brickies", "type": "ssh"}],
1757+ 'launch_index': 0,
1758+ 'name': 'sm-foo-test',
1759+ 'public_keys': {'mykey': PUBKEY},
1760+ 'project_id': '6a103f813b774b9fb15a4fcd36e1c056',
1761+ 'uuid': 'b0fa911b-69d4-4476-bbe2-1c92bff6535c'}
1762+
1763+ def register(self, path, body=None, status=200):
1764+ content = (body if not isinstance(body, text_type)
1765+ else body.encode('utf-8'))
1766+ hp.register_uri(
1767+ hp.GET, self.burl + "openstack" + path, status=status,
1768+ body=content)
1769+
1770+ def register_versions(self, versions):
1771+ self.register("", '\n'.join(versions))
1772+ self.register("/", '\n'.join(versions))
1773+
1774+ def register_version(self, version, data):
1775+ content = '\n'.join(sorted(data.keys()))
1776+ self.register(version, content)
1777+ self.register(version + "/", content)
1778+ for path, content in data.items():
1779+ self.register("/%s/%s" % (version, path), content)
1780+ self.register("/%s/%s" % (version, path), content)
1781+ if 'user_data' not in data:
1782+ self.register("/%s/user_data" % version, "nodata", status=404)
1783+
1784+ def test__find_working_version(self):
1785+ """Test a working version ignores unsupported."""
1786+ unsup = "2016-11-09"
1787+ self.register_versions(
1788+ [openstack.OS_FOLSOM, openstack.OS_LIBERTY, unsup,
1789+ openstack.OS_LATEST])
1790+ self.assertEqual(
1791+ openstack.OS_LIBERTY,
1792+ openstack.MetadataReader(self.burl)._find_working_version())
1793+
1794+ def test__find_working_version_uses_latest(self):
1795+ """'latest' should be used if no supported versions."""
1796+ unsup1, unsup2 = ("2016-11-09", '2017-06-06')
1797+ self.register_versions([unsup1, unsup2, openstack.OS_LATEST])
1798+ self.assertEqual(
1799+ openstack.OS_LATEST,
1800+ openstack.MetadataReader(self.burl)._find_working_version())
1801+
1802+ def test_read_v2_os_ocata(self):
1803+ """Validate return value of read_v2 for os_ocata data."""
1804+ md = copy.deepcopy(self.md_base)
1805+ md['devices'] = []
1806+ network_data = {'links': [], 'networks': [], 'services': []}
1807+ vendor_data = {}
1808+ vendor_data2 = {"static": {}}
1809+
1810+ data = {
1811+ 'meta_data.json': json.dumps(md),
1812+ 'network_data.json': json.dumps(network_data),
1813+ 'vendor_data.json': json.dumps(vendor_data),
1814+ 'vendor_data2.json': json.dumps(vendor_data2),
1815+ }
1816+
1817+ self.register_versions([openstack.OS_OCATA, openstack.OS_LATEST])
1818+ self.register_version(openstack.OS_OCATA, data)
1819+
1820+ mock_read_ec2 = test_helpers.mock.MagicMock(
1821+ return_value={'instance-id': 'unused-ec2'})
1822+ expected_md = copy.deepcopy(md)
1823+ expected_md.update(
1824+ {'instance-id': md['uuid'], 'local-hostname': md['hostname']})
1825+ expected = {
1826+ 'userdata': '', # Annoying, no user-data results in empty string.
1827+ 'version': 2,
1828+ 'metadata': expected_md,
1829+ 'vendordata': vendor_data,
1830+ 'networkdata': network_data,
1831+ 'ec2-metadata': mock_read_ec2.return_value,
1832+ 'files': {},
1833+ }
1834+ reader = openstack.MetadataReader(self.burl)
1835+ reader._read_ec2_metadata = mock_read_ec2
1836+ self.assertEqual(expected, reader.read_v2())
1837+ self.assertEqual(1, mock_read_ec2.call_count)
1838+
1839+
1840 # vi: ts=4 expandtab
1841diff --git a/tests/unittests/test_handler/test_handler_etc_hosts.py b/tests/unittests/test_handler/test_handler_etc_hosts.py
1842index ced05a8..d854afc 100644
1843--- a/tests/unittests/test_handler/test_handler_etc_hosts.py
1844+++ b/tests/unittests/test_handler/test_handler_etc_hosts.py
1845@@ -49,6 +49,7 @@ class TestHostsFile(t_help.FilesystemMockingTestCase):
1846 if '192.168.1.1\tblah.blah.us\tblah' not in contents:
1847 self.assertIsNone('Default etc/hosts content modified')
1848
1849+ @t_help.skipUnlessJinja()
1850 def test_write_etc_hosts_suse_template(self):
1851 cfg = {
1852 'manage_etc_hosts': 'template',
1853diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
1854index 6fe3659..0f22e57 100644
1855--- a/tests/unittests/test_handler/test_handler_ntp.py
1856+++ b/tests/unittests/test_handler/test_handler_ntp.py
1857@@ -3,6 +3,7 @@
1858 from cloudinit.config import cc_ntp
1859 from cloudinit.sources import DataSourceNone
1860 from cloudinit import (distros, helpers, cloud, util)
1861+
1862 from cloudinit.tests.helpers import (
1863 CiTestCase, FilesystemMockingTestCase, mock, skipUnlessJsonSchema)
1864
1865diff --git a/tests/unittests/test_templating.py b/tests/unittests/test_templating.py
1866index 20c87ef..c36e6eb 100644
1867--- a/tests/unittests/test_templating.py
1868+++ b/tests/unittests/test_templating.py
1869@@ -21,6 +21,9 @@ except ImportError:
1870
1871
1872 class TestTemplates(test_helpers.CiTestCase):
1873+
1874+ with_logs = True
1875+
1876 jinja_utf8 = b'It\xe2\x80\x99s not ascii, {{name}}\n'
1877 jinja_utf8_rbob = b'It\xe2\x80\x99s not ascii, bob\n'.decode('utf-8')
1878
1879@@ -124,6 +127,13 @@ $a,$b'''
1880 self.add_header("jinja", self.jinja_utf8), {"name": "bob"}),
1881 self.jinja_utf8_rbob)
1882
1883+ def test_jinja_nonascii_render_undefined_variables_to_default_py3(self):
1884+ """Test py3 jinja render_to_string with undefined variable default."""
1885+ self.assertEqual(
1886+ templater.render_string(
1887+ self.add_header("jinja", self.jinja_utf8), {}),
1888+ self.jinja_utf8_rbob.replace('bob', 'CI_MISSING_JINJA_VAR/name'))
1889+
1890 def test_jinja_nonascii_render_to_file(self):
1891 """Test jinja render_to_file of a filename with non-ascii content."""
1892 tmpl_fn = self.tmp_path("j-render-to-file.template")
1893@@ -144,5 +154,18 @@ $a,$b'''
1894 result = templater.render_from_file(tmpl_fn, {"name": "bob"})
1895 self.assertEqual(result, self.jinja_utf8_rbob)
1896
1897+ @test_helpers.skipIfJinja()
1898+ def test_jinja_warns_on_missing_dep_and_uses_basic_renderer(self):
1899+ """Test jinja render_from_file will fallback to basic renderer."""
1900+ tmpl_fn = self.tmp_path("j-render-from-file.template")
1901+ write_file(tmpl_fn, omode="wb",
1902+ content=self.add_header(
1903+ "jinja", self.jinja_utf8).encode('utf-8'))
1904+ result = templater.render_from_file(tmpl_fn, {"name": "bob"})
1905+ self.assertEqual(result, self.jinja_utf8.decode())
1906+ self.assertIn(
1907+ 'WARNING: Jinja not available as the selected renderer for desired'
1908+ ' template, reverting to the basic renderer.',
1909+ self.logs.getvalue())
1910
1911 # vi: ts=4 expandtab

Subscribers

People subscribed via source and target branches