Merge ~smoser/cloud-init:bug/noise-on-cmdline-url-fail into cloud-init:master
- Git
- lp:~smoser/cloud-init
- bug/noise-on-cmdline-url-fail
- Merge into master
Proposed by
Scott Moser
Status: | Merged |
---|---|
Merged at revision: | a1b185d0cce5064e9b36b4db7b55564e2ab1d7a8 |
Proposed branch: | ~smoser/cloud-init:bug/noise-on-cmdline-url-fail |
Merge into: | cloud-init:master |
Diff against target: |
377 lines (+173/-97) 4 files modified
cloudinit/cmd/main.py (+103/-15) cloudinit/util.py (+0/-44) tests/unittests/helpers.py (+11/-5) tests/unittests/test__init__.py (+59/-33) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
cloud-init Commiters | Pending | ||
Review via email:
|
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Ryan Harper (raharper) : | # |
Revision history for this message
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py | |||
2 | index c83496c..65b15ed 100644 | |||
3 | --- a/cloudinit/cmd/main.py | |||
4 | +++ b/cloudinit/cmd/main.py | |||
5 | @@ -26,6 +26,7 @@ from cloudinit import signal_handler | |||
6 | 26 | from cloudinit import sources | 26 | from cloudinit import sources |
7 | 27 | from cloudinit import stages | 27 | from cloudinit import stages |
8 | 28 | from cloudinit import templater | 28 | from cloudinit import templater |
9 | 29 | from cloudinit import url_helper | ||
10 | 29 | from cloudinit import util | 30 | from cloudinit import util |
11 | 30 | from cloudinit import version | 31 | from cloudinit import version |
12 | 31 | 32 | ||
13 | @@ -129,23 +130,104 @@ def apply_reporting_cfg(cfg): | |||
14 | 129 | reporting.update_configuration(cfg.get('reporting')) | 130 | reporting.update_configuration(cfg.get('reporting')) |
15 | 130 | 131 | ||
16 | 131 | 132 | ||
17 | 133 | def parse_cmdline_url(cmdline, names=('cloud-config-url', 'url')): | ||
18 | 134 | data = util.keyval_str_to_dict(cmdline) | ||
19 | 135 | for key in names: | ||
20 | 136 | if key in data: | ||
21 | 137 | return key, data[key] | ||
22 | 138 | raise KeyError("No keys (%s) found in string '%s'" % | ||
23 | 139 | (cmdline, names)) | ||
24 | 140 | |||
25 | 141 | |||
26 | 142 | def attempt_cmdline_url(path, network=True, cmdline=None): | ||
27 | 143 | """Write data from url referenced in command line to path. | ||
28 | 144 | |||
29 | 145 | path: a file to write content to if downloaded. | ||
30 | 146 | network: should network access be assumed. | ||
31 | 147 | cmdline: the cmdline to parse for cloud-config-url. | ||
32 | 148 | |||
33 | 149 | This is used in MAAS datasource, in "ephemeral" (read-only root) | ||
34 | 150 | environment where the instance netboots to iscsi ro root. | ||
35 | 151 | and the entity that controls the pxe config has to configure | ||
36 | 152 | the maas datasource. | ||
37 | 153 | |||
38 | 154 | An attempt is made on network urls even in local datasource | ||
39 | 155 | for case of network set up in initramfs. | ||
40 | 156 | |||
41 | 157 | Return value is a tuple of a logger function (logging.DEBUG) | ||
42 | 158 | and a message indicating what happened. | ||
43 | 159 | """ | ||
44 | 160 | |||
45 | 161 | if cmdline is None: | ||
46 | 162 | cmdline = util.get_cmdline() | ||
47 | 163 | |||
48 | 164 | try: | ||
49 | 165 | cmdline_name, url = parse_cmdline_url(cmdline) | ||
50 | 166 | except KeyError: | ||
51 | 167 | return (logging.DEBUG, "No kernel command line url found.") | ||
52 | 168 | |||
53 | 169 | path_is_local = url.startswith("file://") or url.startswith("/") | ||
54 | 170 | |||
55 | 171 | if path_is_local and os.path.exists(path): | ||
56 | 172 | if network: | ||
57 | 173 | m = ("file '%s' existed, possibly from local stage download" | ||
58 | 174 | " of command line url '%s'. Not re-writing." % (path, url)) | ||
59 | 175 | level = logging.INFO | ||
60 | 176 | if path_is_local: | ||
61 | 177 | level = logging.DEBUG | ||
62 | 178 | else: | ||
63 | 179 | m = ("file '%s' existed, possibly from previous boot download" | ||
64 | 180 | " of command line url '%s'. Not re-writing." % (path, url)) | ||
65 | 181 | level = logging.WARN | ||
66 | 182 | |||
67 | 183 | return (level, m) | ||
68 | 184 | |||
69 | 185 | kwargs = {'url': url, 'timeout': 10, 'retries': 2} | ||
70 | 186 | if network or path_is_local: | ||
71 | 187 | level = logging.WARN | ||
72 | 188 | kwargs['sec_between'] = 1 | ||
73 | 189 | else: | ||
74 | 190 | level = logging.DEBUG | ||
75 | 191 | kwargs['sec_between'] = .1 | ||
76 | 192 | |||
77 | 193 | data = None | ||
78 | 194 | header = b'#cloud-config' | ||
79 | 195 | try: | ||
80 | 196 | resp = util.read_file_or_url(**kwargs) | ||
81 | 197 | if resp.ok(): | ||
82 | 198 | data = resp.contents | ||
83 | 199 | if not resp.contents.startswith(header): | ||
84 | 200 | if cmdline_name == 'cloud-config-url': | ||
85 | 201 | level = logging.WARN | ||
86 | 202 | else: | ||
87 | 203 | level = logging.INFO | ||
88 | 204 | return ( | ||
89 | 205 | level, | ||
90 | 206 | "contents of '%s' did not start with %s" % (url, header)) | ||
91 | 207 | else: | ||
92 | 208 | return (level, | ||
93 | 209 | "url '%s' returned code %s. Ignoring." % (url, resp.code)) | ||
94 | 210 | |||
95 | 211 | except url_helper.UrlError as e: | ||
96 | 212 | return (level, "retrieving url '%s' failed: %s" % (url, e)) | ||
97 | 213 | |||
98 | 214 | util.write_file(path, data, mode=0o600) | ||
99 | 215 | return (logging.INFO, | ||
100 | 216 | "wrote cloud-config data from %s='%s' to %s" % | ||
101 | 217 | (cmdline_name, url, path)) | ||
102 | 218 | |||
103 | 219 | |||
104 | 132 | def main_init(name, args): | 220 | def main_init(name, args): |
105 | 133 | deps = [sources.DEP_FILESYSTEM, sources.DEP_NETWORK] | 221 | deps = [sources.DEP_FILESYSTEM, sources.DEP_NETWORK] |
106 | 134 | if args.local: | 222 | if args.local: |
107 | 135 | deps = [sources.DEP_FILESYSTEM] | 223 | deps = [sources.DEP_FILESYSTEM] |
108 | 136 | 224 | ||
121 | 137 | if not args.local: | 225 | early_logs = [] |
122 | 138 | # See doc/kernel-cmdline.txt | 226 | early_logs.append( |
123 | 139 | # | 227 | attempt_cmdline_url( |
124 | 140 | # This is used in maas datasource, in "ephemeral" (read-only root) | 228 | path=os.path.join("%s.d" % CLOUD_CONFIG, |
125 | 141 | # environment where the instance netboots to iscsi ro root. | 229 | "91_kernel_cmdline_url.cfg"), |
126 | 142 | # and the entity that controls the pxe config has to configure | 230 | network=not args.local)) |
115 | 143 | # the maas datasource. | ||
116 | 144 | # | ||
117 | 145 | # Could be used elsewhere, only works on network based (not local). | ||
118 | 146 | root_name = "%s.d" % (CLOUD_CONFIG) | ||
119 | 147 | target_fn = os.path.join(root_name, "91_kernel_cmdline_url.cfg") | ||
120 | 148 | util.read_write_cmdline_url(target_fn) | ||
127 | 149 | 231 | ||
128 | 150 | # Cloud-init 'init' stage is broken up into the following sub-stages | 232 | # Cloud-init 'init' stage is broken up into the following sub-stages |
129 | 151 | # 1. Ensure that the init object fetches its config without errors | 233 | # 1. Ensure that the init object fetches its config without errors |
130 | @@ -171,12 +253,14 @@ def main_init(name, args): | |||
131 | 171 | outfmt = None | 253 | outfmt = None |
132 | 172 | errfmt = None | 254 | errfmt = None |
133 | 173 | try: | 255 | try: |
135 | 174 | LOG.debug("Closing stdin") | 256 | early_logs.append((logging.DEBUG, "Closing stdin.")) |
136 | 175 | util.close_stdin() | 257 | util.close_stdin() |
137 | 176 | (outfmt, errfmt) = util.fixup_output(init.cfg, name) | 258 | (outfmt, errfmt) = util.fixup_output(init.cfg, name) |
138 | 177 | except Exception: | 259 | except Exception: |
141 | 178 | util.logexc(LOG, "Failed to setup output redirection!") | 260 | msg = "Failed to setup output redirection!" |
142 | 179 | print_exc("Failed to setup output redirection!") | 261 | util.logexc(LOG, msg) |
143 | 262 | print_exc(msg) | ||
144 | 263 | early_logs.append((logging.WARN, msg)) | ||
145 | 180 | if args.debug: | 264 | if args.debug: |
146 | 181 | # Reset so that all the debug handlers are closed out | 265 | # Reset so that all the debug handlers are closed out |
147 | 182 | LOG.debug(("Logging being reset, this logger may no" | 266 | LOG.debug(("Logging being reset, this logger may no" |
148 | @@ -190,6 +274,10 @@ def main_init(name, args): | |||
149 | 190 | # been redirected and log now configured. | 274 | # been redirected and log now configured. |
150 | 191 | welcome(name, msg=w_msg) | 275 | welcome(name, msg=w_msg) |
151 | 192 | 276 | ||
152 | 277 | # re-play early log messages before logging was setup | ||
153 | 278 | for lvl, msg in early_logs: | ||
154 | 279 | LOG.log(lvl, msg) | ||
155 | 280 | |||
156 | 193 | # Stage 3 | 281 | # Stage 3 |
157 | 194 | try: | 282 | try: |
158 | 195 | init.initialize() | 283 | init.initialize() |
159 | diff --git a/cloudinit/util.py b/cloudinit/util.py | |||
160 | index 5725129..7196a7c 100644 | |||
161 | --- a/cloudinit/util.py | |||
162 | +++ b/cloudinit/util.py | |||
163 | @@ -1089,31 +1089,6 @@ def get_fqdn_from_hosts(hostname, filename="/etc/hosts"): | |||
164 | 1089 | return fqdn | 1089 | return fqdn |
165 | 1090 | 1090 | ||
166 | 1091 | 1091 | ||
167 | 1092 | def get_cmdline_url(names=('cloud-config-url', 'url'), | ||
168 | 1093 | starts=b"#cloud-config", cmdline=None): | ||
169 | 1094 | if cmdline is None: | ||
170 | 1095 | cmdline = get_cmdline() | ||
171 | 1096 | |||
172 | 1097 | data = keyval_str_to_dict(cmdline) | ||
173 | 1098 | url = None | ||
174 | 1099 | key = None | ||
175 | 1100 | for key in names: | ||
176 | 1101 | if key in data: | ||
177 | 1102 | url = data[key] | ||
178 | 1103 | break | ||
179 | 1104 | |||
180 | 1105 | if not url: | ||
181 | 1106 | return (None, None, None) | ||
182 | 1107 | |||
183 | 1108 | resp = read_file_or_url(url) | ||
184 | 1109 | # allow callers to pass starts as text when comparing to bytes contents | ||
185 | 1110 | starts = encode_text(starts) | ||
186 | 1111 | if resp.ok() and resp.contents.startswith(starts): | ||
187 | 1112 | return (key, url, resp.contents) | ||
188 | 1113 | |||
189 | 1114 | return (key, url, None) | ||
190 | 1115 | |||
191 | 1116 | |||
192 | 1117 | def is_resolvable(name): | 1092 | def is_resolvable(name): |
193 | 1118 | """determine if a url is resolvable, return a boolean | 1093 | """determine if a url is resolvable, return a boolean |
194 | 1119 | This also attempts to be resilent against dns redirection. | 1094 | This also attempts to be resilent against dns redirection. |
195 | @@ -1475,25 +1450,6 @@ def ensure_dirs(dirlist, mode=0o755): | |||
196 | 1475 | ensure_dir(d, mode) | 1450 | ensure_dir(d, mode) |
197 | 1476 | 1451 | ||
198 | 1477 | 1452 | ||
199 | 1478 | def read_write_cmdline_url(target_fn): | ||
200 | 1479 | if not os.path.exists(target_fn): | ||
201 | 1480 | try: | ||
202 | 1481 | (key, url, content) = get_cmdline_url() | ||
203 | 1482 | except Exception: | ||
204 | 1483 | logexc(LOG, "Failed fetching command line url") | ||
205 | 1484 | return | ||
206 | 1485 | try: | ||
207 | 1486 | if key and content: | ||
208 | 1487 | write_file(target_fn, content, mode=0o600) | ||
209 | 1488 | LOG.debug(("Wrote to %s with contents of command line" | ||
210 | 1489 | " url %s (len=%s)"), target_fn, url, len(content)) | ||
211 | 1490 | elif key and not content: | ||
212 | 1491 | LOG.debug(("Command line key %s with url" | ||
213 | 1492 | " %s had no contents"), key, url) | ||
214 | 1493 | except Exception: | ||
215 | 1494 | logexc(LOG, "Failed writing url content to %s", target_fn) | ||
216 | 1495 | |||
217 | 1496 | |||
218 | 1497 | def yaml_dumps(obj, explicit_start=True, explicit_end=True): | 1453 | def yaml_dumps(obj, explicit_start=True, explicit_end=True): |
219 | 1498 | return yaml.safe_dump(obj, | 1454 | return yaml.safe_dump(obj, |
220 | 1499 | line_break="\n", | 1455 | line_break="\n", |
221 | diff --git a/tests/unittests/helpers.py b/tests/unittests/helpers.py | |||
222 | index cf3b46d..64e56d9 100644 | |||
223 | --- a/tests/unittests/helpers.py | |||
224 | +++ b/tests/unittests/helpers.py | |||
225 | @@ -264,16 +264,22 @@ class HttprettyTestCase(TestCase): | |||
226 | 264 | 264 | ||
227 | 265 | class TempDirTestCase(TestCase): | 265 | class TempDirTestCase(TestCase): |
228 | 266 | # provide a tempdir per class, not per test. | 266 | # provide a tempdir per class, not per test. |
233 | 267 | def setUp(self): | 267 | @classmethod |
234 | 268 | super(TempDirTestCase, self).setUp() | 268 | def setUpClass(cls): |
235 | 269 | self.tmp = tempfile.mkdtemp() | 269 | cls.tmpd = tempfile.mkdtemp(prefix="ci-%s." % cls.__name__) |
236 | 270 | self.addCleanup(shutil.rmtree, self.tmp) | 270 | return TestCase.setUpClass() |
237 | 271 | |||
238 | 272 | @classmethod | ||
239 | 273 | def tearDownClass(cls): | ||
240 | 274 | shutil.rmtree(cls.tmpd) | ||
241 | 275 | return TestCase.tearDownClass() | ||
242 | 271 | 276 | ||
243 | 272 | def tmp_path(self, path): | 277 | def tmp_path(self, path): |
244 | 278 | # if absolute path (starts with /), then make ./path | ||
245 | 273 | if path.startswith(os.path.sep): | 279 | if path.startswith(os.path.sep): |
246 | 274 | path = "." + path | 280 | path = "." + path |
247 | 275 | 281 | ||
249 | 276 | return os.path.normpath(os.path.join(self.tmp, path)) | 282 | return os.path.normpath(os.path.join(self.tmpd, path)) |
250 | 277 | 283 | ||
251 | 278 | 284 | ||
252 | 279 | def populate_dir(path, files): | 285 | def populate_dir(path, files): |
253 | diff --git a/tests/unittests/test__init__.py b/tests/unittests/test__init__.py | |||
254 | index 7b6f8c4..e6f4c31 100644 | |||
255 | --- a/tests/unittests/test__init__.py | |||
256 | +++ b/tests/unittests/test__init__.py | |||
257 | @@ -1,16 +1,18 @@ | |||
258 | 1 | # This file is part of cloud-init. See LICENSE file for license information. | 1 | # This file is part of cloud-init. See LICENSE file for license information. |
259 | 2 | 2 | ||
260 | 3 | import logging | ||
261 | 3 | import os | 4 | import os |
262 | 4 | import shutil | 5 | import shutil |
263 | 5 | import tempfile | 6 | import tempfile |
264 | 6 | 7 | ||
265 | 8 | from cloudinit.cmd import main | ||
266 | 7 | from cloudinit import handlers | 9 | from cloudinit import handlers |
267 | 8 | from cloudinit import helpers | 10 | from cloudinit import helpers |
268 | 9 | from cloudinit import settings | 11 | from cloudinit import settings |
269 | 10 | from cloudinit import url_helper | 12 | from cloudinit import url_helper |
270 | 11 | from cloudinit import util | 13 | from cloudinit import util |
271 | 12 | 14 | ||
273 | 13 | from .helpers import TestCase, ExitStack, mock | 15 | from .helpers import TestCase, TempDirTestCase, ExitStack, mock |
274 | 14 | 16 | ||
275 | 15 | 17 | ||
276 | 16 | class FakeModule(handlers.Handler): | 18 | class FakeModule(handlers.Handler): |
277 | @@ -170,44 +172,68 @@ class TestHandlerHandlePart(TestCase): | |||
278 | 170 | self.data, self.ctype, self.filename, self.payload) | 172 | self.data, self.ctype, self.filename, self.payload) |
279 | 171 | 173 | ||
280 | 172 | 174 | ||
287 | 173 | class TestCmdlineUrl(TestCase): | 175 | class TestCmdlineUrl(TempDirTestCase): |
288 | 174 | def test_invalid_content(self): | 176 | def test_parse_cmdline_url_nokey_raises_keyerror(self): |
289 | 175 | url = "http://example.com/foo" | 177 | self.assertRaises( |
290 | 176 | key = "mykey" | 178 | KeyError, main.parse_cmdline_url, 'root=foo bar single') |
285 | 177 | payload = b"0" | ||
286 | 178 | cmdline = "ro %s=%s bar=1" % (key, url) | ||
291 | 179 | 179 | ||
298 | 180 | with mock.patch('cloudinit.url_helper.readurl', | 180 | def test_parse_cmdline_url_found(self): |
299 | 181 | return_value=url_helper.StringResponse(payload)): | 181 | cmdline = 'root=foo bar single url=http://example.com arg1 -v' |
300 | 182 | self.assertEqual( | 182 | self.assertEqual( |
301 | 183 | util.get_cmdline_url(names=[key], starts="xxxxxx", | 183 | ('url', 'http://example.com'), main.parse_cmdline_url(cmdline)) |
296 | 184 | cmdline=cmdline), | ||
297 | 185 | (key, url, None)) | ||
302 | 186 | 184 | ||
307 | 187 | def test_valid_content(self): | 185 | @mock.patch('cloudinit.cmd.main.util.read_file_or_url') |
308 | 188 | url = "http://example.com/foo" | 186 | def test_invalid_content(self, m_read): |
309 | 189 | key = "mykey" | 187 | key = "cloud-config-url" |
310 | 190 | payload = b"xcloud-config\nmydata: foo\nbar: wark\n" | 188 | url = 'http://example.com/foo' |
311 | 191 | cmdline = "ro %s=%s bar=1" % (key, url) | 189 | cmdline = "ro %s=%s bar=1" % (key, url) |
312 | 190 | m_read.return_value = url_helper.StringResponse(b"unexpected blob") | ||
313 | 192 | 191 | ||
320 | 193 | with mock.patch('cloudinit.url_helper.readurl', | 192 | fpath = self.tmp_path("test_valid") |
321 | 194 | return_value=url_helper.StringResponse(payload)): | 193 | lvl, msg = main.attempt_cmdline_url( |
322 | 195 | self.assertEqual( | 194 | fpath, network=True, cmdline=cmdline) |
323 | 196 | util.get_cmdline_url(names=[key], starts=b"xcloud-config", | 195 | self.assertEqual(logging.WARN, lvl) |
324 | 197 | cmdline=cmdline), | 196 | self.assertIn(url, msg) |
325 | 198 | (key, url, payload)) | 197 | self.assertFalse(os.path.exists(fpath)) |
326 | 199 | 198 | ||
328 | 200 | def test_no_key_found(self): | 199 | @mock.patch('cloudinit.cmd.main.util.read_file_or_url') |
329 | 200 | def test_valid_content(self, m_read): | ||
330 | 201 | url = "http://example.com/foo" | 201 | url = "http://example.com/foo" |
340 | 202 | key = "mykey" | 202 | payload = b"#cloud-config\nmydata: foo\nbar: wark\n" |
341 | 203 | cmdline = "ro %s=%s bar=1" % (key, url) | 203 | cmdline = "ro %s=%s bar=1" % ('cloud-config-url', url) |
342 | 204 | 204 | ||
343 | 205 | with mock.patch('cloudinit.url_helper.readurl', | 205 | m_read.return_value = url_helper.StringResponse(payload) |
344 | 206 | return_value=url_helper.StringResponse(b'')): | 206 | fpath = self.tmp_path("test_valid") |
345 | 207 | self.assertEqual( | 207 | lvl, msg = main.attempt_cmdline_url( |
346 | 208 | util.get_cmdline_url(names=["does-not-appear"], | 208 | fpath, network=True, cmdline=cmdline) |
347 | 209 | starts="#cloud-config", cmdline=cmdline), | 209 | self.assertEqual(util.load_file(fpath, decode=False), payload) |
348 | 210 | (None, None, None)) | 210 | self.assertEqual(logging.INFO, lvl) |
349 | 211 | self.assertIn(url, msg) | ||
350 | 212 | |||
351 | 213 | @mock.patch('cloudinit.cmd.main.util.read_file_or_url') | ||
352 | 214 | def test_no_key_found(self, m_read): | ||
353 | 215 | cmdline = "ro mykey=http://example.com/foo root=foo" | ||
354 | 216 | fpath = self.tmp_path("test_no_key_found") | ||
355 | 217 | lvl, msg = main.attempt_cmdline_url( | ||
356 | 218 | fpath, network=True, cmdline=cmdline) | ||
357 | 219 | |||
358 | 220 | m_read.assert_not_called() | ||
359 | 221 | self.assertFalse(os.path.exists(fpath)) | ||
360 | 222 | self.assertEqual(logging.DEBUG, lvl) | ||
361 | 223 | |||
362 | 224 | @mock.patch('cloudinit.cmd.main.util.read_file_or_url') | ||
363 | 225 | def test_exception_warns(self, m_read): | ||
364 | 226 | url = "http://example.com/foo" | ||
365 | 227 | cmdline = "ro cloud-config-url=%s root=LABEL=bar" % url | ||
366 | 228 | fpath = self.tmp_path("test_no_key_found") | ||
367 | 229 | m_read.side_effect = url_helper.UrlError( | ||
368 | 230 | cause="Unexpected Error", url="http://example.com/foo") | ||
369 | 231 | |||
370 | 232 | lvl, msg = main.attempt_cmdline_url( | ||
371 | 233 | fpath, network=True, cmdline=cmdline) | ||
372 | 234 | self.assertEqual(logging.WARN, lvl) | ||
373 | 235 | self.assertIn(url, msg) | ||
374 | 236 | self.assertFalse(os.path.exists(fpath)) | ||
375 | 211 | 237 | ||
376 | 212 | 238 | ||
377 | 213 | # vi: ts=4 expandtab | 239 | # vi: ts=4 expandtab |
Thanks for the review, your comments seem sane.
I really do want to get this in, as currently this is a common path for failure in maas (node can't reach region controller) and there isn't much path as to what is going on there.