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
diff --git a/bash_completion/cloud-init b/bash_completion/cloud-init
index f38164b..6d01bf3 100644
--- a/bash_completion/cloud-init
+++ b/bash_completion/cloud-init
@@ -62,6 +62,8 @@ _cloudinit_complete()
62 net-convert)62 net-convert)
63 COMPREPLY=($(compgen -W "--help --network-data --kind --directory --output-kind" -- $cur_word))63 COMPREPLY=($(compgen -W "--help --network-data --kind --directory --output-kind" -- $cur_word))
64 ;;64 ;;
65 render)
66 COMPREPLY=($(compgen -W "--help --instance-data --debug" -- $cur_word));;
65 schema)67 schema)
66 COMPREPLY=($(compgen -W "--help --config-file --doc --annotate" -- $cur_word))68 COMPREPLY=($(compgen -W "--help --config-file --doc --annotate" -- $cur_word))
67 ;;69 ;;
diff --git a/cloudinit/cmd/devel/__init__.py b/cloudinit/cmd/devel/__init__.py
index e69de29..3ae28b6 100644
--- a/cloudinit/cmd/devel/__init__.py
+++ b/cloudinit/cmd/devel/__init__.py
@@ -0,0 +1,25 @@
1# This file is part of cloud-init. See LICENSE file for license information.
2
3"""Common cloud-init devel commandline utility functions."""
4
5
6import logging
7
8from cloudinit import log
9from cloudinit.stages import Init
10
11
12def addLogHandlerCLI(logger, log_level):
13 """Add a commandline logging handler to emit messages to stderr."""
14 formatter = logging.Formatter('%(levelname)s: %(message)s')
15 log.setupBasicLogging(log_level, formatter=formatter)
16 return logger
17
18
19def read_cfg_paths():
20 """Return a Paths object based on the system configuration on disk."""
21 init = Init(ds_deps=[])
22 init.read_cfg()
23 return init.paths
24
25# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/devel/parser.py b/cloudinit/cmd/devel/parser.py
index 40a4b01..99a234c 100644
--- a/cloudinit/cmd/devel/parser.py
+++ b/cloudinit/cmd/devel/parser.py
@@ -8,6 +8,7 @@ import argparse
8from cloudinit.config import schema8from cloudinit.config import schema
99
10from . import net_convert10from . import net_convert
11from . import render
1112
1213
13def get_parser(parser=None):14def get_parser(parser=None):
@@ -22,7 +23,9 @@ def get_parser(parser=None):
22 ('schema', 'Validate cloud-config files for document schema',23 ('schema', 'Validate cloud-config files for document schema',
23 schema.get_parser, schema.handle_schema_args),24 schema.get_parser, schema.handle_schema_args),
24 (net_convert.NAME, net_convert.__doc__,25 (net_convert.NAME, net_convert.__doc__,
25 net_convert.get_parser, net_convert.handle_args)26 net_convert.get_parser, net_convert.handle_args),
27 (render.NAME, render.__doc__,
28 render.get_parser, render.handle_args)
26 ]29 ]
27 for (subcmd, helpmsg, get_parser, handler) in subcmds:30 for (subcmd, helpmsg, get_parser, handler) in subcmds:
28 parser = subparsers.add_parser(subcmd, help=helpmsg)31 parser = subparsers.add_parser(subcmd, help=helpmsg)
diff --git a/cloudinit/cmd/devel/render.py b/cloudinit/cmd/devel/render.py
29new file mode 10075532new file mode 100755
index 0000000..e85933d
--- /dev/null
+++ b/cloudinit/cmd/devel/render.py
@@ -0,0 +1,90 @@
1# This file is part of cloud-init. See LICENSE file for license information.
2
3"""Debug jinja template rendering of user-data."""
4
5import argparse
6import os
7import sys
8
9from cloudinit.handlers.jinja_template import render_jinja_payload_from_file
10from cloudinit import log
11from cloudinit.sources import INSTANCE_JSON_FILE
12from cloudinit import util
13from . import addLogHandlerCLI, read_cfg_paths
14
15NAME = 'render'
16DEFAULT_INSTANCE_DATA = '/run/cloud-init/instance-data.json'
17
18LOG = log.getLogger(NAME)
19
20
21def get_parser(parser=None):
22 """Build or extend and arg parser for jinja render utility.
23
24 @param parser: Optional existing ArgumentParser instance representing the
25 subcommand which will be extended to support the args of this utility.
26
27 @returns: ArgumentParser with proper argument configuration.
28 """
29 if not parser:
30 parser = argparse.ArgumentParser(prog=NAME, description=__doc__)
31 parser.add_argument(
32 'user_data', type=str, help='Path to the user-data file to render')
33 parser.add_argument(
34 '-i', '--instance-data', type=str,
35 help=('Optional path to instance-data.json file. Defaults to'
36 ' /run/cloud-init/instance-data.json'))
37 parser.add_argument('-d', '--debug', action='store_true', default=False,
38 help='Add verbose messages during template render')
39 return parser
40
41
42def handle_args(name, args):
43 """Render the provided user-data template file using instance-data values.
44
45 Also setup CLI log handlers to report to stderr since this is a development
46 utility which should be run by a human on the CLI.
47
48 @return 0 on success, 1 on failure.
49 """
50 addLogHandlerCLI(LOG, log.DEBUG if args.debug else log.WARNING)
51 if not args.instance_data:
52 paths = read_cfg_paths()
53 instance_data_fn = os.path.join(
54 paths.run_dir, INSTANCE_JSON_FILE)
55 else:
56 instance_data_fn = args.instance_data
57 try:
58 with open(instance_data_fn) as stream:
59 instance_data = stream.read()
60 instance_data = util.load_json(instance_data)
61 except IOError:
62 LOG.error('Missing instance-data.json file: %s', instance_data_fn)
63 return 1
64 try:
65 with open(args.user_data) as stream:
66 user_data = stream.read()
67 except IOError:
68 LOG.error('Missing user-data file: %s', args.user_data)
69 return 1
70 rendered_payload = render_jinja_payload_from_file(
71 payload=user_data, payload_fn=args.user_data,
72 instance_data_file=instance_data_fn,
73 debug=True if args.debug else False)
74 if not rendered_payload:
75 LOG.error('Unable to render user-data file: %s', args.user_data)
76 return 1
77 sys.stdout.write(rendered_payload)
78 return 0
79
80
81def main():
82 args = get_parser().parse_args()
83 return(handle_args(NAME, args))
84
85
86if __name__ == '__main__':
87 sys.exit(main())
88
89
90# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/devel/tests/test_render.py b/cloudinit/cmd/devel/tests/test_render.py
0new file mode 10064491new file mode 100644
index 0000000..fc5d2c0
--- /dev/null
+++ b/cloudinit/cmd/devel/tests/test_render.py
@@ -0,0 +1,101 @@
1# This file is part of cloud-init. See LICENSE file for license information.
2
3from six import StringIO
4import os
5
6from collections import namedtuple
7from cloudinit.cmd.devel import render
8from cloudinit.helpers import Paths
9from cloudinit.sources import INSTANCE_JSON_FILE
10from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJinja
11from cloudinit.util import ensure_dir, write_file
12
13
14class TestRender(CiTestCase):
15
16 with_logs = True
17
18 args = namedtuple('renderargs', 'user_data instance_data debug')
19
20 def setUp(self):
21 super(TestRender, self).setUp()
22 self.tmp = self.tmp_dir()
23
24 def test_handle_args_error_on_missing_user_data(self):
25 """When user_data file path does not exist, log an error."""
26 absent_file = self.tmp_path('user-data', dir=self.tmp)
27 instance_data = self.tmp_path('instance-data', dir=self.tmp)
28 write_file(instance_data, '{}')
29 args = self.args(
30 user_data=absent_file, instance_data=instance_data, debug=False)
31 with mock.patch('sys.stderr', new_callable=StringIO):
32 self.assertEqual(1, render.handle_args('anyname', args))
33 self.assertIn(
34 'Missing user-data file: %s' % absent_file,
35 self.logs.getvalue())
36
37 def test_handle_args_error_on_missing_instance_data(self):
38 """When instance_data file path does not exist, log an error."""
39 user_data = self.tmp_path('user-data', dir=self.tmp)
40 absent_file = self.tmp_path('instance-data', dir=self.tmp)
41 args = self.args(
42 user_data=user_data, instance_data=absent_file, debug=False)
43 with mock.patch('sys.stderr', new_callable=StringIO):
44 self.assertEqual(1, render.handle_args('anyname', args))
45 self.assertIn(
46 'Missing instance-data.json file: %s' % absent_file,
47 self.logs.getvalue())
48
49 def test_handle_args_defaults_instance_data(self):
50 """When no instance_data argument, default to configured run_dir."""
51 user_data = self.tmp_path('user-data', dir=self.tmp)
52 run_dir = self.tmp_path('run_dir', dir=self.tmp)
53 ensure_dir(run_dir)
54 paths = Paths({'run_dir': run_dir})
55 self.add_patch('cloudinit.cmd.devel.render.read_cfg_paths', 'm_paths')
56 self.m_paths.return_value = paths
57 args = self.args(
58 user_data=user_data, instance_data=None, debug=False)
59 with mock.patch('sys.stderr', new_callable=StringIO):
60 self.assertEqual(1, render.handle_args('anyname', args))
61 json_file = os.path.join(run_dir, INSTANCE_JSON_FILE)
62 self.assertIn(
63 'Missing instance-data.json file: %s' % json_file,
64 self.logs.getvalue())
65
66 @skipUnlessJinja()
67 def test_handle_args_renders_instance_data_vars_in_template(self):
68 """If user_data file is a jinja template render instance-data vars."""
69 user_data = self.tmp_path('user-data', dir=self.tmp)
70 write_file(user_data, '##template: jinja\nrendering: {{ my_var }}')
71 instance_data = self.tmp_path('instance-data', dir=self.tmp)
72 write_file(instance_data, '{"my-var": "jinja worked"}')
73 args = self.args(
74 user_data=user_data, instance_data=instance_data, debug=True)
75 with mock.patch('sys.stderr', new_callable=StringIO) as m_console_err:
76 with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
77 self.assertEqual(0, render.handle_args('anyname', args))
78 self.assertIn(
79 'DEBUG: Converted jinja variables\n{', self.logs.getvalue())
80 self.assertIn(
81 'DEBUG: Converted jinja variables\n{', m_console_err.getvalue())
82 self.assertEqual('rendering: jinja worked', m_stdout.getvalue())
83
84 @skipUnlessJinja()
85 def test_handle_args_warns_and_gives_up_on_invalid_jinja_operation(self):
86 """If user_data file has invalid jinja operations log warnings."""
87 user_data = self.tmp_path('user-data', dir=self.tmp)
88 write_file(user_data, '##template: jinja\nrendering: {{ my-var }}')
89 instance_data = self.tmp_path('instance-data', dir=self.tmp)
90 write_file(instance_data, '{"my-var": "jinja worked"}')
91 args = self.args(
92 user_data=user_data, instance_data=instance_data, debug=True)
93 with mock.patch('sys.stderr', new_callable=StringIO):
94 self.assertEqual(1, render.handle_args('anyname', args))
95 self.assertIn(
96 'WARNING: Ignoring jinja template for %s: Undefined jinja'
97 ' variable: "my-var". Jinja tried subtraction. Perhaps you meant'
98 ' "my_var"?' % user_data,
99 self.logs.getvalue())
100
101# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
index 4ea4fe7..0eee583 100644
--- a/cloudinit/cmd/main.py
+++ b/cloudinit/cmd/main.py
@@ -348,6 +348,7 @@ def main_init(name, args):
348 LOG.debug("[%s] barreling on in force mode without datasource",348 LOG.debug("[%s] barreling on in force mode without datasource",
349 mode)349 mode)
350350
351 _maybe_persist_instance_data(init)
351 # Stage 6352 # Stage 6
352 iid = init.instancify()353 iid = init.instancify()
353 LOG.debug("[%s] %s will now be targeting instance id: %s. new=%s",354 LOG.debug("[%s] %s will now be targeting instance id: %s. new=%s",
@@ -490,6 +491,7 @@ def main_modules(action_name, args):
490 print_exc(msg)491 print_exc(msg)
491 if not args.force:492 if not args.force:
492 return [(msg)]493 return [(msg)]
494 _maybe_persist_instance_data(init)
493 # Stage 3495 # Stage 3
494 mods = stages.Modules(init, extract_fns(args), reporter=args.reporter)496 mods = stages.Modules(init, extract_fns(args), reporter=args.reporter)
495 # Stage 4497 # Stage 4
@@ -541,6 +543,7 @@ def main_single(name, args):
541 " likely bad things to come!"))543 " likely bad things to come!"))
542 if not args.force:544 if not args.force:
543 return 1545 return 1
546 _maybe_persist_instance_data(init)
544 # Stage 3547 # Stage 3
545 mods = stages.Modules(init, extract_fns(args), reporter=args.reporter)548 mods = stages.Modules(init, extract_fns(args), reporter=args.reporter)
546 mod_args = args.module_args549 mod_args = args.module_args
@@ -688,6 +691,15 @@ def status_wrapper(name, args, data_d=None, link_d=None):
688 return len(v1[mode]['errors'])691 return len(v1[mode]['errors'])
689692
690693
694def _maybe_persist_instance_data(init):
695 """Write instance-data.json file if absent and datasource is restored."""
696 if init.ds_restored:
697 instance_data_file = os.path.join(
698 init.paths.run_dir, sources.INSTANCE_JSON_FILE)
699 if not os.path.exists(instance_data_file):
700 init.datasource.persist_instance_data()
701
702
691def _maybe_set_hostname(init, stage, retry_stage):703def _maybe_set_hostname(init, stage, retry_stage):
692 """Call set-hostname if metadata, vendordata or userdata provides it.704 """Call set-hostname if metadata, vendordata or userdata provides it.
693705
@@ -887,6 +899,8 @@ def main(sysv_args=None):
887if __name__ == '__main__':899if __name__ == '__main__':
888 if 'TZ' not in os.environ:900 if 'TZ' not in os.environ:
889 os.environ['TZ'] = ":/etc/localtime"901 os.environ['TZ'] = ":/etc/localtime"
890 main(sys.argv)902 return_value = main(sys.argv)
903 if return_value:
904 sys.exit(return_value)
891905
892# vi: ts=4 expandtab906# vi: ts=4 expandtab
diff --git a/cloudinit/handlers/__init__.py b/cloudinit/handlers/__init__.py
index c3576c0..0db75af 100644
--- a/cloudinit/handlers/__init__.py
+++ b/cloudinit/handlers/__init__.py
@@ -41,7 +41,7 @@ PART_HANDLER_FN_TMPL = 'part-handler-%03d'
41# For parts without filenames41# For parts without filenames
42PART_FN_TPL = 'part-%03d'42PART_FN_TPL = 'part-%03d'
4343
44# Different file beginnings to there content type44# Different file beginnings to their content type
45INCLUSION_TYPES_MAP = {45INCLUSION_TYPES_MAP = {
46 '#include': 'text/x-include-url',46 '#include': 'text/x-include-url',
47 '#include-once': 'text/x-include-once-url',47 '#include-once': 'text/x-include-once-url',
@@ -52,6 +52,7 @@ INCLUSION_TYPES_MAP = {
52 '#cloud-boothook': 'text/cloud-boothook',52 '#cloud-boothook': 'text/cloud-boothook',
53 '#cloud-config-archive': 'text/cloud-config-archive',53 '#cloud-config-archive': 'text/cloud-config-archive',
54 '#cloud-config-jsonp': 'text/cloud-config-jsonp',54 '#cloud-config-jsonp': 'text/cloud-config-jsonp',
55 '## template: jinja': 'text/jinja2',
55}56}
5657
57# Sorted longest first58# Sorted longest first
@@ -69,9 +70,13 @@ class Handler(object):
69 def __repr__(self):70 def __repr__(self):
70 return "%s: [%s]" % (type_utils.obj_name(self), self.list_types())71 return "%s: [%s]" % (type_utils.obj_name(self), self.list_types())
7172
72 @abc.abstractmethod
73 def list_types(self):73 def list_types(self):
74 raise NotImplementedError()74 # Each subclass must define the supported content prefixes it handles.
75 if not hasattr(self, 'prefixes'):
76 raise NotImplementedError('Missing prefixes subclass attribute')
77 else:
78 return [INCLUSION_TYPES_MAP[prefix]
79 for prefix in getattr(self, 'prefixes')]
7580
76 @abc.abstractmethod81 @abc.abstractmethod
77 def handle_part(self, *args, **kwargs):82 def handle_part(self, *args, **kwargs):
diff --git a/cloudinit/handlers/boot_hook.py b/cloudinit/handlers/boot_hook.py
index 057b4db..dca50a4 100644
--- a/cloudinit/handlers/boot_hook.py
+++ b/cloudinit/handlers/boot_hook.py
@@ -17,10 +17,13 @@ from cloudinit import util
17from cloudinit.settings import (PER_ALWAYS)17from cloudinit.settings import (PER_ALWAYS)
1818
19LOG = logging.getLogger(__name__)19LOG = logging.getLogger(__name__)
20BOOTHOOK_PREFIX = "#cloud-boothook"
2120
2221
23class BootHookPartHandler(handlers.Handler):22class BootHookPartHandler(handlers.Handler):
23
24 # The content prefixes this handler understands.
25 prefixes = ['#cloud-boothook']
26
24 def __init__(self, paths, datasource, **_kwargs):27 def __init__(self, paths, datasource, **_kwargs):
25 handlers.Handler.__init__(self, PER_ALWAYS)28 handlers.Handler.__init__(self, PER_ALWAYS)
26 self.boothook_dir = paths.get_ipath("boothooks")29 self.boothook_dir = paths.get_ipath("boothooks")
@@ -28,16 +31,11 @@ class BootHookPartHandler(handlers.Handler):
28 if datasource:31 if datasource:
29 self.instance_id = datasource.get_instance_id()32 self.instance_id = datasource.get_instance_id()
3033
31 def list_types(self):
32 return [
33 handlers.type_from_starts_with(BOOTHOOK_PREFIX),
34 ]
35
36 def _write_part(self, payload, filename):34 def _write_part(self, payload, filename):
37 filename = util.clean_filename(filename)35 filename = util.clean_filename(filename)
38 filepath = os.path.join(self.boothook_dir, filename)36 filepath = os.path.join(self.boothook_dir, filename)
39 contents = util.strip_prefix_suffix(util.dos2unix(payload),37 contents = util.strip_prefix_suffix(util.dos2unix(payload),
40 prefix=BOOTHOOK_PREFIX)38 prefix=self.prefixes[0])
41 util.write_file(filepath, contents.lstrip(), 0o700)39 util.write_file(filepath, contents.lstrip(), 0o700)
42 return filepath40 return filepath
4341
diff --git a/cloudinit/handlers/cloud_config.py b/cloudinit/handlers/cloud_config.py
index 178a5b9..99bf0e6 100644
--- a/cloudinit/handlers/cloud_config.py
+++ b/cloudinit/handlers/cloud_config.py
@@ -42,14 +42,12 @@ DEF_MERGERS = mergers.string_extract_mergers('dict(replace)+list()+str()')
42CLOUD_PREFIX = "#cloud-config"42CLOUD_PREFIX = "#cloud-config"
43JSONP_PREFIX = "#cloud-config-jsonp"43JSONP_PREFIX = "#cloud-config-jsonp"
4444
45# The file header -> content types this module will handle.
46CC_TYPES = {
47 JSONP_PREFIX: handlers.type_from_starts_with(JSONP_PREFIX),
48 CLOUD_PREFIX: handlers.type_from_starts_with(CLOUD_PREFIX),
49}
50
5145
52class CloudConfigPartHandler(handlers.Handler):46class CloudConfigPartHandler(handlers.Handler):
47
48 # The content prefixes this handler understands.
49 prefixes = [CLOUD_PREFIX, JSONP_PREFIX]
50
53 def __init__(self, paths, **_kwargs):51 def __init__(self, paths, **_kwargs):
54 handlers.Handler.__init__(self, PER_ALWAYS, version=3)52 handlers.Handler.__init__(self, PER_ALWAYS, version=3)
55 self.cloud_buf = None53 self.cloud_buf = None
@@ -58,9 +56,6 @@ class CloudConfigPartHandler(handlers.Handler):
58 self.cloud_fn = paths.get_ipath(_kwargs["cloud_config_path"])56 self.cloud_fn = paths.get_ipath(_kwargs["cloud_config_path"])
59 self.file_names = []57 self.file_names = []
6058
61 def list_types(self):
62 return list(CC_TYPES.values())
63
64 def _write_cloud_config(self):59 def _write_cloud_config(self):
65 if not self.cloud_fn:60 if not self.cloud_fn:
66 return61 return
@@ -138,7 +133,7 @@ class CloudConfigPartHandler(handlers.Handler):
138 # First time through, merge with an empty dict...133 # First time through, merge with an empty dict...
139 if self.cloud_buf is None or not self.file_names:134 if self.cloud_buf is None or not self.file_names:
140 self.cloud_buf = {}135 self.cloud_buf = {}
141 if ctype == CC_TYPES[JSONP_PREFIX]:136 if ctype == handlers.INCLUSION_TYPES_MAP[JSONP_PREFIX]:
142 self._merge_patch(payload)137 self._merge_patch(payload)
143 else:138 else:
144 self._merge_part(payload, headers)139 self._merge_part(payload, headers)
diff --git a/cloudinit/handlers/jinja_template.py b/cloudinit/handlers/jinja_template.py
145new file mode 100644140new file mode 100644
index 0000000..3fa4097
--- /dev/null
+++ b/cloudinit/handlers/jinja_template.py
@@ -0,0 +1,137 @@
1# This file is part of cloud-init. See LICENSE file for license information.
2
3import os
4import re
5
6try:
7 from jinja2.exceptions import UndefinedError as JUndefinedError
8except ImportError:
9 # No jinja2 dependency
10 JUndefinedError = Exception
11
12from cloudinit import handlers
13from cloudinit import log as logging
14from cloudinit.sources import INSTANCE_JSON_FILE
15from cloudinit.templater import render_string, MISSING_JINJA_PREFIX
16from cloudinit.util import b64d, load_file, load_json, json_dumps
17
18from cloudinit.settings import PER_ALWAYS
19
20LOG = logging.getLogger(__name__)
21
22
23class JinjaTemplatePartHandler(handlers.Handler):
24
25 prefixes = ['## template: jinja']
26
27 def __init__(self, paths, **_kwargs):
28 handlers.Handler.__init__(self, PER_ALWAYS, version=3)
29 self.paths = paths
30 self.sub_handlers = {}
31 for handler in _kwargs.get('sub_handlers', []):
32 for ctype in handler.list_types():
33 self.sub_handlers[ctype] = handler
34
35 def handle_part(self, data, ctype, filename, payload, frequency, headers):
36 if ctype in handlers.CONTENT_SIGNALS:
37 return
38 jinja_json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
39 rendered_payload = render_jinja_payload_from_file(
40 payload, filename, jinja_json_file)
41 if not rendered_payload:
42 return
43 subtype = handlers.type_from_starts_with(rendered_payload)
44 sub_handler = self.sub_handlers.get(subtype)
45 if not sub_handler:
46 LOG.warning(
47 'Ignoring jinja template for %s. Could not find supported'
48 ' sub-handler for type %s', filename, subtype)
49 return
50 if sub_handler.handler_version == 3:
51 sub_handler.handle_part(
52 data, ctype, filename, rendered_payload, frequency, headers)
53 elif sub_handler.handler_version == 2:
54 sub_handler.handle_part(
55 data, ctype, filename, rendered_payload, frequency)
56
57
58def render_jinja_payload_from_file(
59 payload, payload_fn, instance_data_file, debug=False):
60 """Render a jinja template payload sourcing variables from jinja_vars_path.
61
62 @param payload: String of jinja template content. Should begin with
63 ## template: jinja\n.
64 @param payload_fn: String representing the filename from which the payload
65 was read used in error reporting. Generally in part-handling this is
66 'part-##'.
67 @param instance_data_file: A path to a json file containing variables that
68 will be used as jinja template variables.
69
70 @return: A string of jinja-rendered content with the jinja header removed.
71 Returns None on error.
72 """
73 instance_data = {}
74 rendered_payload = None
75 if not os.path.exists(instance_data_file):
76 raise RuntimeError(
77 'Cannot render jinja template vars. Instance data not yet'
78 ' present at %s' % instance_data_file)
79 instance_data = load_json(load_file(instance_data_file))
80 rendered_payload = render_jinja_payload(
81 payload, payload_fn, instance_data, debug)
82 if not rendered_payload:
83 return None
84 return rendered_payload
85
86
87def render_jinja_payload(payload, payload_fn, instance_data, debug=False):
88 instance_jinja_vars = convert_jinja_instance_data(
89 instance_data,
90 decode_paths=instance_data.get('base64-encoded-keys', []))
91 if debug:
92 LOG.debug('Converted jinja variables\n%s',
93 json_dumps(instance_jinja_vars))
94 try:
95 rendered_payload = render_string(payload, instance_jinja_vars)
96 except (TypeError, JUndefinedError) as e:
97 LOG.warning(
98 'Ignoring jinja template for %s: %s', payload_fn, str(e))
99 return None
100 warnings = [
101 "'%s'" % var.replace(MISSING_JINJA_PREFIX, '')
102 for var in re.findall(
103 r'%s[^\s]+' % MISSING_JINJA_PREFIX, rendered_payload)]
104 if warnings:
105 LOG.warning(
106 "Could not render jinja template variables in file '%s': %s",
107 payload_fn, ', '.join(warnings))
108 return rendered_payload
109
110
111def convert_jinja_instance_data(data, prefix='', sep='/', decode_paths=()):
112 """Process instance-data.json dict for use in jinja templates.
113
114 Replace hyphens with underscores for jinja templates and decode any
115 base64_encoded_keys.
116 """
117 result = {}
118 decode_paths = [path.replace('-', '_') for path in decode_paths]
119 for key, value in sorted(data.items()):
120 if '-' in key:
121 # Standardize keys for use in #cloud-config/shell templates
122 key = key.replace('-', '_')
123 key_path = '{0}{1}{2}'.format(prefix, sep, key) if prefix else key
124 if key_path in decode_paths:
125 value = b64d(value)
126 if isinstance(value, dict):
127 result[key] = convert_jinja_instance_data(
128 value, key_path, sep=sep, decode_paths=decode_paths)
129 if re.match(r'v\d+', key):
130 # Copy values to top-level aliases
131 for subkey, subvalue in result[key].items():
132 result[subkey] = subvalue
133 else:
134 result[key] = value
135 return result
136
137# vi: ts=4 expandtab
diff --git a/cloudinit/handlers/shell_script.py b/cloudinit/handlers/shell_script.py
index e4945a2..214714b 100644
--- a/cloudinit/handlers/shell_script.py
+++ b/cloudinit/handlers/shell_script.py
@@ -17,21 +17,18 @@ from cloudinit import util
17from cloudinit.settings import (PER_ALWAYS)17from cloudinit.settings import (PER_ALWAYS)
1818
19LOG = logging.getLogger(__name__)19LOG = logging.getLogger(__name__)
20SHELL_PREFIX = "#!"
2120
2221
23class ShellScriptPartHandler(handlers.Handler):22class ShellScriptPartHandler(handlers.Handler):
23
24 prefixes = ['#!']
25
24 def __init__(self, paths, **_kwargs):26 def __init__(self, paths, **_kwargs):
25 handlers.Handler.__init__(self, PER_ALWAYS)27 handlers.Handler.__init__(self, PER_ALWAYS)
26 self.script_dir = paths.get_ipath_cur('scripts')28 self.script_dir = paths.get_ipath_cur('scripts')
27 if 'script_path' in _kwargs:29 if 'script_path' in _kwargs:
28 self.script_dir = paths.get_ipath_cur(_kwargs['script_path'])30 self.script_dir = paths.get_ipath_cur(_kwargs['script_path'])
2931
30 def list_types(self):
31 return [
32 handlers.type_from_starts_with(SHELL_PREFIX),
33 ]
34
35 def handle_part(self, data, ctype, filename, payload, frequency):32 def handle_part(self, data, ctype, filename, payload, frequency):
36 if ctype in handlers.CONTENT_SIGNALS:33 if ctype in handlers.CONTENT_SIGNALS:
37 # TODO(harlowja): maybe delete existing things here34 # TODO(harlowja): maybe delete existing things here
diff --git a/cloudinit/handlers/upstart_job.py b/cloudinit/handlers/upstart_job.py
index dc33876..83fb072 100644
--- a/cloudinit/handlers/upstart_job.py
+++ b/cloudinit/handlers/upstart_job.py
@@ -18,19 +18,16 @@ from cloudinit import util
18from cloudinit.settings import (PER_INSTANCE)18from cloudinit.settings import (PER_INSTANCE)
1919
20LOG = logging.getLogger(__name__)20LOG = logging.getLogger(__name__)
21UPSTART_PREFIX = "#upstart-job"
2221
2322
24class UpstartJobPartHandler(handlers.Handler):23class UpstartJobPartHandler(handlers.Handler):
24
25 prefixes = ['#upstart-job']
26
25 def __init__(self, paths, **_kwargs):27 def __init__(self, paths, **_kwargs):
26 handlers.Handler.__init__(self, PER_INSTANCE)28 handlers.Handler.__init__(self, PER_INSTANCE)
27 self.upstart_dir = paths.upstart_conf_d29 self.upstart_dir = paths.upstart_conf_d
2830
29 def list_types(self):
30 return [
31 handlers.type_from_starts_with(UPSTART_PREFIX),
32 ]
33
34 def handle_part(self, data, ctype, filename, payload, frequency):31 def handle_part(self, data, ctype, filename, payload, frequency):
35 if ctype in handlers.CONTENT_SIGNALS:32 if ctype in handlers.CONTENT_SIGNALS:
36 return33 return
diff --git a/cloudinit/helpers.py b/cloudinit/helpers.py
index 1979cd9..3cc1fb1 100644
--- a/cloudinit/helpers.py
+++ b/cloudinit/helpers.py
@@ -449,4 +449,8 @@ class DefaultingConfigParser(RawConfigParser):
449 contents = '\n'.join([header, contents, ''])449 contents = '\n'.join([header, contents, ''])
450 return contents450 return contents
451451
452
453def identity(object):
454 return object
455
452# vi: ts=4 expandtab456# vi: ts=4 expandtab
diff --git a/cloudinit/log.py b/cloudinit/log.py
index 1d75c9f..5ae312b 100644
--- a/cloudinit/log.py
+++ b/cloudinit/log.py
@@ -38,10 +38,18 @@ DEF_CON_FORMAT = '%(asctime)s - %(filename)s[%(levelname)s]: %(message)s'
38logging.Formatter.converter = time.gmtime38logging.Formatter.converter = time.gmtime
3939
4040
41def setupBasicLogging(level=DEBUG):41def setupBasicLogging(level=DEBUG, formatter=None):
42 if not formatter:
43 formatter = logging.Formatter(DEF_CON_FORMAT)
42 root = logging.getLogger()44 root = logging.getLogger()
45 for handler in root.handlers:
46 if hasattr(handler, 'stream') and hasattr(handler.stream, 'name'):
47 if handler.stream.name == '<stderr>':
48 handler.setLevel(level)
49 return
50 # Didn't have an existing stderr handler; create a new handler
43 console = logging.StreamHandler(sys.stderr)51 console = logging.StreamHandler(sys.stderr)
44 console.setFormatter(logging.Formatter(DEF_CON_FORMAT))52 console.setFormatter(formatter)
45 console.setLevel(level)53 console.setLevel(level)
46 root.addHandler(console)54 root.addHandler(console)
47 root.setLevel(level)55 root.setLevel(level)
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index 3ffde52..5e87bca 100644
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -698,6 +698,13 @@ class EphemeralIPv4Network(object):
698 self.interface, out.strip())698 self.interface, out.strip())
699 return699 return
700 util.subp(700 util.subp(
701 ['ip', '-4', 'route', 'add', self.router, 'dev', self.interface,
702 'src', self.ip], capture=True)
703 self.cleanup_cmds.insert(
704 0,
705 ['ip', '-4', 'route', 'del', self.router, 'dev', self.interface,
706 'src', self.ip])
707 util.subp(
701 ['ip', '-4', 'route', 'add', 'default', 'via', self.router,708 ['ip', '-4', 'route', 'add', 'default', 'via', self.router,
702 'dev', self.interface], capture=True)709 'dev', self.interface], capture=True)
703 self.cleanup_cmds.insert(710 self.cleanup_cmds.insert(
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
index 8b444f1..58e0a59 100644
--- a/cloudinit/net/tests/test_init.py
+++ b/cloudinit/net/tests/test_init.py
@@ -515,12 +515,17 @@ class TestEphemeralIPV4Network(CiTestCase):
515 capture=True),515 capture=True),
516 mock.call(516 mock.call(
517 ['ip', 'route', 'show', '0.0.0.0/0'], capture=True),517 ['ip', 'route', 'show', '0.0.0.0/0'], capture=True),
518 mock.call(['ip', '-4', 'route', 'add', '192.168.2.1',
519 'dev', 'eth0', 'src', '192.168.2.2'], capture=True),
518 mock.call(520 mock.call(
519 ['ip', '-4', 'route', 'add', 'default', 'via',521 ['ip', '-4', 'route', 'add', 'default', 'via',
520 '192.168.2.1', 'dev', 'eth0'], capture=True)]522 '192.168.2.1', 'dev', 'eth0'], capture=True)]
521 expected_teardown_calls = [mock.call(523 expected_teardown_calls = [
522 ['ip', '-4', 'route', 'del', 'default', 'dev', 'eth0'],524 mock.call(['ip', '-4', 'route', 'del', 'default', 'dev', 'eth0'],
523 capture=True)]525 capture=True),
526 mock.call(['ip', '-4', 'route', 'del', '192.168.2.1',
527 'dev', 'eth0', 'src', '192.168.2.2'], capture=True),
528 ]
524529
525 with net.EphemeralIPv4Network(**params):530 with net.EphemeralIPv4Network(**params):
526 self.assertEqual(expected_setup_calls, m_subp.call_args_list)531 self.assertEqual(expected_setup_calls, m_subp.call_args_list)
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
index 41fde9b..a775f1a 100644
--- a/cloudinit/sources/__init__.py
+++ b/cloudinit/sources/__init__.py
@@ -58,22 +58,27 @@ class InvalidMetaDataException(Exception):
58 pass58 pass
5959
6060
61def process_base64_metadata(metadata, key_path=''):61def process_instance_metadata(metadata, key_path=''):
62 """Strip ci-b64 prefix and return metadata with base64-encoded-keys set."""62 """Process all instance metadata cleaning it up for persisting as json.
63
64 Strip ci-b64 prefix and catalog any 'base64_encoded_keys' as a list
65
66 @return Dict copy of processed metadata.
67 """
63 md_copy = copy.deepcopy(metadata)68 md_copy = copy.deepcopy(metadata)
64 md_copy['base64-encoded-keys'] = []69 md_copy['base64_encoded_keys'] = []
65 for key, val in metadata.items():70 for key, val in metadata.items():
66 if key_path:71 if key_path:
67 sub_key_path = key_path + '/' + key72 sub_key_path = key_path + '/' + key
68 else:73 else:
69 sub_key_path = key74 sub_key_path = key
70 if isinstance(val, str) and val.startswith('ci-b64:'):75 if isinstance(val, str) and val.startswith('ci-b64:'):
71 md_copy['base64-encoded-keys'].append(sub_key_path)76 md_copy['base64_encoded_keys'].append(sub_key_path)
72 md_copy[key] = val.replace('ci-b64:', '')77 md_copy[key] = val.replace('ci-b64:', '')
73 if isinstance(val, dict):78 if isinstance(val, dict):
74 return_val = process_base64_metadata(val, sub_key_path)79 return_val = process_instance_metadata(val, sub_key_path)
75 md_copy['base64-encoded-keys'].extend(80 md_copy['base64_encoded_keys'].extend(
76 return_val.pop('base64-encoded-keys'))81 return_val.pop('base64_encoded_keys'))
77 md_copy[key] = return_val82 md_copy[key] = return_val
78 return md_copy83 return md_copy
7984
@@ -180,15 +185,24 @@ class DataSource(object):
180 """185 """
181 self._dirty_cache = True186 self._dirty_cache = True
182 return_value = self._get_data()187 return_value = self._get_data()
183 json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
184 if not return_value:188 if not return_value:
185 return return_value189 return return_value
190 self.persist_instance_data()
191 return return_value
192
193 def persist_instance_data(self):
194 """Process and write INSTANCE_JSON_FILE with all instance metadata.
186195
196 Replace any hyphens with underscores in key names for use in template
197 processing.
198
199 @return True on successful write, False otherwise.
200 """
187 instance_data = {201 instance_data = {
188 'ds': {202 'ds': {
189 'meta-data': self.metadata,203 'meta_data': self.metadata,
190 'user-data': self.get_userdata_raw(),204 'user_data': self.get_userdata_raw(),
191 'vendor-data': self.get_vendordata_raw()}}205 'vendor_data': self.get_vendordata_raw()}}
192 if hasattr(self, 'network_json'):206 if hasattr(self, 'network_json'):
193 network_json = getattr(self, 'network_json')207 network_json = getattr(self, 'network_json')
194 if network_json != UNSET:208 if network_json != UNSET:
@@ -202,16 +216,17 @@ class DataSource(object):
202 try:216 try:
203 # Process content base64encoding unserializable values217 # Process content base64encoding unserializable values
204 content = util.json_dumps(instance_data)218 content = util.json_dumps(instance_data)
205 # Strip base64: prefix and return base64-encoded-keys219 # Strip base64: prefix and set base64_encoded_keys list.
206 processed_data = process_base64_metadata(json.loads(content))220 processed_data = process_instance_metadata(json.loads(content))
207 except TypeError as e:221 except TypeError as e:
208 LOG.warning('Error persisting instance-data.json: %s', str(e))222 LOG.warning('Error persisting instance-data.json: %s', str(e))
209 return return_value223 return False
210 except UnicodeDecodeError as e:224 except UnicodeDecodeError as e:
211 LOG.warning('Error persisting instance-data.json: %s', str(e))225 LOG.warning('Error persisting instance-data.json: %s', str(e))
212 return return_value226 return False
227 json_file = os.path.join(self.paths.run_dir, INSTANCE_JSON_FILE)
213 write_json(json_file, processed_data, mode=0o600)228 write_json(json_file, processed_data, mode=0o600)
214 return return_value229 return True
215230
216 def _get_data(self):231 def _get_data(self):
217 """Walk metadata sources, process crawled data and save attributes."""232 """Walk metadata sources, process crawled data and save attributes."""
diff --git a/cloudinit/sources/helpers/openstack.py b/cloudinit/sources/helpers/openstack.py
index 8f9c144..d6f39a1 100644
--- a/cloudinit/sources/helpers/openstack.py
+++ b/cloudinit/sources/helpers/openstack.py
@@ -38,21 +38,38 @@ KEY_COPIES = (
38 ('local-hostname', 'hostname', False),38 ('local-hostname', 'hostname', False),
39 ('instance-id', 'uuid', True),39 ('instance-id', 'uuid', True),
40)40)
41
42# Versions and names taken from nova source nova/api/metadata/base.py
41OS_LATEST = 'latest'43OS_LATEST = 'latest'
42OS_FOLSOM = '2012-08-10'44OS_FOLSOM = '2012-08-10'
43OS_GRIZZLY = '2013-04-04'45OS_GRIZZLY = '2013-04-04'
44OS_HAVANA = '2013-10-17'46OS_HAVANA = '2013-10-17'
45OS_LIBERTY = '2015-10-15'47OS_LIBERTY = '2015-10-15'
48# NEWTON_ONE adds 'devices' to md (sriov-pf-passthrough-neutron-port-vlan)
49OS_NEWTON_ONE = '2016-06-30'
50# NEWTON_TWO adds vendor_data2.json (vendordata-reboot)
51OS_NEWTON_TWO = '2016-10-06'
52# OS_OCATA adds 'vif' field to devices (sriov-pf-passthrough-neutron-port-vlan)
53OS_OCATA = '2017-02-22'
54# OS_ROCKY adds a vf_trusted field to devices (sriov-trusted-vfs)
55OS_ROCKY = '2018-08-27'
56
57
46# keep this in chronological order. new supported versions go at the end.58# keep this in chronological order. new supported versions go at the end.
47OS_VERSIONS = (59OS_VERSIONS = (
48 OS_FOLSOM,60 OS_FOLSOM,
49 OS_GRIZZLY,61 OS_GRIZZLY,
50 OS_HAVANA,62 OS_HAVANA,
51 OS_LIBERTY,63 OS_LIBERTY,
64 OS_NEWTON_ONE,
65 OS_NEWTON_TWO,
66 OS_OCATA,
67 OS_ROCKY,
52)68)
5369
54PHYSICAL_TYPES = (70PHYSICAL_TYPES = (
55 None,71 None,
72 'bgpovs', # not present in OpenStack upstream but used on OVH cloud.
56 'bridge',73 'bridge',
57 'dvs',74 'dvs',
58 'ethernet',75 'ethernet',
@@ -439,7 +456,7 @@ class MetadataReader(BaseReader):
439 return self._versions456 return self._versions
440 found = []457 found = []
441 version_path = self._path_join(self.base_path, "openstack")458 version_path = self._path_join(self.base_path, "openstack")
442 content = self._path_read(version_path)459 content = self._path_read(version_path, decode=True)
443 for line in content.splitlines():460 for line in content.splitlines():
444 line = line.strip()461 line = line.strip()
445 if not line:462 if not line:
diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
index 9e939c1..8299af2 100644
--- a/cloudinit/sources/tests/test_init.py
+++ b/cloudinit/sources/tests/test_init.py
@@ -20,10 +20,12 @@ class DataSourceTestSubclassNet(DataSource):
20 dsname = 'MyTestSubclass'20 dsname = 'MyTestSubclass'
21 url_max_wait = 5521 url_max_wait = 55
2222
23 def __init__(self, sys_cfg, distro, paths, custom_userdata=None):23 def __init__(self, sys_cfg, distro, paths, custom_userdata=None,
24 get_data_retval=True):
24 super(DataSourceTestSubclassNet, self).__init__(25 super(DataSourceTestSubclassNet, self).__init__(
25 sys_cfg, distro, paths)26 sys_cfg, distro, paths)
26 self._custom_userdata = custom_userdata27 self._custom_userdata = custom_userdata
28 self._get_data_retval = get_data_retval
2729
28 def _get_cloud_name(self):30 def _get_cloud_name(self):
29 return 'SubclassCloudName'31 return 'SubclassCloudName'
@@ -37,7 +39,7 @@ class DataSourceTestSubclassNet(DataSource):
37 else:39 else:
38 self.userdata_raw = 'userdata_raw'40 self.userdata_raw = 'userdata_raw'
39 self.vendordata_raw = 'vendordata_raw'41 self.vendordata_raw = 'vendordata_raw'
40 return True42 return self._get_data_retval
4143
4244
43class InvalidDataSourceTestSubclassNet(DataSource):45class InvalidDataSourceTestSubclassNet(DataSource):
@@ -264,7 +266,18 @@ class TestDataSource(CiTestCase):
264 self.assertEqual('fqdnhostname.domain.com',266 self.assertEqual('fqdnhostname.domain.com',
265 datasource.get_hostname(fqdn=True))267 datasource.get_hostname(fqdn=True))
266268
267 def test_get_data_write_json_instance_data(self):269 def test_get_data_does_not_write_instance_data_on_failure(self):
270 """get_data does not write INSTANCE_JSON_FILE on get_data False."""
271 tmp = self.tmp_dir()
272 datasource = DataSourceTestSubclassNet(
273 self.sys_cfg, self.distro, Paths({'run_dir': tmp}),
274 get_data_retval=False)
275 self.assertFalse(datasource.get_data())
276 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
277 self.assertFalse(
278 os.path.exists(json_file), 'Found unexpected file %s' % json_file)
279
280 def test_get_data_writes_json_instance_data_on_success(self):
268 """get_data writes INSTANCE_JSON_FILE to run_dir as readonly root."""281 """get_data writes INSTANCE_JSON_FILE to run_dir as readonly root."""
269 tmp = self.tmp_dir()282 tmp = self.tmp_dir()
270 datasource = DataSourceTestSubclassNet(283 datasource = DataSourceTestSubclassNet(
@@ -273,7 +286,7 @@ class TestDataSource(CiTestCase):
273 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)286 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
274 content = util.load_file(json_file)287 content = util.load_file(json_file)
275 expected = {288 expected = {
276 'base64-encoded-keys': [],289 'base64_encoded_keys': [],
277 'v1': {290 'v1': {
278 'availability-zone': 'myaz',291 'availability-zone': 'myaz',
279 'cloud-name': 'subclasscloudname',292 'cloud-name': 'subclasscloudname',
@@ -281,11 +294,12 @@ class TestDataSource(CiTestCase):
281 'local-hostname': 'test-subclass-hostname',294 'local-hostname': 'test-subclass-hostname',
282 'region': 'myregion'},295 'region': 'myregion'},
283 'ds': {296 'ds': {
284 'meta-data': {'availability_zone': 'myaz',297 'meta_data': {'availability_zone': 'myaz',
285 'local-hostname': 'test-subclass-hostname',298 'local-hostname': 'test-subclass-hostname',
286 'region': 'myregion'},299 'region': 'myregion'},
287 'user-data': 'userdata_raw',300 'user_data': 'userdata_raw',
288 'vendor-data': 'vendordata_raw'}}301 'vendor_data': 'vendordata_raw'}}
302 self.maxDiff = None
289 self.assertEqual(expected, util.load_json(content))303 self.assertEqual(expected, util.load_json(content))
290 file_stat = os.stat(json_file)304 file_stat = os.stat(json_file)
291 self.assertEqual(0o600, stat.S_IMODE(file_stat.st_mode))305 self.assertEqual(0o600, stat.S_IMODE(file_stat.st_mode))
@@ -296,7 +310,7 @@ class TestDataSource(CiTestCase):
296 datasource = DataSourceTestSubclassNet(310 datasource = DataSourceTestSubclassNet(
297 self.sys_cfg, self.distro, Paths({'run_dir': tmp}),311 self.sys_cfg, self.distro, Paths({'run_dir': tmp}),
298 custom_userdata={'key1': 'val1', 'key2': {'key2.1': self.paths}})312 custom_userdata={'key1': 'val1', 'key2': {'key2.1': self.paths}})
299 self.assertTrue(datasource.get_data())313 datasource.get_data()
300 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)314 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
301 content = util.load_file(json_file)315 content = util.load_file(json_file)
302 expected_userdata = {316 expected_userdata = {
@@ -306,7 +320,40 @@ class TestDataSource(CiTestCase):
306 " 'cloudinit.helpers.Paths'>"}}320 " 'cloudinit.helpers.Paths'>"}}
307 instance_json = util.load_json(content)321 instance_json = util.load_json(content)
308 self.assertEqual(322 self.assertEqual(
309 expected_userdata, instance_json['ds']['user-data'])323 expected_userdata, instance_json['ds']['user_data'])
324
325 def test_persist_instance_data_writes_ec2_metadata_when_set(self):
326 """When ec2_metadata class attribute is set, persist to json."""
327 tmp = self.tmp_dir()
328 datasource = DataSourceTestSubclassNet(
329 self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
330 datasource.ec2_metadata = UNSET
331 datasource.get_data()
332 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
333 instance_data = util.load_json(util.load_file(json_file))
334 self.assertNotIn('ec2_metadata', instance_data['ds'])
335 datasource.ec2_metadata = {'ec2stuff': 'is good'}
336 datasource.persist_instance_data()
337 instance_data = util.load_json(util.load_file(json_file))
338 self.assertEqual(
339 {'ec2stuff': 'is good'},
340 instance_data['ds']['ec2_metadata'])
341
342 def test_persist_instance_data_writes_network_json_when_set(self):
343 """When network_data.json class attribute is set, persist to json."""
344 tmp = self.tmp_dir()
345 datasource = DataSourceTestSubclassNet(
346 self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
347 datasource.get_data()
348 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
349 instance_data = util.load_json(util.load_file(json_file))
350 self.assertNotIn('network_json', instance_data['ds'])
351 datasource.network_json = {'network_json': 'is good'}
352 datasource.persist_instance_data()
353 instance_data = util.load_json(util.load_file(json_file))
354 self.assertEqual(
355 {'network_json': 'is good'},
356 instance_data['ds']['network_json'])
310357
311 @skipIf(not six.PY3, "json serialization on <= py2.7 handles bytes")358 @skipIf(not six.PY3, "json serialization on <= py2.7 handles bytes")
312 def test_get_data_base64encodes_unserializable_bytes(self):359 def test_get_data_base64encodes_unserializable_bytes(self):
@@ -320,11 +367,11 @@ class TestDataSource(CiTestCase):
320 content = util.load_file(json_file)367 content = util.load_file(json_file)
321 instance_json = util.load_json(content)368 instance_json = util.load_json(content)
322 self.assertEqual(369 self.assertEqual(
323 ['ds/user-data/key2/key2.1'],370 ['ds/user_data/key2/key2.1'],
324 instance_json['base64-encoded-keys'])371 instance_json['base64_encoded_keys'])
325 self.assertEqual(372 self.assertEqual(
326 {'key1': 'val1', 'key2': {'key2.1': 'EjM='}},373 {'key1': 'val1', 'key2': {'key2.1': 'EjM='}},
327 instance_json['ds']['user-data'])374 instance_json['ds']['user_data'])
328375
329 @skipIf(not six.PY2, "json serialization on <= py2.7 handles bytes")376 @skipIf(not six.PY2, "json serialization on <= py2.7 handles bytes")
330 def test_get_data_handles_bytes_values(self):377 def test_get_data_handles_bytes_values(self):
@@ -337,10 +384,10 @@ class TestDataSource(CiTestCase):
337 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)384 json_file = self.tmp_path(INSTANCE_JSON_FILE, tmp)
338 content = util.load_file(json_file)385 content = util.load_file(json_file)
339 instance_json = util.load_json(content)386 instance_json = util.load_json(content)
340 self.assertEqual([], instance_json['base64-encoded-keys'])387 self.assertEqual([], instance_json['base64_encoded_keys'])
341 self.assertEqual(388 self.assertEqual(
342 {'key1': 'val1', 'key2': {'key2.1': '\x123'}},389 {'key1': 'val1', 'key2': {'key2.1': '\x123'}},
343 instance_json['ds']['user-data'])390 instance_json['ds']['user_data'])
344391
345 @skipIf(not six.PY2, "Only python2 hits UnicodeDecodeErrors on non-utf8")392 @skipIf(not six.PY2, "Only python2 hits UnicodeDecodeErrors on non-utf8")
346 def test_non_utf8_encoding_logs_warning(self):393 def test_non_utf8_encoding_logs_warning(self):
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index 8874d40..ef5c699 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -17,10 +17,11 @@ from cloudinit.settings import (
17from cloudinit import handlers17from cloudinit import handlers
1818
19# Default handlers (used if not overridden)19# Default handlers (used if not overridden)
20from cloudinit.handlers import boot_hook as bh_part20from cloudinit.handlers.boot_hook import BootHookPartHandler
21from cloudinit.handlers import cloud_config as cc_part21from cloudinit.handlers.cloud_config import CloudConfigPartHandler
22from cloudinit.handlers import shell_script as ss_part22from cloudinit.handlers.jinja_template import JinjaTemplatePartHandler
23from cloudinit.handlers import upstart_job as up_part23from cloudinit.handlers.shell_script import ShellScriptPartHandler
24from cloudinit.handlers.upstart_job import UpstartJobPartHandler
2425
25from cloudinit.event import EventType26from cloudinit.event import EventType
2627
@@ -413,12 +414,17 @@ class Init(object):
413 'datasource': self.datasource,414 'datasource': self.datasource,
414 })415 })
415 # TODO(harlowja) Hmmm, should we dynamically import these??416 # TODO(harlowja) Hmmm, should we dynamically import these??
417 cloudconfig_handler = CloudConfigPartHandler(**opts)
418 shellscript_handler = ShellScriptPartHandler(**opts)
416 def_handlers = [419 def_handlers = [
417 cc_part.CloudConfigPartHandler(**opts),420 cloudconfig_handler,
418 ss_part.ShellScriptPartHandler(**opts),421 shellscript_handler,
419 bh_part.BootHookPartHandler(**opts),422 BootHookPartHandler(**opts),
420 up_part.UpstartJobPartHandler(**opts),423 UpstartJobPartHandler(**opts),
421 ]424 ]
425 opts.update(
426 {'sub_handlers': [cloudconfig_handler, shellscript_handler]})
427 def_handlers.append(JinjaTemplatePartHandler(**opts))
422 return def_handlers428 return def_handlers
423429
424 def _default_userdata_handlers(self):430 def _default_userdata_handlers(self):
diff --git a/cloudinit/templater.py b/cloudinit/templater.py
index 7e7acb8..b668674 100644
--- a/cloudinit/templater.py
+++ b/cloudinit/templater.py
@@ -13,6 +13,7 @@
13import collections13import collections
14import re14import re
1515
16
16try:17try:
17 from Cheetah.Template import Template as CTemplate18 from Cheetah.Template import Template as CTemplate
18 CHEETAH_AVAILABLE = True19 CHEETAH_AVAILABLE = True
@@ -20,23 +21,44 @@ except (ImportError, AttributeError):
20 CHEETAH_AVAILABLE = False21 CHEETAH_AVAILABLE = False
2122
22try:23try:
23 import jinja224 from jinja2.runtime import implements_to_string
24 from jinja2 import Template as JTemplate25 from jinja2 import Template as JTemplate
26 from jinja2 import DebugUndefined as JUndefined
25 JINJA_AVAILABLE = True27 JINJA_AVAILABLE = True
26except (ImportError, AttributeError):28except (ImportError, AttributeError):
29 from cloudinit.helpers import identity
30 implements_to_string = identity
27 JINJA_AVAILABLE = False31 JINJA_AVAILABLE = False
32 JUndefined = object
2833
29from cloudinit import log as logging34from cloudinit import log as logging
30from cloudinit import type_utils as tu35from cloudinit import type_utils as tu
31from cloudinit import util36from cloudinit import util
3237
38
33LOG = logging.getLogger(__name__)39LOG = logging.getLogger(__name__)
34TYPE_MATCHER = re.compile(r"##\s*template:(.*)", re.I)40TYPE_MATCHER = re.compile(r"##\s*template:(.*)", re.I)
35BASIC_MATCHER = re.compile(r'\$\{([A-Za-z0-9_.]+)\}|\$([A-Za-z0-9_.]+)')41BASIC_MATCHER = re.compile(r'\$\{([A-Za-z0-9_.]+)\}|\$([A-Za-z0-9_.]+)')
42MISSING_JINJA_PREFIX = u'CI_MISSING_JINJA_VAR/'
43
44
45@implements_to_string # Needed for python2.7. Otherwise cached super.__str__
46class UndefinedJinjaVariable(JUndefined):
47 """Class used to represent any undefined jinja template varible."""
48
49 def __str__(self):
50 return u'%s%s' % (MISSING_JINJA_PREFIX, self._undefined_name)
51
52 def __sub__(self, other):
53 other = str(other).replace(MISSING_JINJA_PREFIX, '')
54 raise TypeError(
55 'Undefined jinja variable: "{this}-{other}". Jinja tried'
56 ' subtraction. Perhaps you meant "{this}_{other}"?'.format(
57 this=self._undefined_name, other=other))
3658
3759
38def basic_render(content, params):60def basic_render(content, params):
39 """This does simple replacement of bash variable like templates.61 """This does sumple replacement of bash variable like templates.
4062
41 It identifies patterns like ${a} or $a and can also identify patterns like63 It identifies patterns like ${a} or $a and can also identify patterns like
42 ${a.b} or $a.b which will look for a key 'b' in the dictionary rooted64 ${a.b} or $a.b which will look for a key 'b' in the dictionary rooted
@@ -82,7 +104,7 @@ def detect_template(text):
82 # keep_trailing_newline is in jinja2 2.7+, not 2.6104 # keep_trailing_newline is in jinja2 2.7+, not 2.6
83 add = "\n" if content.endswith("\n") else ""105 add = "\n" if content.endswith("\n") else ""
84 return JTemplate(content,106 return JTemplate(content,
85 undefined=jinja2.StrictUndefined,107 undefined=UndefinedJinjaVariable,
86 trim_blocks=True).render(**params) + add108 trim_blocks=True).render(**params) + add
87109
88 if text.find("\n") != -1:110 if text.find("\n") != -1:
diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
index 42f56c2..2eb7b0c 100644
--- a/cloudinit/tests/helpers.py
+++ b/cloudinit/tests/helpers.py
@@ -32,6 +32,7 @@ from cloudinit import cloud
32from cloudinit import distros32from cloudinit import distros
33from cloudinit import helpers as ch33from cloudinit import helpers as ch
34from cloudinit.sources import DataSourceNone34from cloudinit.sources import DataSourceNone
35from cloudinit.templater import JINJA_AVAILABLE
35from cloudinit import util36from cloudinit import util
3637
37_real_subp = util.subp38_real_subp = util.subp
@@ -518,6 +519,14 @@ def skipUnlessJsonSchema():
518 _missing_jsonschema_dep, "No python-jsonschema dependency present.")519 _missing_jsonschema_dep, "No python-jsonschema dependency present.")
519520
520521
522def skipUnlessJinja():
523 return skipIf(not JINJA_AVAILABLE, "No jinja dependency present.")
524
525
526def skipIfJinja():
527 return skipIf(JINJA_AVAILABLE, "Jinja dependency present.")
528
529
521# older versions of mock do not have the useful 'assert_not_called'530# older versions of mock do not have the useful 'assert_not_called'
522if not hasattr(mock.Mock, 'assert_not_called'):531if not hasattr(mock.Mock, 'assert_not_called'):
523 def __mock_assert_not_called(mmock):532 def __mock_assert_not_called(mmock):
diff --git a/debian/changelog b/debian/changelog
index 6b1a35f..4ad8877 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,14 @@
1cloud-init (18.3-44-g84bf2482-0ubuntu1) cosmic; urgency=medium
2
3 * New upstream snapshot.
4 - bash_completion/cloud-init: fix shell syntax error.
5 - EphemeralIPv4Network: Be more explicit when adding default route.
6 - OpenStack: support reading of newer versions of metdata.
7 - OpenStack: fix bug causing 'latest' version to be used from network.
8 - user-data: jinja template to render instance-data.json in cloud-config
9
10 -- Ryan Harper <ryan.harper@canonical.com> Fri, 14 Sep 2018 14:06:29 -0500
11
1cloud-init (18.3-39-g757247f9-0ubuntu1) cosmic; urgency=medium12cloud-init (18.3-39-g757247f9-0ubuntu1) cosmic; urgency=medium
213
3 * New upstream snapshot.14 * New upstream snapshot.
diff --git a/doc/rtd/topics/capabilities.rst b/doc/rtd/topics/capabilities.rst
index 3e2c9e3..2d8e253 100644
--- a/doc/rtd/topics/capabilities.rst
+++ b/doc/rtd/topics/capabilities.rst
@@ -16,13 +16,15 @@ User configurability
1616
17`Cloud-init`_ 's behavior can be configured via user-data.17`Cloud-init`_ 's behavior can be configured via user-data.
1818
19 User-data can be given by the user at instance launch time.19 User-data can be given by the user at instance launch time. See
20 :ref:`user_data_formats` for acceptable user-data content.
21
2022
21This is done via the ``--user-data`` or ``--user-data-file`` argument to23This is done via the ``--user-data`` or ``--user-data-file`` argument to
22ec2-run-instances for example.24ec2-run-instances for example.
2325
24* Check your local clients documentation for how to provide a `user-data`26* Check your local client's documentation for how to provide a `user-data`
25 string or `user-data` file for usage by cloud-init on instance creation.27 string or `user-data` file to cloud-init on instance creation.
2628
2729
28Feature detection30Feature detection
@@ -166,6 +168,13 @@ likely be promoted to top-level subcommands when stable.
166 validation is work in progress and supports a subset of cloud-config168 validation is work in progress and supports a subset of cloud-config
167 modules.169 modules.
168170
171 * ``cloud-init devel render``: Use cloud-init's jinja template render to
172 process **#cloud-config** or **custom-scripts**, injecting any variables
173 from ``/run/cloud-init/instance-data.json``. It accepts a user-data file
174 containing the jinja template header ``## template: jinja`` and renders
175 that content with any instance-data.json variables present.
176
177
169.. _cli_clean:178.. _cli_clean:
170179
171cloud-init clean180cloud-init clean
diff --git a/doc/rtd/topics/datasources.rst b/doc/rtd/topics/datasources.rst
index 8303458..14432e6 100644
--- a/doc/rtd/topics/datasources.rst
+++ b/doc/rtd/topics/datasources.rst
@@ -18,6 +18,8 @@ single way to access the different cloud systems methods to provide this data
18through the typical usage of subclasses.18through the typical usage of subclasses.
1919
2020
21.. _instance_metadata:
22
21instance-data23instance-data
22-------------24-------------
23For reference, cloud-init stores all the metadata, vendordata and userdata25For reference, cloud-init stores all the metadata, vendordata and userdata
@@ -110,6 +112,51 @@ Below is an instance-data.json example from an OpenStack instance:
110 }112 }
111 }113 }
112114
115
116As of cloud-init v. 18.4, any values present in
117``/run/cloud-init/instance-data.json`` can be used in cloud-init user data
118scripts or cloud config data. This allows consumers to use cloud-init's
119vendor-neutral, standardized metadata keys as well as datasource-specific
120content for any scripts or cloud-config modules they are using.
121
122To use instance-data.json values in scripts and **#config-config** files the
123user-data will need to contain the following header as the first line **## template: jinja**. Cloud-init will source all variables defined in
124``/run/cloud-init/instance-data.json`` and allow scripts or cloud-config files
125to reference those paths. Below are two examples::
126
127 * Cloud config calling home with the ec2 public hostname and avaliability-zone
128 ```
129 ## template: jinja
130 #cloud-config
131 runcmd:
132 - echo 'EC2 public hostname allocated to instance: {{ ds.meta_data.public_hostname }}' > /tmp/instance_metadata
133 - echo 'EC2 avaiability zone: {{ v1.availability_zone }}' >> /tmp/instance_metadata
134 - curl -X POST -d '{"hostname": "{{ds.meta_data.public_hostname }}", "availability-zone": "{{ v1.availability_zone }}"}' https://example.com.com
135 ```
136
137 * Custom user script performing different operations based on region
138 ```
139 ## template: jinja
140 #!/bin/bash
141 {% if v1.region == 'us-east-2' -%}
142 echo 'Installing custom proxies for {{ v1.region }}
143 sudo apt-get install my-xtra-fast-stack
144 {%- endif %}
145 ...
146
147 ```
148
149.. note::
150 Trying to reference jinja variables that don't exist in
151 instance-data.json will result in warnings in ``/var/log/cloud-init.log``
152 and the following string in your rendered user-data:
153 ``CI_MISSING_JINJA_VAR/<your_varname>``.
154
155.. note::
156 To save time designing your user-data for a specific cloud's
157 instance-data.json, use the 'render' cloud-init command on an
158 instance booted on your favorite cloud. See :ref:`cli_devel` for more
159 information.
113160
114161
115Datasource API162Datasource API
diff --git a/doc/rtd/topics/format.rst b/doc/rtd/topics/format.rst
index 1b0ff36..15234d2 100644
--- a/doc/rtd/topics/format.rst
+++ b/doc/rtd/topics/format.rst
@@ -1,6 +1,8 @@
1*******1.. _user_data_formats:
2Formats2
3*******3*****************
4User-Data Formats
5*****************
46
5User data that will be acted upon by cloud-init must be in one of the following types.7User data that will be acted upon by cloud-init must be in one of the following types.
68
@@ -65,6 +67,11 @@ Typically used by those who just want to execute a shell script.
6567
66Begins with: ``#!`` or ``Content-Type: text/x-shellscript`` when using a MIME archive.68Begins with: ``#!`` or ``Content-Type: text/x-shellscript`` when using a MIME archive.
6769
70.. note::
71 New in cloud-init v. 18.4: User-data scripts can also render cloud instance
72 metadata variables using jinja templating. See
73 :ref:`instance_metadata` for more information.
74
68Example75Example
69-------76-------
7077
@@ -103,12 +110,18 @@ These things include:
103- certain ssh keys should be imported110- certain ssh keys should be imported
104- *and many more...*111- *and many more...*
105112
106**Note:** The file must be valid yaml syntax.113.. note::
114 This file must be valid yaml syntax.
107115
108See the :ref:`yaml_examples` section for a commented set of examples of supported cloud config formats.116See the :ref:`yaml_examples` section for a commented set of examples of supported cloud config formats.
109117
110Begins with: ``#cloud-config`` or ``Content-Type: text/cloud-config`` when using a MIME archive.118Begins with: ``#cloud-config`` or ``Content-Type: text/cloud-config`` when using a MIME archive.
111119
120.. note::
121 New in cloud-init v. 18.4: Cloud config dta can also render cloud instance
122 metadata variables using jinja templating. See
123 :ref:`instance_metadata` for more information.
124
112Upstart Job125Upstart Job
113===========126===========
114127
diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
index 696db8d..2745827 100644
--- a/tests/cloud_tests/testcases/base.py
+++ b/tests/cloud_tests/testcases/base.py
@@ -168,7 +168,7 @@ class CloudTestCase(unittest.TestCase):
168 ' OS: %s not bionic or newer' % self.os_name)168 ' OS: %s not bionic or newer' % self.os_name)
169 instance_data = json.loads(out)169 instance_data = json.loads(out)
170 self.assertEqual(170 self.assertEqual(
171 ['ds/user-data'], instance_data['base64-encoded-keys'])171 ['ds/user_data'], instance_data['base64_encoded_keys'])
172 ds = instance_data.get('ds', {})172 ds = instance_data.get('ds', {})
173 v1_data = instance_data.get('v1', {})173 v1_data = instance_data.get('v1', {})
174 metadata = ds.get('meta-data', {})174 metadata = ds.get('meta-data', {})
@@ -214,8 +214,8 @@ class CloudTestCase(unittest.TestCase):
214 instance_data = json.loads(out)214 instance_data = json.loads(out)
215 v1_data = instance_data.get('v1', {})215 v1_data = instance_data.get('v1', {})
216 self.assertEqual(216 self.assertEqual(
217 ['ds/user-data', 'ds/vendor-data'],217 ['ds/user_data', 'ds/vendor_data'],
218 sorted(instance_data['base64-encoded-keys']))218 sorted(instance_data['base64_encoded_keys']))
219 self.assertEqual('nocloud', v1_data['cloud-name'])219 self.assertEqual('nocloud', v1_data['cloud-name'])
220 self.assertIsNone(220 self.assertIsNone(
221 v1_data['availability-zone'],221 v1_data['availability-zone'],
@@ -249,7 +249,7 @@ class CloudTestCase(unittest.TestCase):
249 instance_data = json.loads(out)249 instance_data = json.loads(out)
250 v1_data = instance_data.get('v1', {})250 v1_data = instance_data.get('v1', {})
251 self.assertEqual(251 self.assertEqual(
252 ['ds/user-data'], instance_data['base64-encoded-keys'])252 ['ds/user_data'], instance_data['base64_encoded_keys'])
253 self.assertEqual('nocloud', v1_data['cloud-name'])253 self.assertEqual('nocloud', v1_data['cloud-name'])
254 self.assertIsNone(254 self.assertIsNone(
255 v1_data['availability-zone'],255 v1_data['availability-zone'],
diff --git a/tests/unittests/test_builtin_handlers.py b/tests/unittests/test_builtin_handlers.py
index 9751ed9..abe820e 100644
--- a/tests/unittests/test_builtin_handlers.py
+++ b/tests/unittests/test_builtin_handlers.py
@@ -2,27 +2,34 @@
22
3"""Tests of the built-in user data handlers."""3"""Tests of the built-in user data handlers."""
44
5import copy
5import os6import os
6import shutil7import shutil
7import tempfile8import tempfile
9from textwrap import dedent
810
9try:
10 from unittest import mock
11except ImportError:
12 import mock
1311
14from cloudinit.tests import helpers as test_helpers12from cloudinit.tests.helpers import (
13 FilesystemMockingTestCase, CiTestCase, mock, skipUnlessJinja)
1514
16from cloudinit import handlers15from cloudinit import handlers
17from cloudinit import helpers16from cloudinit import helpers
18from cloudinit import util17from cloudinit import util
1918
20from cloudinit.handlers import upstart_job19from cloudinit.handlers.cloud_config import CloudConfigPartHandler
20from cloudinit.handlers.jinja_template import (
21 JinjaTemplatePartHandler, convert_jinja_instance_data,
22 render_jinja_payload)
23from cloudinit.handlers.shell_script import ShellScriptPartHandler
24from cloudinit.handlers.upstart_job import UpstartJobPartHandler
2125
22from cloudinit.settings import (PER_ALWAYS, PER_INSTANCE)26from cloudinit.settings import (PER_ALWAYS, PER_INSTANCE)
2327
2428
25class TestBuiltins(test_helpers.FilesystemMockingTestCase):29class TestUpstartJobPartHandler(FilesystemMockingTestCase):
30
31 mpath = 'cloudinit.handlers.upstart_job.'
32
26 def test_upstart_frequency_no_out(self):33 def test_upstart_frequency_no_out(self):
27 c_root = tempfile.mkdtemp()34 c_root = tempfile.mkdtemp()
28 self.addCleanup(shutil.rmtree, c_root)35 self.addCleanup(shutil.rmtree, c_root)
@@ -32,14 +39,13 @@ class TestBuiltins(test_helpers.FilesystemMockingTestCase):
32 'cloud_dir': c_root,39 'cloud_dir': c_root,
33 'upstart_dir': up_root,40 'upstart_dir': up_root,
34 })41 })
35 freq = PER_ALWAYS42 h = UpstartJobPartHandler(paths)
36 h = upstart_job.UpstartJobPartHandler(paths)
37 # No files should be written out when43 # No files should be written out when
38 # the frequency is ! per-instance44 # the frequency is ! per-instance
39 h.handle_part('', handlers.CONTENT_START,45 h.handle_part('', handlers.CONTENT_START,
40 None, None, None)46 None, None, None)
41 h.handle_part('blah', 'text/upstart-job',47 h.handle_part('blah', 'text/upstart-job',
42 'test.conf', 'blah', freq)48 'test.conf', 'blah', frequency=PER_ALWAYS)
43 h.handle_part('', handlers.CONTENT_END,49 h.handle_part('', handlers.CONTENT_END,
44 None, None, None)50 None, None, None)
45 self.assertEqual(0, len(os.listdir(up_root)))51 self.assertEqual(0, len(os.listdir(up_root)))
@@ -48,7 +54,6 @@ class TestBuiltins(test_helpers.FilesystemMockingTestCase):
48 # files should be written out when frequency is ! per-instance54 # files should be written out when frequency is ! per-instance
49 new_root = tempfile.mkdtemp()55 new_root = tempfile.mkdtemp()
50 self.addCleanup(shutil.rmtree, new_root)56 self.addCleanup(shutil.rmtree, new_root)
51 freq = PER_INSTANCE
5257
53 self.patchOS(new_root)58 self.patchOS(new_root)
54 self.patchUtils(new_root)59 self.patchUtils(new_root)
@@ -56,22 +61,297 @@ class TestBuiltins(test_helpers.FilesystemMockingTestCase):
56 'upstart_dir': "/etc/upstart",61 'upstart_dir': "/etc/upstart",
57 })62 })
5863
59 upstart_job.SUITABLE_UPSTART = True
60 util.ensure_dir("/run")64 util.ensure_dir("/run")
61 util.ensure_dir("/etc/upstart")65 util.ensure_dir("/etc/upstart")
6266
63 with mock.patch.object(util, 'subp') as mockobj:67 with mock.patch(self.mpath + 'SUITABLE_UPSTART', return_value=True):
64 h = upstart_job.UpstartJobPartHandler(paths)68 with mock.patch.object(util, 'subp') as m_subp:
65 h.handle_part('', handlers.CONTENT_START,69 h = UpstartJobPartHandler(paths)
66 None, None, None)70 h.handle_part('', handlers.CONTENT_START,
67 h.handle_part('blah', 'text/upstart-job',71 None, None, None)
68 'test.conf', 'blah', freq)72 h.handle_part('blah', 'text/upstart-job',
69 h.handle_part('', handlers.CONTENT_END,73 'test.conf', 'blah', frequency=PER_INSTANCE)
70 None, None, None)74 h.handle_part('', handlers.CONTENT_END,
75 None, None, None)
7176
72 self.assertEqual(len(os.listdir('/etc/upstart')), 1)77 self.assertEqual(len(os.listdir('/etc/upstart')), 1)
7378
74 mockobj.assert_called_once_with(79 m_subp.assert_called_once_with(
75 ['initctl', 'reload-configuration'], capture=False)80 ['initctl', 'reload-configuration'], capture=False)
7681
82
83class TestJinjaTemplatePartHandler(CiTestCase):
84
85 with_logs = True
86
87 mpath = 'cloudinit.handlers.jinja_template.'
88
89 def setUp(self):
90 super(TestJinjaTemplatePartHandler, self).setUp()
91 self.tmp = self.tmp_dir()
92 self.run_dir = os.path.join(self.tmp, 'run_dir')
93 util.ensure_dir(self.run_dir)
94 self.paths = helpers.Paths({
95 'cloud_dir': self.tmp, 'run_dir': self.run_dir})
96
97 def test_jinja_template_part_handler_defaults(self):
98 """On init, paths are saved and subhandler types are empty."""
99 h = JinjaTemplatePartHandler(self.paths)
100 self.assertEqual(['## template: jinja'], h.prefixes)
101 self.assertEqual(3, h.handler_version)
102 self.assertEqual(self.paths, h.paths)
103 self.assertEqual({}, h.sub_handlers)
104
105 def test_jinja_template_part_handler_looks_up_sub_handler_types(self):
106 """When sub_handlers are passed, init lists types of subhandlers."""
107 script_handler = ShellScriptPartHandler(self.paths)
108 cloudconfig_handler = CloudConfigPartHandler(self.paths)
109 h = JinjaTemplatePartHandler(
110 self.paths, sub_handlers=[script_handler, cloudconfig_handler])
111 self.assertItemsEqual(
112 ['text/cloud-config', 'text/cloud-config-jsonp',
113 'text/x-shellscript'],
114 h.sub_handlers)
115
116 def test_jinja_template_part_handler_looks_up_subhandler_types(self):
117 """When sub_handlers are passed, init lists types of subhandlers."""
118 script_handler = ShellScriptPartHandler(self.paths)
119 cloudconfig_handler = CloudConfigPartHandler(self.paths)
120 h = JinjaTemplatePartHandler(
121 self.paths, sub_handlers=[script_handler, cloudconfig_handler])
122 self.assertItemsEqual(
123 ['text/cloud-config', 'text/cloud-config-jsonp',
124 'text/x-shellscript'],
125 h.sub_handlers)
126
127 def test_jinja_template_handle_noop_on_content_signals(self):
128 """Perform no part handling when content type is CONTENT_SIGNALS."""
129 script_handler = ShellScriptPartHandler(self.paths)
130
131 h = JinjaTemplatePartHandler(
132 self.paths, sub_handlers=[script_handler])
133 with mock.patch.object(script_handler, 'handle_part') as m_handle_part:
134 h.handle_part(
135 data='data', ctype=handlers.CONTENT_START, filename='part-1',
136 payload='## template: jinja\n#!/bin/bash\necho himom',
137 frequency='freq', headers='headers')
138 m_handle_part.assert_not_called()
139
140 @skipUnlessJinja()
141 def test_jinja_template_handle_subhandler_v2_with_clean_payload(self):
142 """Call version 2 subhandler.handle_part with stripped payload."""
143 script_handler = ShellScriptPartHandler(self.paths)
144 self.assertEqual(2, script_handler.handler_version)
145
146 # Create required instance-data.json file
147 instance_json = os.path.join(self.run_dir, 'instance-data.json')
148 instance_data = {'topkey': 'echo himom'}
149 util.write_file(instance_json, util.json_dumps(instance_data))
150 h = JinjaTemplatePartHandler(
151 self.paths, sub_handlers=[script_handler])
152 with mock.patch.object(script_handler, 'handle_part') as m_part:
153 # ctype with leading '!' not in handlers.CONTENT_SIGNALS
154 h.handle_part(
155 data='data', ctype="!" + handlers.CONTENT_START,
156 filename='part01',
157 payload='## template: jinja \t \n#!/bin/bash\n{{ topkey }}',
158 frequency='freq', headers='headers')
159 m_part.assert_called_once_with(
160 'data', '!__begin__', 'part01', '#!/bin/bash\necho himom', 'freq')
161
162 @skipUnlessJinja()
163 def test_jinja_template_handle_subhandler_v3_with_clean_payload(self):
164 """Call version 3 subhandler.handle_part with stripped payload."""
165 cloudcfg_handler = CloudConfigPartHandler(self.paths)
166 self.assertEqual(3, cloudcfg_handler.handler_version)
167
168 # Create required instance-data.json file
169 instance_json = os.path.join(self.run_dir, 'instance-data.json')
170 instance_data = {'topkey': {'sub': 'runcmd: [echo hi]'}}
171 util.write_file(instance_json, util.json_dumps(instance_data))
172 h = JinjaTemplatePartHandler(
173 self.paths, sub_handlers=[cloudcfg_handler])
174 with mock.patch.object(cloudcfg_handler, 'handle_part') as m_part:
175 # ctype with leading '!' not in handlers.CONTENT_SIGNALS
176 h.handle_part(
177 data='data', ctype="!" + handlers.CONTENT_END,
178 filename='part01',
179 payload='## template: jinja\n#cloud-config\n{{ topkey.sub }}',
180 frequency='freq', headers='headers')
181 m_part.assert_called_once_with(
182 'data', '!__end__', 'part01', '#cloud-config\nruncmd: [echo hi]',
183 'freq', 'headers')
184
185 def test_jinja_template_handle_errors_on_missing_instance_data_json(self):
186 """If instance-data is absent, raise an error from handle_part."""
187 script_handler = ShellScriptPartHandler(self.paths)
188 h = JinjaTemplatePartHandler(
189 self.paths, sub_handlers=[script_handler])
190 with self.assertRaises(RuntimeError) as context_manager:
191 h.handle_part(
192 data='data', ctype="!" + handlers.CONTENT_START,
193 filename='part01',
194 payload='## template: jinja \n#!/bin/bash\necho himom',
195 frequency='freq', headers='headers')
196 script_file = os.path.join(script_handler.script_dir, 'part01')
197 self.assertEqual(
198 'Cannot render jinja template vars. Instance data not yet present'
199 ' at {}/instance-data.json'.format(
200 self.run_dir), str(context_manager.exception))
201 self.assertFalse(
202 os.path.exists(script_file),
203 'Unexpected file created %s' % script_file)
204
205 @skipUnlessJinja()
206 def test_jinja_template_handle_renders_jinja_content(self):
207 """When present, render jinja variables from instance-data.json."""
208 script_handler = ShellScriptPartHandler(self.paths)
209 instance_json = os.path.join(self.run_dir, 'instance-data.json')
210 instance_data = {'topkey': {'subkey': 'echo himom'}}
211 util.write_file(instance_json, util.json_dumps(instance_data))
212 h = JinjaTemplatePartHandler(
213 self.paths, sub_handlers=[script_handler])
214 h.handle_part(
215 data='data', ctype="!" + handlers.CONTENT_START,
216 filename='part01',
217 payload=(
218 '## template: jinja \n'
219 '#!/bin/bash\n'
220 '{{ topkey.subkey|default("nosubkey") }}'),
221 frequency='freq', headers='headers')
222 script_file = os.path.join(script_handler.script_dir, 'part01')
223 self.assertNotIn(
224 'Instance data not yet present at {}/instance-data.json'.format(
225 self.run_dir),
226 self.logs.getvalue())
227 self.assertEqual(
228 '#!/bin/bash\necho himom', util.load_file(script_file))
229
230 @skipUnlessJinja()
231 def test_jinja_template_handle_renders_jinja_content_missing_keys(self):
232 """When specified jinja variable is undefined, log a warning."""
233 script_handler = ShellScriptPartHandler(self.paths)
234 instance_json = os.path.join(self.run_dir, 'instance-data.json')
235 instance_data = {'topkey': {'subkey': 'echo himom'}}
236 util.write_file(instance_json, util.json_dumps(instance_data))
237 h = JinjaTemplatePartHandler(
238 self.paths, sub_handlers=[script_handler])
239 h.handle_part(
240 data='data', ctype="!" + handlers.CONTENT_START,
241 filename='part01',
242 payload='## template: jinja \n#!/bin/bash\n{{ goodtry }}',
243 frequency='freq', headers='headers')
244 script_file = os.path.join(script_handler.script_dir, 'part01')
245 self.assertTrue(
246 os.path.exists(script_file),
247 'Missing expected file %s' % script_file)
248 self.assertIn(
249 "WARNING: Could not render jinja template variables in file"
250 " 'part01': 'goodtry'\n",
251 self.logs.getvalue())
252
253
254class TestConvertJinjaInstanceData(CiTestCase):
255
256 def test_convert_instance_data_hyphens_to_underscores(self):
257 """Replace hyphenated keys with underscores in instance-data."""
258 data = {'hyphenated-key': 'hyphenated-val',
259 'underscore_delim_key': 'underscore_delimited_val'}
260 expected_data = {'hyphenated_key': 'hyphenated-val',
261 'underscore_delim_key': 'underscore_delimited_val'}
262 self.assertEqual(
263 expected_data,
264 convert_jinja_instance_data(data=data))
265
266 def test_convert_instance_data_promotes_versioned_keys_to_top_level(self):
267 """Any versioned keys are promoted as top-level keys
268
269 This provides any cloud-init standardized keys up at a top-level to
270 allow ease of reference for users. Intsead of v1.availability_zone,
271 the name availability_zone can be used in templates.
272 """
273 data = {'ds': {'dskey1': 1, 'dskey2': 2},
274 'v1': {'v1key1': 'v1.1'},
275 'v2': {'v2key1': 'v2.1'}}
276 expected_data = copy.deepcopy(data)
277 expected_data.update({'v1key1': 'v1.1', 'v2key1': 'v2.1'})
278
279 converted_data = convert_jinja_instance_data(data=data)
280 self.assertItemsEqual(
281 ['ds', 'v1', 'v2', 'v1key1', 'v2key1'], converted_data.keys())
282 self.assertEqual(
283 expected_data,
284 converted_data)
285
286 def test_convert_instance_data_most_recent_version_of_promoted_keys(self):
287 """The most-recent versioned key value is promoted to top-level."""
288 data = {'v1': {'key1': 'old v1 key1', 'key2': 'old v1 key2'},
289 'v2': {'key1': 'newer v2 key1', 'key3': 'newer v2 key3'},
290 'v3': {'key1': 'newest v3 key1'}}
291 expected_data = copy.deepcopy(data)
292 expected_data.update(
293 {'key1': 'newest v3 key1', 'key2': 'old v1 key2',
294 'key3': 'newer v2 key3'})
295
296 converted_data = convert_jinja_instance_data(data=data)
297 self.assertEqual(
298 expected_data,
299 converted_data)
300
301 def test_convert_instance_data_decodes_decode_paths(self):
302 """Any decode_paths provided are decoded by convert_instance_data."""
303 data = {'key1': {'subkey1': 'aGkgbW9t'}, 'key2': 'aGkgZGFk'}
304 expected_data = copy.deepcopy(data)
305 expected_data['key1']['subkey1'] = 'hi mom'
306
307 converted_data = convert_jinja_instance_data(
308 data=data, decode_paths=('key1/subkey1',))
309 self.assertEqual(
310 expected_data,
311 converted_data)
312
313
314class TestRenderJinjaPayload(CiTestCase):
315
316 with_logs = True
317
318 @skipUnlessJinja()
319 def test_render_jinja_payload_logs_jinja_vars_on_debug(self):
320 """When debug is True, log jinja varables available."""
321 payload = (
322 '## template: jinja\n#!/bin/sh\necho hi from {{ v1.hostname }}')
323 instance_data = {'v1': {'hostname': 'foo'}, 'instance-id': 'iid'}
324 expected_log = dedent("""\
325 DEBUG: Converted jinja variables
326 {
327 "hostname": "foo",
328 "instance_id": "iid",
329 "v1": {
330 "hostname": "foo"
331 }
332 }
333 """)
334 self.assertEqual(
335 render_jinja_payload(
336 payload=payload, payload_fn='myfile',
337 instance_data=instance_data, debug=True),
338 '#!/bin/sh\necho hi from foo')
339 self.assertEqual(expected_log, self.logs.getvalue())
340
341 @skipUnlessJinja()
342 def test_render_jinja_payload_replaces_missing_variables_and_warns(self):
343 """Warn on missing jinja variables and replace the absent variable."""
344 payload = (
345 '## template: jinja\n#!/bin/sh\necho hi from {{ NOTHERE }}')
346 instance_data = {'v1': {'hostname': 'foo'}, 'instance-id': 'iid'}
347 self.assertEqual(
348 render_jinja_payload(
349 payload=payload, payload_fn='myfile',
350 instance_data=instance_data),
351 '#!/bin/sh\necho hi from CI_MISSING_JINJA_VAR/NOTHERE')
352 expected_log = (
353 'WARNING: Could not render jinja template variables in file'
354 " 'myfile': 'NOTHERE'")
355 self.assertIn(expected_log, self.logs.getvalue())
356
77# vi: ts=4 expandtab357# vi: ts=4 expandtab
diff --git a/tests/unittests/test_datasource/test_openstack.py b/tests/unittests/test_datasource/test_openstack.py
index 6e1e971..a731f1e 100644
--- a/tests/unittests/test_datasource/test_openstack.py
+++ b/tests/unittests/test_datasource/test_openstack.py
@@ -12,7 +12,7 @@ import re
12from cloudinit.tests import helpers as test_helpers12from cloudinit.tests import helpers as test_helpers
1313
14from six.moves.urllib.parse import urlparse14from six.moves.urllib.parse import urlparse
15from six import StringIO15from six import StringIO, text_type
1616
17from cloudinit import helpers17from cloudinit import helpers
18from cloudinit import settings18from cloudinit import settings
@@ -555,4 +555,94 @@ class TestDetectOpenStack(test_helpers.CiTestCase):
555 m_proc_env.assert_called_with(1)555 m_proc_env.assert_called_with(1)
556556
557557
558class TestMetadataReader(test_helpers.HttprettyTestCase):
559 """Test the MetadataReader."""
560 burl = 'http://169.254.169.254/'
561 md_base = {
562 'availability_zone': 'myaz1',
563 'hostname': 'sm-foo-test.novalocal',
564 "keys": [{"data": PUBKEY, "name": "brickies", "type": "ssh"}],
565 'launch_index': 0,
566 'name': 'sm-foo-test',
567 'public_keys': {'mykey': PUBKEY},
568 'project_id': '6a103f813b774b9fb15a4fcd36e1c056',
569 'uuid': 'b0fa911b-69d4-4476-bbe2-1c92bff6535c'}
570
571 def register(self, path, body=None, status=200):
572 content = (body if not isinstance(body, text_type)
573 else body.encode('utf-8'))
574 hp.register_uri(
575 hp.GET, self.burl + "openstack" + path, status=status,
576 body=content)
577
578 def register_versions(self, versions):
579 self.register("", '\n'.join(versions))
580 self.register("/", '\n'.join(versions))
581
582 def register_version(self, version, data):
583 content = '\n'.join(sorted(data.keys()))
584 self.register(version, content)
585 self.register(version + "/", content)
586 for path, content in data.items():
587 self.register("/%s/%s" % (version, path), content)
588 self.register("/%s/%s" % (version, path), content)
589 if 'user_data' not in data:
590 self.register("/%s/user_data" % version, "nodata", status=404)
591
592 def test__find_working_version(self):
593 """Test a working version ignores unsupported."""
594 unsup = "2016-11-09"
595 self.register_versions(
596 [openstack.OS_FOLSOM, openstack.OS_LIBERTY, unsup,
597 openstack.OS_LATEST])
598 self.assertEqual(
599 openstack.OS_LIBERTY,
600 openstack.MetadataReader(self.burl)._find_working_version())
601
602 def test__find_working_version_uses_latest(self):
603 """'latest' should be used if no supported versions."""
604 unsup1, unsup2 = ("2016-11-09", '2017-06-06')
605 self.register_versions([unsup1, unsup2, openstack.OS_LATEST])
606 self.assertEqual(
607 openstack.OS_LATEST,
608 openstack.MetadataReader(self.burl)._find_working_version())
609
610 def test_read_v2_os_ocata(self):
611 """Validate return value of read_v2 for os_ocata data."""
612 md = copy.deepcopy(self.md_base)
613 md['devices'] = []
614 network_data = {'links': [], 'networks': [], 'services': []}
615 vendor_data = {}
616 vendor_data2 = {"static": {}}
617
618 data = {
619 'meta_data.json': json.dumps(md),
620 'network_data.json': json.dumps(network_data),
621 'vendor_data.json': json.dumps(vendor_data),
622 'vendor_data2.json': json.dumps(vendor_data2),
623 }
624
625 self.register_versions([openstack.OS_OCATA, openstack.OS_LATEST])
626 self.register_version(openstack.OS_OCATA, data)
627
628 mock_read_ec2 = test_helpers.mock.MagicMock(
629 return_value={'instance-id': 'unused-ec2'})
630 expected_md = copy.deepcopy(md)
631 expected_md.update(
632 {'instance-id': md['uuid'], 'local-hostname': md['hostname']})
633 expected = {
634 'userdata': '', # Annoying, no user-data results in empty string.
635 'version': 2,
636 'metadata': expected_md,
637 'vendordata': vendor_data,
638 'networkdata': network_data,
639 'ec2-metadata': mock_read_ec2.return_value,
640 'files': {},
641 }
642 reader = openstack.MetadataReader(self.burl)
643 reader._read_ec2_metadata = mock_read_ec2
644 self.assertEqual(expected, reader.read_v2())
645 self.assertEqual(1, mock_read_ec2.call_count)
646
647
558# vi: ts=4 expandtab648# vi: ts=4 expandtab
diff --git a/tests/unittests/test_handler/test_handler_etc_hosts.py b/tests/unittests/test_handler/test_handler_etc_hosts.py
index ced05a8..d854afc 100644
--- a/tests/unittests/test_handler/test_handler_etc_hosts.py
+++ b/tests/unittests/test_handler/test_handler_etc_hosts.py
@@ -49,6 +49,7 @@ class TestHostsFile(t_help.FilesystemMockingTestCase):
49 if '192.168.1.1\tblah.blah.us\tblah' not in contents:49 if '192.168.1.1\tblah.blah.us\tblah' not in contents:
50 self.assertIsNone('Default etc/hosts content modified')50 self.assertIsNone('Default etc/hosts content modified')
5151
52 @t_help.skipUnlessJinja()
52 def test_write_etc_hosts_suse_template(self):53 def test_write_etc_hosts_suse_template(self):
53 cfg = {54 cfg = {
54 'manage_etc_hosts': 'template',55 'manage_etc_hosts': 'template',
diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
index 6fe3659..0f22e57 100644
--- a/tests/unittests/test_handler/test_handler_ntp.py
+++ b/tests/unittests/test_handler/test_handler_ntp.py
@@ -3,6 +3,7 @@
3from cloudinit.config import cc_ntp3from cloudinit.config import cc_ntp
4from cloudinit.sources import DataSourceNone4from cloudinit.sources import DataSourceNone
5from cloudinit import (distros, helpers, cloud, util)5from cloudinit import (distros, helpers, cloud, util)
6
6from cloudinit.tests.helpers import (7from cloudinit.tests.helpers import (
7 CiTestCase, FilesystemMockingTestCase, mock, skipUnlessJsonSchema)8 CiTestCase, FilesystemMockingTestCase, mock, skipUnlessJsonSchema)
89
diff --git a/tests/unittests/test_templating.py b/tests/unittests/test_templating.py
index 20c87ef..c36e6eb 100644
--- a/tests/unittests/test_templating.py
+++ b/tests/unittests/test_templating.py
@@ -21,6 +21,9 @@ except ImportError:
2121
2222
23class TestTemplates(test_helpers.CiTestCase):23class TestTemplates(test_helpers.CiTestCase):
24
25 with_logs = True
26
24 jinja_utf8 = b'It\xe2\x80\x99s not ascii, {{name}}\n'27 jinja_utf8 = b'It\xe2\x80\x99s not ascii, {{name}}\n'
25 jinja_utf8_rbob = b'It\xe2\x80\x99s not ascii, bob\n'.decode('utf-8')28 jinja_utf8_rbob = b'It\xe2\x80\x99s not ascii, bob\n'.decode('utf-8')
2629
@@ -124,6 +127,13 @@ $a,$b'''
124 self.add_header("jinja", self.jinja_utf8), {"name": "bob"}),127 self.add_header("jinja", self.jinja_utf8), {"name": "bob"}),
125 self.jinja_utf8_rbob)128 self.jinja_utf8_rbob)
126129
130 def test_jinja_nonascii_render_undefined_variables_to_default_py3(self):
131 """Test py3 jinja render_to_string with undefined variable default."""
132 self.assertEqual(
133 templater.render_string(
134 self.add_header("jinja", self.jinja_utf8), {}),
135 self.jinja_utf8_rbob.replace('bob', 'CI_MISSING_JINJA_VAR/name'))
136
127 def test_jinja_nonascii_render_to_file(self):137 def test_jinja_nonascii_render_to_file(self):
128 """Test jinja render_to_file of a filename with non-ascii content."""138 """Test jinja render_to_file of a filename with non-ascii content."""
129 tmpl_fn = self.tmp_path("j-render-to-file.template")139 tmpl_fn = self.tmp_path("j-render-to-file.template")
@@ -144,5 +154,18 @@ $a,$b'''
144 result = templater.render_from_file(tmpl_fn, {"name": "bob"})154 result = templater.render_from_file(tmpl_fn, {"name": "bob"})
145 self.assertEqual(result, self.jinja_utf8_rbob)155 self.assertEqual(result, self.jinja_utf8_rbob)
146156
157 @test_helpers.skipIfJinja()
158 def test_jinja_warns_on_missing_dep_and_uses_basic_renderer(self):
159 """Test jinja render_from_file will fallback to basic renderer."""
160 tmpl_fn = self.tmp_path("j-render-from-file.template")
161 write_file(tmpl_fn, omode="wb",
162 content=self.add_header(
163 "jinja", self.jinja_utf8).encode('utf-8'))
164 result = templater.render_from_file(tmpl_fn, {"name": "bob"})
165 self.assertEqual(result, self.jinja_utf8.decode())
166 self.assertIn(
167 'WARNING: Jinja not available as the selected renderer for desired'
168 ' template, reverting to the basic renderer.',
169 self.logs.getvalue())
147170
148# vi: ts=4 expandtab171# vi: ts=4 expandtab

Subscribers

People subscribed via source and target branches