Merge ~chad.smith/cloud-init:clean-status-commands into cloud-init:master
- Git
- lp:~chad.smith/cloud-init
- clean-status-commands
- Merge into master
Status: | Merged |
---|---|
Approved by: | Chad Smith |
Approved revision: | 5609ca047823ec70a82a5a2e521e32cd5c3632ce |
Merged at revision: | 30b4d15764a1a9644379cf95770e8b2480856882 |
Proposed branch: | ~chad.smith/cloud-init:clean-status-commands |
Merge into: | cloud-init:master |
Diff against target: |
1018 lines (+888/-11) 10 files modified
cloudinit/cmd/clean.py (+102/-0) cloudinit/cmd/main.py (+18/-0) cloudinit/cmd/status.py (+157/-0) cloudinit/cmd/tests/__init__.py (+0/-0) cloudinit/cmd/tests/test_clean.py (+159/-0) cloudinit/cmd/tests/test_status.py (+353/-0) cloudinit/distros/__init__.py (+11/-5) cloudinit/util.py (+26/-0) tests/unittests/test_cli.py (+24/-6) tests/unittests/test_util.py (+38/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Server Team CI bot | continuous-integration | Approve | |
Scott Moser | Needs Fixing | ||
Review via email: mp+333513@code.launchpad.net |
Commit message
Description of the change
cli: Add clean and status subcommands
The 'cloud-init clean' command allows a user or script to clear cloud-init artifacts from the system so that cloud-init sees the system as unconfigured upon reboot. Optional parameters can be provided to remove cloud-init logs and reboot after clean.
The 'cloud-init status' command allows the user or script to check whether cloud-init has finished all configuration stages and whether errors occurred. An optional --wait argument will poll on a 0.25 second interval until cloud-init configuration is complete. The benefit here is scripts can block on cloud-init completion before performing post-config tasks.
Server Team CI bot (server-team-bot) wrote : | # |
Scott Moser (smoser) wrote : | # |
I really like it.
some minor things inline.
And what happens when cloud-init is disabled, either by ds-identify disabling it or /etc/cloud/
to test that, just touch /etc/cloud/
and reboot
Ryan Harper (raharper) : | # |
- 0ea787b... by Chad Smith
-
sort unit test cli parameter tests, separate cloud-init imports from system imports, use del_dir del_file instead of rm -Rf, fixup mocked unit test returing shutdown instead of reboot command path
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:0ea787b32e6
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Chad Smith (chad.smith) : | # |
- 656e569... by Chad Smith
-
add util.get_
config_ logfiles for parsing merged configs and returning logfiles configured - 38999e5... by Chad Smith
-
use util.get_
config_ logfiles from clean subcommand and no longer hardcode CLOUDINIT_LOGS and CLOUDINIT_ ARTIFACTS_ DIR
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:38999e541fc
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild:
https:/
- 001c52a... by Chad Smith
-
flakes
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:001c52a8078
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
- f5633b9... by Chad Smith
-
add unit tests for disabled by /etc/cloud/
cloud-init. disabled and kernel cmdline
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:f5633b91c02
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild:
https:/
- e80b2cb... by Chad Smith
-
mock os.path.exists instead of mocking the CLOUDINIT_
DISABLED_ FILE
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:e80b2cbeb3c
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
- ee2d62c... by Chad Smith
-
add systemctl disabled check to cloud-init status
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:ee2d62c41d2
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Scott Moser (smoser) wrote : | # |
some comments on a probably older version.
re-reviwing here shortly.
Chad Smith (chad.smith) : | # |
- b08ee4a... by Chad Smith
-
do not use sudo (as we generally require sudo for any cloud-init command) also no need to which("shutdown") just call it directly
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:b08ee4ad44c
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Scott Moser (smoser) wrote : | # |
one small request.
other than that i approve.
i'll try to test some tomorrow with this.
Scott Moser (smoser) wrote : | # |
I launched a container (bionic)
installed a deb from your branch (17.1-43-
then:
root@b1:~# cat /run/cloud-
{
"v1": {
"datasource": "DataSourceNoCloud [seed=/
"errors": []
}
}
root@b1:~# systemctl status cloud-init --no-pager | grep Active
Active: active (exited) since Mon 2017-11-27 22:45:19 UTC; 3min 11s ago
Scott Moser (smoser) wrote : | # |
above, it would have made more sense if i'd have shown this also
root@b1:~# cloud-init status
status: disabled
- 14e9ab8... by Chad Smith
-
fixup status to avoid using systemctl cli and inspect /etc/cloud/
cloud-init. disabled, kernel commandline and /run/cloud- init/enabled to determine whether or not we are disabled
Chad Smith (chad.smith) wrote : | # |
Addressed review comment, shuffling uses_systemd out of Distros.
- 95acd47... by Chad Smith
-
use util.write_file in unit tests to handle py3 py2-isms dealing with byte strings
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:14e9ab84581
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild:
https:/
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:95acd47699c
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild:
https:/
- d04460a... by Chad Smith
-
grr flakes
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:d04460aee05
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Scott Moser (smoser) wrote : | # |
Chad, this looks good.
Thanks.
i had one thing inline that i'd like fixed (kernel commandline cloud-init=
and then the c-i.
and then i think we're good.
- 59929ff... by Chad Smith
-
address review: split kernel cmdline to ensure specific match for cloud-init=
enable| |disable add case for kernel cmdline cloud-init=enable override
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:59929ff7190
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Scott Moser (smoser) wrote : | # |
Can we replace 'print' calls that are for error purposes with a 'print_error' function.
http://
Just appply ^ and I'm good.
We could do the same with 'print' to 'print_output', but I'm not hung up on that now.
- 5609ca0... by Chad Smith
-
use error function per review comments
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:5609ca04782
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Preview Diff
1 | diff --git a/cloudinit/cmd/clean.py b/cloudinit/cmd/clean.py |
2 | new file mode 100644 |
3 | index 0000000..81797b1 |
4 | --- /dev/null |
5 | +++ b/cloudinit/cmd/clean.py |
6 | @@ -0,0 +1,102 @@ |
7 | +# Copyright (C) 2017 Canonical Ltd. |
8 | +# |
9 | +# This file is part of cloud-init. See LICENSE file for license information. |
10 | + |
11 | +"""Define 'clean' utility and handler as part of cloud-init commandline.""" |
12 | + |
13 | +import argparse |
14 | +import os |
15 | +import sys |
16 | + |
17 | +from cloudinit.stages import Init |
18 | +from cloudinit.util import ( |
19 | + ProcessExecutionError, chdir, del_dir, del_file, get_config_logfiles, subp) |
20 | + |
21 | + |
22 | +def error(msg): |
23 | + sys.stderr.write("ERROR: " + msg + "\n") |
24 | + |
25 | + |
26 | +def get_parser(parser=None): |
27 | + """Build or extend an arg parser for clean utility. |
28 | + |
29 | + @param parser: Optional existing ArgumentParser instance representing the |
30 | + clean subcommand which will be extended to support the args of |
31 | + this utility. |
32 | + |
33 | + @returns: ArgumentParser with proper argument configuration. |
34 | + """ |
35 | + if not parser: |
36 | + parser = argparse.ArgumentParser( |
37 | + prog='clean', |
38 | + description=('Remove logs and artifacts so cloud-init re-runs on ' |
39 | + 'a clean system')) |
40 | + parser.add_argument( |
41 | + '-l', '--logs', action='store_true', default=False, dest='remove_logs', |
42 | + help='Remove cloud-init logs.') |
43 | + parser.add_argument( |
44 | + '-r', '--reboot', action='store_true', default=False, |
45 | + help='Reboot system after logs are cleaned so cloud-init re-runs.') |
46 | + parser.add_argument( |
47 | + '-s', '--seed', action='store_true', default=False, dest='remove_seed', |
48 | + help='Remove cloud-init seed directory /var/lib/cloud/seed.') |
49 | + return parser |
50 | + |
51 | + |
52 | +def remove_artifacts(remove_logs, remove_seed=False): |
53 | + """Helper which removes artifacts dir and optionally log files. |
54 | + |
55 | + @param: remove_logs: Boolean. Set True to delete the cloud_dir path. False |
56 | + preserves them. |
57 | + @param: remove_seed: Boolean. Set True to also delete seed subdir in |
58 | + paths.cloud_dir. |
59 | + @returns: 0 on success, 1 otherwise. |
60 | + """ |
61 | + init = Init(ds_deps=[]) |
62 | + init.read_cfg() |
63 | + if remove_logs: |
64 | + for log_file in get_config_logfiles(init.cfg): |
65 | + del_file(log_file) |
66 | + |
67 | + if not os.path.isdir(init.paths.cloud_dir): |
68 | + return 0 # Artifacts dir already cleaned |
69 | + with chdir(init.paths.cloud_dir): |
70 | + for path in os.listdir('.'): |
71 | + if path == 'seed' and not remove_seed: |
72 | + continue |
73 | + try: |
74 | + if os.path.isdir(path): |
75 | + del_dir(path) |
76 | + else: |
77 | + del_file(path) |
78 | + except OSError as e: |
79 | + error('Could not remove {0}: {1}'.format(path, str(e))) |
80 | + return 1 |
81 | + return 0 |
82 | + |
83 | + |
84 | +def handle_clean_args(name, args): |
85 | + """Handle calls to 'cloud-init clean' as a subcommand.""" |
86 | + exit_code = remove_artifacts(args.remove_logs, args.remove_seed) |
87 | + if exit_code == 0 and args.reboot: |
88 | + cmd = ['shutdown', '-r', 'now'] |
89 | + try: |
90 | + subp(cmd, capture=False) |
91 | + except ProcessExecutionError as e: |
92 | + error( |
93 | + 'Could not reboot this system using "{0}": {1}'.format( |
94 | + cmd, str(e))) |
95 | + exit_code = 1 |
96 | + return exit_code |
97 | + |
98 | + |
99 | +def main(): |
100 | + """Tool to collect and tar all cloud-init related logs.""" |
101 | + parser = get_parser() |
102 | + sys.exit(handle_clean_args('clean', parser.parse_args())) |
103 | + |
104 | + |
105 | +if __name__ == '__main__': |
106 | + main() |
107 | + |
108 | +# vi: ts=4 expandtab |
109 | diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py |
110 | index 6fb9d9e..aa56225 100644 |
111 | --- a/cloudinit/cmd/main.py |
112 | +++ b/cloudinit/cmd/main.py |
113 | @@ -767,6 +767,12 @@ def main(sysv_args=None): |
114 | parser_collect_logs = subparsers.add_parser( |
115 | 'collect-logs', help='Collect and tar all cloud-init debug info') |
116 | |
117 | + parser_clean = subparsers.add_parser( |
118 | + 'clean', help='Remove logs and artifacts so cloud-init can re-run.') |
119 | + |
120 | + parser_status = subparsers.add_parser( |
121 | + 'status', help='Report cloud-init status or wait on completion.') |
122 | + |
123 | if sysv_args: |
124 | # Only load subparsers if subcommand is specified to avoid load cost |
125 | if sysv_args[0] == 'analyze': |
126 | @@ -783,6 +789,18 @@ def main(sysv_args=None): |
127 | logs_parser(parser_collect_logs) |
128 | parser_collect_logs.set_defaults( |
129 | action=('collect-logs', handle_collect_logs_args)) |
130 | + elif sysv_args[0] == 'clean': |
131 | + from cloudinit.cmd.clean import ( |
132 | + get_parser as clean_parser, handle_clean_args) |
133 | + clean_parser(parser_clean) |
134 | + parser_clean.set_defaults( |
135 | + action=('clean', handle_clean_args)) |
136 | + elif sysv_args[0] == 'status': |
137 | + from cloudinit.cmd.status import ( |
138 | + get_parser as status_parser, handle_status_args) |
139 | + status_parser(parser_status) |
140 | + parser_status.set_defaults( |
141 | + action=('status', handle_status_args)) |
142 | |
143 | args = parser.parse_args(args=sysv_args) |
144 | |
145 | diff --git a/cloudinit/cmd/status.py b/cloudinit/cmd/status.py |
146 | new file mode 100644 |
147 | index 0000000..3e5d0d0 |
148 | --- /dev/null |
149 | +++ b/cloudinit/cmd/status.py |
150 | @@ -0,0 +1,157 @@ |
151 | +# Copyright (C) 2017 Canonical Ltd. |
152 | +# |
153 | +# This file is part of cloud-init. See LICENSE file for license information. |
154 | + |
155 | +"""Define 'status' utility and handler as part of cloud-init commandline.""" |
156 | + |
157 | +import argparse |
158 | +import os |
159 | +import sys |
160 | +from time import gmtime, strftime, sleep |
161 | + |
162 | +from cloudinit.distros import uses_systemd |
163 | +from cloudinit.stages import Init |
164 | +from cloudinit.util import get_cmdline, load_file, load_json |
165 | + |
166 | +CLOUDINIT_DISABLED_FILE = '/etc/cloud/cloud-init.disabled' |
167 | + |
168 | +# customer visible status messages |
169 | +STATUS_ENABLED_NOT_RUN = 'not run' |
170 | +STATUS_RUNNING = 'running' |
171 | +STATUS_DONE = 'done' |
172 | +STATUS_ERROR = 'error' |
173 | +STATUS_DISABLED = 'disabled' |
174 | + |
175 | + |
176 | +def get_parser(parser=None): |
177 | + """Build or extend an arg parser for status utility. |
178 | + |
179 | + @param parser: Optional existing ArgumentParser instance representing the |
180 | + status subcommand which will be extended to support the args of |
181 | + this utility. |
182 | + |
183 | + @returns: ArgumentParser with proper argument configuration. |
184 | + """ |
185 | + if not parser: |
186 | + parser = argparse.ArgumentParser( |
187 | + prog='status', |
188 | + description='Report run status of cloud init') |
189 | + parser.add_argument( |
190 | + '-l', '--long', action='store_true', default=False, |
191 | + help=('Report long format of statuses including run stage name and' |
192 | + ' error messages')) |
193 | + parser.add_argument( |
194 | + '-w', '--wait', action='store_true', default=False, |
195 | + help='Block waiting on cloud-init to complete') |
196 | + return parser |
197 | + |
198 | + |
199 | +def handle_status_args(name, args): |
200 | + """Handle calls to 'cloud-init status' as a subcommand.""" |
201 | + # Read configured paths |
202 | + init = Init(ds_deps=[]) |
203 | + init.read_cfg() |
204 | + |
205 | + status, status_detail, time = _get_status_details(init.paths) |
206 | + if args.wait: |
207 | + while status in (STATUS_ENABLED_NOT_RUN, STATUS_RUNNING): |
208 | + sys.stdout.write('.') |
209 | + sys.stdout.flush() |
210 | + status, status_detail, time = _get_status_details(init.paths) |
211 | + sleep(0.25) |
212 | + sys.stdout.write('\n') |
213 | + if args.long: |
214 | + print('status: {0}'.format(status)) |
215 | + if time: |
216 | + print('time: {0}'.format(time)) |
217 | + print('detail:\n{0}'.format(status_detail)) |
218 | + else: |
219 | + print('status: {0}'.format(status)) |
220 | + return 1 if status == STATUS_ERROR else 0 |
221 | + |
222 | + |
223 | +def _is_cloudinit_disabled(disable_file, paths): |
224 | + """Report whether cloud-init is disabled. |
225 | + |
226 | + @param disable_file: The path to the cloud-init disable file. |
227 | + @param paths: An initialized cloudinit.helpers.Paths object. |
228 | + @returns: A tuple containing (bool, reason) about cloud-init's status and |
229 | + why. |
230 | + """ |
231 | + is_disabled = False |
232 | + cmdline_parts = get_cmdline().split() |
233 | + if not uses_systemd(): |
234 | + reason = 'Cloud-init enabled on sysvinit' |
235 | + elif 'cloud-init=enabled' in cmdline_parts: |
236 | + reason = 'Cloud-init enabled by kernel command line cloud-init=enabled' |
237 | + elif os.path.exists(disable_file): |
238 | + is_disabled = True |
239 | + reason = 'Cloud-init disabled by {0}'.format(disable_file) |
240 | + elif 'cloud-init=disabled' in cmdline_parts: |
241 | + is_disabled = True |
242 | + reason = 'Cloud-init disabled by kernel parameter cloud-init=disabled' |
243 | + elif not os.path.exists(os.path.join(paths.run_dir, 'enabled')): |
244 | + is_disabled = True |
245 | + reason = 'Cloud-init disabled by cloud-init-generator' |
246 | + return (is_disabled, reason) |
247 | + |
248 | + |
249 | +def _get_status_details(paths): |
250 | + """Return a 3-tuple of status, status_details and time of last event. |
251 | + |
252 | + @param paths: An initialized cloudinit.helpers.paths object. |
253 | + |
254 | + Values are obtained from parsing paths.run_dir/status.json. |
255 | + """ |
256 | + |
257 | + status = STATUS_ENABLED_NOT_RUN |
258 | + status_detail = '' |
259 | + status_v1 = {} |
260 | + |
261 | + status_file = os.path.join(paths.run_dir, 'status.json') |
262 | + |
263 | + (is_disabled, reason) = _is_cloudinit_disabled( |
264 | + CLOUDINIT_DISABLED_FILE, paths) |
265 | + if is_disabled: |
266 | + status = STATUS_DISABLED |
267 | + status_detail = reason |
268 | + if os.path.exists(status_file): |
269 | + status_v1 = load_json(load_file(status_file)).get('v1', {}) |
270 | + errors = [] |
271 | + latest_event = 0 |
272 | + for key, value in sorted(status_v1.items()): |
273 | + if key == 'stage': |
274 | + if value: |
275 | + status_detail = 'Running in stage: {0}'.format(value) |
276 | + elif key == 'datasource': |
277 | + status_detail = value |
278 | + elif isinstance(value, dict): |
279 | + errors.extend(value.get('errors', [])) |
280 | + finished = value.get('finished') or 0 |
281 | + if finished == 0: |
282 | + status = STATUS_RUNNING |
283 | + event_time = max(value.get('start', 0), finished) |
284 | + if event_time > latest_event: |
285 | + latest_event = event_time |
286 | + if errors: |
287 | + status = STATUS_ERROR |
288 | + status_detail = '\n'.join(errors) |
289 | + elif status == STATUS_ENABLED_NOT_RUN and latest_event > 0: |
290 | + status = STATUS_DONE |
291 | + if latest_event: |
292 | + time = strftime('%a, %d %b %Y %H:%M:%S %z', gmtime(latest_event)) |
293 | + else: |
294 | + time = '' |
295 | + return status, status_detail, time |
296 | + |
297 | + |
298 | +def main(): |
299 | + """Tool to report status of cloud-init.""" |
300 | + parser = get_parser() |
301 | + sys.exit(handle_status_args('status', parser.parse_args())) |
302 | + |
303 | + |
304 | +if __name__ == '__main__': |
305 | + main() |
306 | + |
307 | +# vi: ts=4 expandtab |
308 | diff --git a/cloudinit/cmd/tests/__init__.py b/cloudinit/cmd/tests/__init__.py |
309 | new file mode 100644 |
310 | index 0000000..e69de29 |
311 | --- /dev/null |
312 | +++ b/cloudinit/cmd/tests/__init__.py |
313 | diff --git a/cloudinit/cmd/tests/test_clean.py b/cloudinit/cmd/tests/test_clean.py |
314 | new file mode 100644 |
315 | index 0000000..af438aa |
316 | --- /dev/null |
317 | +++ b/cloudinit/cmd/tests/test_clean.py |
318 | @@ -0,0 +1,159 @@ |
319 | +# This file is part of cloud-init. See LICENSE file for license information. |
320 | + |
321 | +from cloudinit.cmd import clean |
322 | +from cloudinit.util import ensure_dir, write_file |
323 | +from cloudinit.tests.helpers import CiTestCase, wrap_and_call, mock |
324 | +from collections import namedtuple |
325 | +import os |
326 | +from six import StringIO |
327 | + |
328 | +mypaths = namedtuple('MyPaths', 'cloud_dir') |
329 | + |
330 | + |
331 | +class TestClean(CiTestCase): |
332 | + |
333 | + def setUp(self): |
334 | + super(TestClean, self).setUp() |
335 | + self.new_root = self.tmp_dir() |
336 | + self.artifact_dir = self.tmp_path('artifacts', self.new_root) |
337 | + self.log1 = self.tmp_path('cloud-init.log', self.new_root) |
338 | + self.log2 = self.tmp_path('cloud-init-output.log', self.new_root) |
339 | + |
340 | + class FakeInit(object): |
341 | + cfg = {'def_log_file': self.log1, |
342 | + 'output': {'all': '|tee -a {0}'.format(self.log2)}} |
343 | + paths = mypaths(cloud_dir=self.artifact_dir) |
344 | + |
345 | + def __init__(self, ds_deps): |
346 | + pass |
347 | + |
348 | + def read_cfg(self): |
349 | + pass |
350 | + |
351 | + self.init_class = FakeInit |
352 | + |
353 | + def test_remove_artifacts_removes_logs(self): |
354 | + """remove_artifacts removes logs when remove_logs is True.""" |
355 | + write_file(self.log1, 'cloud-init-log') |
356 | + write_file(self.log2, 'cloud-init-output-log') |
357 | + |
358 | + self.assertFalse( |
359 | + os.path.exists(self.artifact_dir), 'Unexpected artifacts dir') |
360 | + retcode = wrap_and_call( |
361 | + 'cloudinit.cmd.clean', |
362 | + {'Init': {'side_effect': self.init_class}}, |
363 | + clean.remove_artifacts, remove_logs=True) |
364 | + self.assertFalse(os.path.exists(self.log1), 'Unexpected file') |
365 | + self.assertFalse(os.path.exists(self.log2), 'Unexpected file') |
366 | + self.assertEqual(0, retcode) |
367 | + |
368 | + def test_remove_artifacts_preserves_logs(self): |
369 | + """remove_artifacts leaves logs when remove_logs is False.""" |
370 | + write_file(self.log1, 'cloud-init-log') |
371 | + write_file(self.log2, 'cloud-init-output-log') |
372 | + |
373 | + retcode = wrap_and_call( |
374 | + 'cloudinit.cmd.clean', |
375 | + {'Init': {'side_effect': self.init_class}}, |
376 | + clean.remove_artifacts, remove_logs=False) |
377 | + self.assertTrue(os.path.exists(self.log1), 'Missing expected file') |
378 | + self.assertTrue(os.path.exists(self.log2), 'Missing expected file') |
379 | + self.assertEqual(0, retcode) |
380 | + |
381 | + def test_remove_artifacts_removes_artifacts_skipping_seed(self): |
382 | + """remove_artifacts cleans artifacts dir with exception of seed dir.""" |
383 | + dirs = [ |
384 | + self.artifact_dir, |
385 | + os.path.join(self.artifact_dir, 'seed'), |
386 | + os.path.join(self.artifact_dir, 'dir1'), |
387 | + os.path.join(self.artifact_dir, 'dir2')] |
388 | + for _dir in dirs: |
389 | + ensure_dir(_dir) |
390 | + |
391 | + retcode = wrap_and_call( |
392 | + 'cloudinit.cmd.clean', |
393 | + {'Init': {'side_effect': self.init_class}}, |
394 | + clean.remove_artifacts, remove_logs=False) |
395 | + self.assertEqual(0, retcode) |
396 | + for expected_dir in dirs[:2]: |
397 | + self.assertTrue( |
398 | + os.path.exists(expected_dir), |
399 | + 'Missing {0} dir'.format(expected_dir)) |
400 | + for deleted_dir in dirs[2:]: |
401 | + self.assertFalse( |
402 | + os.path.exists(deleted_dir), |
403 | + 'Unexpected {0} dir'.format(deleted_dir)) |
404 | + |
405 | + def test_remove_artifacts_removes_artifacts_removes_seed(self): |
406 | + """remove_artifacts removes seed dir when remove_seed is True.""" |
407 | + dirs = [ |
408 | + self.artifact_dir, |
409 | + os.path.join(self.artifact_dir, 'seed'), |
410 | + os.path.join(self.artifact_dir, 'dir1'), |
411 | + os.path.join(self.artifact_dir, 'dir2')] |
412 | + for _dir in dirs: |
413 | + ensure_dir(_dir) |
414 | + |
415 | + retcode = wrap_and_call( |
416 | + 'cloudinit.cmd.clean', |
417 | + {'Init': {'side_effect': self.init_class}}, |
418 | + clean.remove_artifacts, remove_logs=False, remove_seed=True) |
419 | + self.assertEqual(0, retcode) |
420 | + self.assertTrue( |
421 | + os.path.exists(self.artifact_dir), 'Missing artifact dir') |
422 | + for deleted_dir in dirs[1:]: |
423 | + self.assertFalse( |
424 | + os.path.exists(deleted_dir), |
425 | + 'Unexpected {0} dir'.format(deleted_dir)) |
426 | + |
427 | + def test_remove_artifacts_returns_one_on_errors(self): |
428 | + """remove_artifacts returns non-zero on failure and prints an error.""" |
429 | + ensure_dir(self.artifact_dir) |
430 | + ensure_dir(os.path.join(self.artifact_dir, 'dir1')) |
431 | + |
432 | + with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr: |
433 | + retcode = wrap_and_call( |
434 | + 'cloudinit.cmd.clean', |
435 | + {'del_dir': {'side_effect': OSError('oops')}, |
436 | + 'Init': {'side_effect': self.init_class}}, |
437 | + clean.remove_artifacts, remove_logs=False) |
438 | + self.assertEqual(1, retcode) |
439 | + self.assertEqual( |
440 | + 'ERROR: Could not remove dir1: oops\n', m_stderr.getvalue()) |
441 | + |
442 | + def test_handle_clean_args_reboots(self): |
443 | + """handle_clean_args_reboots when reboot arg is provided.""" |
444 | + |
445 | + called_cmds = [] |
446 | + |
447 | + def fake_subp(cmd, capture): |
448 | + called_cmds.append((cmd, capture)) |
449 | + return '', '' |
450 | + |
451 | + myargs = namedtuple('MyArgs', 'remove_logs remove_seed reboot') |
452 | + cmdargs = myargs(remove_logs=False, remove_seed=False, reboot=True) |
453 | + retcode = wrap_and_call( |
454 | + 'cloudinit.cmd.clean', |
455 | + {'subp': {'side_effect': fake_subp}, |
456 | + 'Init': {'side_effect': self.init_class}}, |
457 | + clean.handle_clean_args, name='does not matter', args=cmdargs) |
458 | + self.assertEqual(0, retcode) |
459 | + self.assertEqual( |
460 | + [(['shutdown', '-r', 'now'], False)], called_cmds) |
461 | + |
462 | + def test_status_main(self): |
463 | + '''clean.main can be run as a standalone script.''' |
464 | + write_file(self.log1, 'cloud-init-log') |
465 | + with self.assertRaises(SystemExit) as context_manager: |
466 | + wrap_and_call( |
467 | + 'cloudinit.cmd.clean', |
468 | + {'Init': {'side_effect': self.init_class}, |
469 | + 'sys.argv': {'new': ['clean', '--logs']}}, |
470 | + clean.main) |
471 | + |
472 | + self.assertEqual(0, context_manager.exception.code) |
473 | + self.assertFalse( |
474 | + os.path.exists(self.log1), 'Unexpected log {0}'.format(self.log1)) |
475 | + |
476 | + |
477 | +# vi: ts=4 expandtab syntax=python |
478 | diff --git a/cloudinit/cmd/tests/test_status.py b/cloudinit/cmd/tests/test_status.py |
479 | new file mode 100644 |
480 | index 0000000..8ec9b5b |
481 | --- /dev/null |
482 | +++ b/cloudinit/cmd/tests/test_status.py |
483 | @@ -0,0 +1,353 @@ |
484 | +# This file is part of cloud-init. See LICENSE file for license information. |
485 | + |
486 | +from collections import namedtuple |
487 | +import os |
488 | +from six import StringIO |
489 | +from textwrap import dedent |
490 | + |
491 | +from cloudinit.atomic_helper import write_json |
492 | +from cloudinit.cmd import status |
493 | +from cloudinit.util import write_file |
494 | +from cloudinit.tests.helpers import CiTestCase, wrap_and_call, mock |
495 | + |
496 | +mypaths = namedtuple('MyPaths', 'run_dir') |
497 | +myargs = namedtuple('MyArgs', 'long wait') |
498 | + |
499 | + |
500 | +class TestStatus(CiTestCase): |
501 | + |
502 | + def setUp(self): |
503 | + super(TestStatus, self).setUp() |
504 | + self.new_root = self.tmp_dir() |
505 | + self.status_file = self.tmp_path('status.json', self.new_root) |
506 | + self.disable_file = self.tmp_path('cloudinit-disable', self.new_root) |
507 | + self.paths = mypaths(run_dir=self.new_root) |
508 | + |
509 | + class FakeInit(object): |
510 | + paths = self.paths |
511 | + |
512 | + def __init__(self, ds_deps): |
513 | + pass |
514 | + |
515 | + def read_cfg(self): |
516 | + pass |
517 | + |
518 | + self.init_class = FakeInit |
519 | + |
520 | + def test__is_cloudinit_disabled_false_on_sysvinit(self): |
521 | + '''When not in an environment using systemd, return False.''' |
522 | + write_file(self.disable_file, '') # Create the ignored disable file |
523 | + (is_disabled, reason) = wrap_and_call( |
524 | + 'cloudinit.cmd.status', |
525 | + {'uses_systemd': False}, |
526 | + status._is_cloudinit_disabled, self.disable_file, self.paths) |
527 | + self.assertFalse( |
528 | + is_disabled, 'expected enabled cloud-init on sysvinit') |
529 | + self.assertEqual('Cloud-init enabled on sysvinit', reason) |
530 | + |
531 | + def test__is_cloudinit_disabled_true_on_disable_file(self): |
532 | + '''When using systemd and disable_file is present return disabled.''' |
533 | + write_file(self.disable_file, '') # Create observed disable file |
534 | + (is_disabled, reason) = wrap_and_call( |
535 | + 'cloudinit.cmd.status', |
536 | + {'uses_systemd': True}, |
537 | + status._is_cloudinit_disabled, self.disable_file, self.paths) |
538 | + self.assertTrue(is_disabled, 'expected disabled cloud-init') |
539 | + self.assertEqual( |
540 | + 'Cloud-init disabled by {0}'.format(self.disable_file), reason) |
541 | + |
542 | + def test__is_cloudinit_disabled_false_on_kernel_cmdline_enable(self): |
543 | + '''Not disabled when using systemd and enabled via commandline.''' |
544 | + write_file(self.disable_file, '') # Create ignored disable file |
545 | + (is_disabled, reason) = wrap_and_call( |
546 | + 'cloudinit.cmd.status', |
547 | + {'uses_systemd': True, |
548 | + 'get_cmdline': 'something cloud-init=enabled else'}, |
549 | + status._is_cloudinit_disabled, self.disable_file, self.paths) |
550 | + self.assertFalse(is_disabled, 'expected enabled cloud-init') |
551 | + self.assertEqual( |
552 | + 'Cloud-init enabled by kernel command line cloud-init=enabled', |
553 | + reason) |
554 | + |
555 | + def test__is_cloudinit_disabled_true_on_kernel_cmdline(self): |
556 | + '''When using systemd and disable_file is present return disabled.''' |
557 | + (is_disabled, reason) = wrap_and_call( |
558 | + 'cloudinit.cmd.status', |
559 | + {'uses_systemd': True, |
560 | + 'get_cmdline': 'something cloud-init=disabled else'}, |
561 | + status._is_cloudinit_disabled, self.disable_file, self.paths) |
562 | + self.assertTrue(is_disabled, 'expected disabled cloud-init') |
563 | + self.assertEqual( |
564 | + 'Cloud-init disabled by kernel parameter cloud-init=disabled', |
565 | + reason) |
566 | + |
567 | + def test__is_cloudinit_disabled_true_when_generator_disables(self): |
568 | + '''When cloud-init-generator doesn't write enabled file return True.''' |
569 | + enabled_file = os.path.join(self.paths.run_dir, 'enabled') |
570 | + self.assertFalse(os.path.exists(enabled_file)) |
571 | + (is_disabled, reason) = wrap_and_call( |
572 | + 'cloudinit.cmd.status', |
573 | + {'uses_systemd': True, |
574 | + 'get_cmdline': 'something'}, |
575 | + status._is_cloudinit_disabled, self.disable_file, self.paths) |
576 | + self.assertTrue(is_disabled, 'expected disabled cloud-init') |
577 | + self.assertEqual('Cloud-init disabled by cloud-init-generator', reason) |
578 | + |
579 | + def test_status_returns_not_run(self): |
580 | + '''When status.json does not exist yet, return 'not run'.''' |
581 | + self.assertFalse( |
582 | + os.path.exists(self.status_file), 'Unexpected status.json found') |
583 | + cmdargs = myargs(long=False, wait=False) |
584 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
585 | + retcode = wrap_and_call( |
586 | + 'cloudinit.cmd.status', |
587 | + {'_is_cloudinit_disabled': (False, ''), |
588 | + 'Init': {'side_effect': self.init_class}}, |
589 | + status.handle_status_args, 'ignored', cmdargs) |
590 | + self.assertEqual(0, retcode) |
591 | + self.assertEqual('status: not run\n', m_stdout.getvalue()) |
592 | + |
593 | + def test_status_returns_disabled_long_on_presence_of_disable_file(self): |
594 | + '''When cloudinit is disabled, return disabled reason.''' |
595 | + |
596 | + checked_files = [] |
597 | + |
598 | + def fakeexists(filepath): |
599 | + checked_files.append(filepath) |
600 | + status_file = os.path.join(self.paths.run_dir, 'status.json') |
601 | + return bool(not filepath == status_file) |
602 | + |
603 | + cmdargs = myargs(long=True, wait=False) |
604 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
605 | + retcode = wrap_and_call( |
606 | + 'cloudinit.cmd.status', |
607 | + {'os.path.exists': {'side_effect': fakeexists}, |
608 | + '_is_cloudinit_disabled': (True, 'disabled for some reason'), |
609 | + 'Init': {'side_effect': self.init_class}}, |
610 | + status.handle_status_args, 'ignored', cmdargs) |
611 | + self.assertEqual(0, retcode) |
612 | + self.assertEqual( |
613 | + [os.path.join(self.paths.run_dir, 'status.json')], |
614 | + checked_files) |
615 | + expected = dedent('''\ |
616 | + status: disabled |
617 | + detail: |
618 | + disabled for some reason |
619 | + ''') |
620 | + self.assertEqual(expected, m_stdout.getvalue()) |
621 | + |
622 | + def test_status_returns_running(self): |
623 | + '''Report running when status file exists but isn't finished.''' |
624 | + write_json(self.status_file, {'v1': {'init': {'finished': None}}}) |
625 | + cmdargs = myargs(long=False, wait=False) |
626 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
627 | + retcode = wrap_and_call( |
628 | + 'cloudinit.cmd.status', |
629 | + {'_is_cloudinit_disabled': (False, ''), |
630 | + 'Init': {'side_effect': self.init_class}}, |
631 | + status.handle_status_args, 'ignored', cmdargs) |
632 | + self.assertEqual(0, retcode) |
633 | + self.assertEqual('status: running\n', m_stdout.getvalue()) |
634 | + |
635 | + def test_status_returns_done(self): |
636 | + '''Reports done when stage is None and all stages are finished.''' |
637 | + write_json( |
638 | + self.status_file, |
639 | + {'v1': {'stage': None, |
640 | + 'datasource': ( |
641 | + 'DataSourceNoCloud [seed=/var/.../seed/nocloud-net]' |
642 | + '[dsmode=net]'), |
643 | + 'blah': {'finished': 123.456}, |
644 | + 'init': {'errors': [], 'start': 124.567, |
645 | + 'finished': 125.678}, |
646 | + 'init-local': {'start': 123.45, 'finished': 123.46}}}) |
647 | + cmdargs = myargs(long=False, wait=False) |
648 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
649 | + retcode = wrap_and_call( |
650 | + 'cloudinit.cmd.status', |
651 | + {'_is_cloudinit_disabled': (False, ''), |
652 | + 'Init': {'side_effect': self.init_class}}, |
653 | + status.handle_status_args, 'ignored', cmdargs) |
654 | + self.assertEqual(0, retcode) |
655 | + self.assertEqual('status: done\n', m_stdout.getvalue()) |
656 | + |
657 | + def test_status_returns_done_long(self): |
658 | + '''Long format of done status includes datasource info.''' |
659 | + write_json( |
660 | + self.status_file, |
661 | + {'v1': {'stage': None, |
662 | + 'datasource': ( |
663 | + 'DataSourceNoCloud [seed=/var/.../seed/nocloud-net]' |
664 | + '[dsmode=net]'), |
665 | + 'init': {'start': 124.567, 'finished': 125.678}, |
666 | + 'init-local': {'start': 123.45, 'finished': 123.46}}}) |
667 | + cmdargs = myargs(long=True, wait=False) |
668 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
669 | + retcode = wrap_and_call( |
670 | + 'cloudinit.cmd.status', |
671 | + {'_is_cloudinit_disabled': (False, ''), |
672 | + 'Init': {'side_effect': self.init_class}}, |
673 | + status.handle_status_args, 'ignored', cmdargs) |
674 | + self.assertEqual(0, retcode) |
675 | + expected = dedent('''\ |
676 | + status: done |
677 | + time: Thu, 01 Jan 1970 00:02:05 +0000 |
678 | + detail: |
679 | + DataSourceNoCloud [seed=/var/.../seed/nocloud-net][dsmode=net] |
680 | + ''') |
681 | + self.assertEqual(expected, m_stdout.getvalue()) |
682 | + |
683 | + def test_status_on_errors(self): |
684 | + '''Reports error when any stage has errors.''' |
685 | + write_json( |
686 | + self.status_file, |
687 | + {'v1': {'stage': None, |
688 | + 'blah': {'errors': [], 'finished': 123.456}, |
689 | + 'init': {'errors': ['error1'], 'start': 124.567, |
690 | + 'finished': 125.678}, |
691 | + 'init-local': {'start': 123.45, 'finished': 123.46}}}) |
692 | + cmdargs = myargs(long=False, wait=False) |
693 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
694 | + retcode = wrap_and_call( |
695 | + 'cloudinit.cmd.status', |
696 | + {'_is_cloudinit_disabled': (False, ''), |
697 | + 'Init': {'side_effect': self.init_class}}, |
698 | + status.handle_status_args, 'ignored', cmdargs) |
699 | + self.assertEqual(1, retcode) |
700 | + self.assertEqual('status: error\n', m_stdout.getvalue()) |
701 | + |
702 | + def test_status_on_errors_long(self): |
703 | + '''Long format of error status includes all error messages.''' |
704 | + write_json( |
705 | + self.status_file, |
706 | + {'v1': {'stage': None, |
707 | + 'datasource': ( |
708 | + 'DataSourceNoCloud [seed=/var/.../seed/nocloud-net]' |
709 | + '[dsmode=net]'), |
710 | + 'init': {'errors': ['error1'], 'start': 124.567, |
711 | + 'finished': 125.678}, |
712 | + 'init-local': {'errors': ['error2', 'error3'], |
713 | + 'start': 123.45, 'finished': 123.46}}}) |
714 | + cmdargs = myargs(long=True, wait=False) |
715 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
716 | + retcode = wrap_and_call( |
717 | + 'cloudinit.cmd.status', |
718 | + {'_is_cloudinit_disabled': (False, ''), |
719 | + 'Init': {'side_effect': self.init_class}}, |
720 | + status.handle_status_args, 'ignored', cmdargs) |
721 | + self.assertEqual(1, retcode) |
722 | + expected = dedent('''\ |
723 | + status: error |
724 | + time: Thu, 01 Jan 1970 00:02:05 +0000 |
725 | + detail: |
726 | + error1 |
727 | + error2 |
728 | + error3 |
729 | + ''') |
730 | + self.assertEqual(expected, m_stdout.getvalue()) |
731 | + |
732 | + def test_status_returns_running_long_format(self): |
733 | + '''Long format reports the stage in which we are running.''' |
734 | + write_json( |
735 | + self.status_file, |
736 | + {'v1': {'stage': 'init', |
737 | + 'init': {'start': 124.456, 'finished': None}, |
738 | + 'init-local': {'start': 123.45, 'finished': 123.46}}}) |
739 | + cmdargs = myargs(long=True, wait=False) |
740 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
741 | + retcode = wrap_and_call( |
742 | + 'cloudinit.cmd.status', |
743 | + {'_is_cloudinit_disabled': (False, ''), |
744 | + 'Init': {'side_effect': self.init_class}}, |
745 | + status.handle_status_args, 'ignored', cmdargs) |
746 | + self.assertEqual(0, retcode) |
747 | + expected = dedent('''\ |
748 | + status: running |
749 | + time: Thu, 01 Jan 1970 00:02:04 +0000 |
750 | + detail: |
751 | + Running in stage: init |
752 | + ''') |
753 | + self.assertEqual(expected, m_stdout.getvalue()) |
754 | + |
755 | + def test_status_wait_blocks_until_done(self): |
756 | + '''Specifying wait will poll every 1/4 second until done state.''' |
757 | + running_json = { |
758 | + 'v1': {'stage': 'init', |
759 | + 'init': {'start': 124.456, 'finished': None}, |
760 | + 'init-local': {'start': 123.45, 'finished': 123.46}}} |
761 | + done_json = { |
762 | + 'v1': {'stage': None, |
763 | + 'init': {'start': 124.456, 'finished': 125.678}, |
764 | + 'init-local': {'start': 123.45, 'finished': 123.46}}} |
765 | + |
766 | + self.sleep_calls = 0 |
767 | + |
768 | + def fake_sleep(interval): |
769 | + self.assertEqual(0.25, interval) |
770 | + self.sleep_calls += 1 |
771 | + if self.sleep_calls == 2: |
772 | + write_json(self.status_file, running_json) |
773 | + elif self.sleep_calls == 3: |
774 | + write_json(self.status_file, done_json) |
775 | + |
776 | + cmdargs = myargs(long=False, wait=True) |
777 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
778 | + retcode = wrap_and_call( |
779 | + 'cloudinit.cmd.status', |
780 | + {'sleep': {'side_effect': fake_sleep}, |
781 | + '_is_cloudinit_disabled': (False, ''), |
782 | + 'Init': {'side_effect': self.init_class}}, |
783 | + status.handle_status_args, 'ignored', cmdargs) |
784 | + self.assertEqual(0, retcode) |
785 | + self.assertEqual(4, self.sleep_calls) |
786 | + self.assertEqual('....\nstatus: done\n', m_stdout.getvalue()) |
787 | + |
788 | + def test_status_wait_blocks_until_error(self): |
789 | + '''Specifying wait will poll every 1/4 second until error state.''' |
790 | + running_json = { |
791 | + 'v1': {'stage': 'init', |
792 | + 'init': {'start': 124.456, 'finished': None}, |
793 | + 'init-local': {'start': 123.45, 'finished': 123.46}}} |
794 | + error_json = { |
795 | + 'v1': {'stage': None, |
796 | + 'init': {'errors': ['error1'], 'start': 124.456, |
797 | + 'finished': 125.678}, |
798 | + 'init-local': {'start': 123.45, 'finished': 123.46}}} |
799 | + |
800 | + self.sleep_calls = 0 |
801 | + |
802 | + def fake_sleep(interval): |
803 | + self.assertEqual(0.25, interval) |
804 | + self.sleep_calls += 1 |
805 | + if self.sleep_calls == 2: |
806 | + write_json(self.status_file, running_json) |
807 | + elif self.sleep_calls == 3: |
808 | + write_json(self.status_file, error_json) |
809 | + |
810 | + cmdargs = myargs(long=False, wait=True) |
811 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
812 | + retcode = wrap_and_call( |
813 | + 'cloudinit.cmd.status', |
814 | + {'sleep': {'side_effect': fake_sleep}, |
815 | + '_is_cloudinit_disabled': (False, ''), |
816 | + 'Init': {'side_effect': self.init_class}}, |
817 | + status.handle_status_args, 'ignored', cmdargs) |
818 | + self.assertEqual(1, retcode) |
819 | + self.assertEqual(4, self.sleep_calls) |
820 | + self.assertEqual('....\nstatus: error\n', m_stdout.getvalue()) |
821 | + |
822 | + def test_status_main(self): |
823 | + '''status.main can be run as a standalone script.''' |
824 | + write_json(self.status_file, {'v1': {'init': {'finished': None}}}) |
825 | + with self.assertRaises(SystemExit) as context_manager: |
826 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
827 | + wrap_and_call( |
828 | + 'cloudinit.cmd.status', |
829 | + {'sys.argv': {'new': ['status']}, |
830 | + '_is_cloudinit_disabled': (False, ''), |
831 | + 'Init': {'side_effect': self.init_class}}, |
832 | + status.main) |
833 | + self.assertEqual(0, context_manager.exception.code) |
834 | + self.assertEqual('status: running\n', m_stdout.getvalue()) |
835 | + |
836 | +# vi: ts=4 expandtab syntax=python |
837 | diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py |
838 | index d5becd1..99e60e7 100755 |
839 | --- a/cloudinit/distros/__init__.py |
840 | +++ b/cloudinit/distros/__init__.py |
841 | @@ -102,11 +102,8 @@ class Distro(object): |
842 | self._apply_hostname(writeable_hostname) |
843 | |
844 | def uses_systemd(self): |
845 | - try: |
846 | - res = os.lstat('/run/systemd/system') |
847 | - return stat.S_ISDIR(res.st_mode) |
848 | - except Exception: |
849 | - return False |
850 | + """Wrapper to report whether this distro uses systemd or sysvinit.""" |
851 | + return uses_systemd() |
852 | |
853 | @abc.abstractmethod |
854 | def package_command(self, cmd, args=None, pkgs=None): |
855 | @@ -761,4 +758,13 @@ def set_etc_timezone(tz, tz_file=None, tz_conf="/etc/timezone", |
856 | util.copy(tz_file, tz_local) |
857 | return |
858 | |
859 | + |
860 | +def uses_systemd(): |
861 | + try: |
862 | + res = os.lstat('/run/systemd/system') |
863 | + return stat.S_ISDIR(res.st_mode) |
864 | + except Exception: |
865 | + return False |
866 | + |
867 | + |
868 | # vi: ts=4 expandtab |
869 | diff --git a/cloudinit/util.py b/cloudinit/util.py |
870 | index 6c014ba..320d64e 100644 |
871 | --- a/cloudinit/util.py |
872 | +++ b/cloudinit/util.py |
873 | @@ -1398,6 +1398,32 @@ def get_output_cfg(cfg, mode): |
874 | return ret |
875 | |
876 | |
877 | +def get_config_logfiles(cfg): |
878 | + """Return a list of log file paths from the configuration dictionary. |
879 | + |
880 | + @param cfg: The cloud-init merged configuration dictionary. |
881 | + """ |
882 | + logs = [] |
883 | + if not cfg or not isinstance(cfg, dict): |
884 | + return logs |
885 | + default_log = cfg.get('def_log_file') |
886 | + if default_log: |
887 | + logs.append(default_log) |
888 | + for fmt in get_output_cfg(cfg, None): |
889 | + if not fmt: |
890 | + continue |
891 | + match = re.match('(?P<type>\||>+)\s*(?P<target>.*)', fmt) |
892 | + if not match: |
893 | + continue |
894 | + target = match.group('target') |
895 | + parts = target.split() |
896 | + if len(parts) == 1: |
897 | + logs.append(target) |
898 | + elif ['tee', '-a'] == parts[:2]: |
899 | + logs.append(parts[2]) |
900 | + return list(set(logs)) |
901 | + |
902 | + |
903 | def logexc(log, msg, *args): |
904 | # Setting this here allows this to change |
905 | # levels easily (not always error level) |
906 | diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py |
907 | index fccbbd2..a8d28ae 100644 |
908 | --- a/tests/unittests/test_cli.py |
909 | +++ b/tests/unittests/test_cli.py |
910 | @@ -2,9 +2,9 @@ |
911 | |
912 | import six |
913 | |
914 | +from cloudinit.cmd import main as cli |
915 | from cloudinit.tests import helpers as test_helpers |
916 | |
917 | -from cloudinit.cmd import main as cli |
918 | |
919 | mock = test_helpers.mock |
920 | |
921 | @@ -45,8 +45,8 @@ class TestCLI(test_helpers.FilesystemMockingTestCase): |
922 | """All known subparsers are represented in the cloud-int help doc.""" |
923 | self._call_main() |
924 | error = self.stderr.getvalue() |
925 | - expected_subcommands = ['analyze', 'init', 'modules', 'single', |
926 | - 'dhclient-hook', 'features', 'devel'] |
927 | + expected_subcommands = ['analyze', 'clean', 'devel', 'dhclient-hook', |
928 | + 'features', 'init', 'modules', 'single'] |
929 | for subcommand in expected_subcommands: |
930 | self.assertIn(subcommand, error) |
931 | |
932 | @@ -76,9 +76,11 @@ class TestCLI(test_helpers.FilesystemMockingTestCase): |
933 | self.patchStdoutAndStderr(stdout=stdout) |
934 | |
935 | expected_errors = [ |
936 | - 'usage: cloud-init analyze', 'usage: cloud-init collect-logs', |
937 | - 'usage: cloud-init devel'] |
938 | - conditional_subcommands = ['analyze', 'collect-logs', 'devel'] |
939 | + 'usage: cloud-init analyze', 'usage: cloud-init clean', |
940 | + 'usage: cloud-init collect-logs', 'usage: cloud-init devel', |
941 | + 'usage: cloud-init status'] |
942 | + conditional_subcommands = [ |
943 | + 'analyze', 'clean', 'collect-logs', 'devel', 'status'] |
944 | # The cloud-init entrypoint calls main without passing sys_argv |
945 | for subcommand in conditional_subcommands: |
946 | with mock.patch('sys.argv', ['cloud-init', subcommand, '-h']): |
947 | @@ -106,6 +108,22 @@ class TestCLI(test_helpers.FilesystemMockingTestCase): |
948 | self._call_main(['cloud-init', 'collect-logs', '-h']) |
949 | self.assertIn('usage: cloud-init collect-log', stdout.getvalue()) |
950 | |
951 | + def test_clean_subcommand_parser(self): |
952 | + """The subcommand cloud-init clean calls the subparser.""" |
953 | + # Provide -h param to clean to avoid having to mock behavior. |
954 | + stdout = six.StringIO() |
955 | + self.patchStdoutAndStderr(stdout=stdout) |
956 | + self._call_main(['cloud-init', 'clean', '-h']) |
957 | + self.assertIn('usage: cloud-init clean', stdout.getvalue()) |
958 | + |
959 | + def test_status_subcommand_parser(self): |
960 | + """The subcommand cloud-init status calls the subparser.""" |
961 | + # Provide -h param to clean to avoid having to mock behavior. |
962 | + stdout = six.StringIO() |
963 | + self.patchStdoutAndStderr(stdout=stdout) |
964 | + self._call_main(['cloud-init', 'status', '-h']) |
965 | + self.assertIn('usage: cloud-init status', stdout.getvalue()) |
966 | + |
967 | def test_devel_subcommand_parser(self): |
968 | """The subcommand cloud-init devel calls the correct subparser.""" |
969 | self._call_main(['cloud-init', 'devel']) |
970 | diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py |
971 | index 3e4154c..71f5952 100644 |
972 | --- a/tests/unittests/test_util.py |
973 | +++ b/tests/unittests/test_util.py |
974 | @@ -477,6 +477,44 @@ class TestReadDMIData(helpers.FilesystemMockingTestCase): |
975 | self.assertIsNone(util.read_dmi_data("system-product-name")) |
976 | |
977 | |
978 | +class TestGetConfigLogfiles(helpers.CiTestCase): |
979 | + |
980 | + def test_empty_cfg_returns_empty_list(self): |
981 | + """An empty config passed to get_config_logfiles returns empty list.""" |
982 | + self.assertEqual([], util.get_config_logfiles(None)) |
983 | + self.assertEqual([], util.get_config_logfiles({})) |
984 | + |
985 | + def test_default_log_file_present(self): |
986 | + """When default_log_file is set get_config_logfiles finds it.""" |
987 | + self.assertEqual( |
988 | + ['/my.log'], |
989 | + util.get_config_logfiles({'def_log_file': '/my.log'})) |
990 | + |
991 | + def test_output_logs_parsed_when_teeing_files(self): |
992 | + """When output configuration is parsed when teeing files.""" |
993 | + self.assertEqual( |
994 | + ['/himom.log', '/my.log'], |
995 | + sorted(util.get_config_logfiles({ |
996 | + 'def_log_file': '/my.log', |
997 | + 'output': {'all': '|tee -a /himom.log'}}))) |
998 | + |
999 | + def test_output_logs_parsed_when_redirecting(self): |
1000 | + """When output configuration is parsed when redirecting to a file.""" |
1001 | + self.assertEqual( |
1002 | + ['/my.log', '/test.log'], |
1003 | + sorted(util.get_config_logfiles({ |
1004 | + 'def_log_file': '/my.log', |
1005 | + 'output': {'all': '>/test.log'}}))) |
1006 | + |
1007 | + def test_output_logs_parsed_when_appending(self): |
1008 | + """When output configuration is parsed when appending to a file.""" |
1009 | + self.assertEqual( |
1010 | + ['/my.log', '/test.log'], |
1011 | + sorted(util.get_config_logfiles({ |
1012 | + 'def_log_file': '/my.log', |
1013 | + 'output': {'all': '>> /test.log'}}))) |
1014 | + |
1015 | + |
1016 | class TestMultiLog(helpers.FilesystemMockingTestCase): |
1017 | |
1018 | def _createConsole(self, root): |
PASSED: Continuous integration, rev:965e55fd772 20461709ec0208e bc524dcfd62919 /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 483/
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
SUCCESS: MAAS Compatability Testing
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild: /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 483/rebuild
https:/