Merge ~chad.smith/cloud-init:clean-status-commands into cloud-init:master

Proposed by Chad Smith
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)
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

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.

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

PASSED: Continuous integration, rev:965e55fd77220461709ec0208ebc524dcfd62919
https://jenkins.ubuntu.com/server/job/cloud-init-ci/483/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/483/rebuild

review: Approve (continuous-integration)
Revision history for this message
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/cloud-init.disabled.

to test that, just touch /etc/cloud/cloud-init.disabled
and reboot

review: Needs Fixing
Revision history for this message
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

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:0ea787b32e66c2764e97cd74871b6dc0db1e081f
https://jenkins.ubuntu.com/server/job/cloud-init-ci/488/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/488/rebuild

review: Approve (continuous-integration)
Revision history for this message
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

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:38999e541fc45e5a8f6af3d52b5939ea51cc9b59
https://jenkins.ubuntu.com/server/job/cloud-init-ci/497/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/497/rebuild

review: Needs Fixing (continuous-integration)
001c52a... by Chad Smith

flakes

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:001c52a8078d7bb36afecdbf6dac31a27e3ae518
https://jenkins.ubuntu.com/server/job/cloud-init-ci/498/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/498/rebuild

review: Approve (continuous-integration)
f5633b9... by Chad Smith

add unit tests for disabled by /etc/cloud/cloud-init.disabled and kernel cmdline

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:f5633b91c025619e1446965f1a6591590051f212
https://jenkins.ubuntu.com/server/job/cloud-init-ci/499/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/499/rebuild

review: Needs Fixing (continuous-integration)
e80b2cb... by Chad Smith

mock os.path.exists instead of mocking the CLOUDINIT_DISABLED_FILE

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:e80b2cbeb3c543d064f8bf0917ca7d1a0c5f75fa
https://jenkins.ubuntu.com/server/job/cloud-init-ci/500/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/500/rebuild

review: Approve (continuous-integration)
ee2d62c... by Chad Smith

add systemctl disabled check to cloud-init status

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:ee2d62c41d2d087b90c44a1d7788ae852a603157
https://jenkins.ubuntu.com/server/job/cloud-init-ci/502/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/502/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

some comments on a probably older version.
re-reviwing here shortly.

Revision history for this message
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

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:b08ee4ad44c7861c5dceaf3f861a5dd058083e17
https://jenkins.ubuntu.com/server/job/cloud-init-ci/518/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/518/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

one small request.
other than that i approve.

i'll try to test some tomorrow with this.

Revision history for this message
Scott Moser (smoser) wrote :

I launched a container (bionic)
installed a deb from your branch (17.1-43-gb08ee4ad-1~bddeb)

then:

root@b1:~# cat /run/cloud-init/result.json
{
 "v1": {
  "datasource": "DataSourceNoCloud [seed=/var/lib/cloud/seed/nocloud-net][dsmode=net]",
  "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

Revision history for this message
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

Revision history for this message
Chad Smith (chad.smith) wrote :

Addressed review comment, shuffling uses_systemd out of Distros.uses_systemd and into a standalone function. Also per our discussions in IRC about systemd commandline utilities shortcomings on <= Zesty, we can no longer look at "systemctl is-enabled cloud-init.target". Using your suggestions @ http://paste.ubuntu.com/26067095/, we now just check presence of /etc/cloud/cloud-init.disabled, kernel cmdline contianing "cloud-init=disabled" or absence of /run/cloud-init/enabled to determine whether cloud-init is disabled.

95acd47... by Chad Smith

use util.write_file in unit tests to handle py3 py2-isms dealing with byte strings

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:14e9ab84581f9ae34f3ffd932e9c48994759b708
https://jenkins.ubuntu.com/server/job/cloud-init-ci/554/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/554/rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:95acd47699c9669d805881838267b45c0e7fcce6
https://jenkins.ubuntu.com/server/job/cloud-init-ci/556/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/556/rebuild

review: Needs Fixing (continuous-integration)
d04460a... by Chad Smith

grr flakes

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:d04460aee056c04f4db5c1d83a8317d0778fe0d7
https://jenkins.ubuntu.com/server/job/cloud-init-ci/557/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/557/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

Chad, this looks good.
Thanks.
i had one thing inline that i'd like fixed (kernel commandline cloud-init=enabled),
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

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:59929ff7190e573642056f810af1ad9775db4608
https://jenkins.ubuntu.com/server/job/cloud-init-ci/564/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/564/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

Can we replace 'print' calls that are for error purposes with a 'print_error' function.

http://paste.ubuntu.com/26121207/

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

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:5609ca047823ec70a82a5a2e521e32cd5c3632ce
https://jenkins.ubuntu.com/server/job/cloud-init-ci/587/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/587/rebuild

review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/cmd/clean.py b/cloudinit/cmd/clean.py
2new file mode 100644
3index 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
109diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
110index 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
145diff --git a/cloudinit/cmd/status.py b/cloudinit/cmd/status.py
146new file mode 100644
147index 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
308diff --git a/cloudinit/cmd/tests/__init__.py b/cloudinit/cmd/tests/__init__.py
309new file mode 100644
310index 0000000..e69de29
311--- /dev/null
312+++ b/cloudinit/cmd/tests/__init__.py
313diff --git a/cloudinit/cmd/tests/test_clean.py b/cloudinit/cmd/tests/test_clean.py
314new file mode 100644
315index 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
478diff --git a/cloudinit/cmd/tests/test_status.py b/cloudinit/cmd/tests/test_status.py
479new file mode 100644
480index 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
837diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
838index 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
869diff --git a/cloudinit/util.py b/cloudinit/util.py
870index 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)
906diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
907index 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'])
970diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
971index 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):

Subscribers

People subscribed via source and target branches