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

PASSED: Continuous integration, rev:d418088c4cb 2176ee4e1e73e3a 3aa7f91e8436e1 /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 320/
https:/
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: /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 320/rebuild
https:/