Skip to content

Commit 30b4d15

Browse files
committed
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.
1 parent 4701679 commit 30b4d15

10 files changed

Lines changed: 888 additions & 11 deletions

File tree

cloudinit/cmd/clean.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (C) 2017 Canonical Ltd.
2+
#
3+
# This file is part of cloud-init. See LICENSE file for license information.
4+
5+
"""Define 'clean' utility and handler as part of cloud-init commandline."""
6+
7+
import argparse
8+
import os
9+
import sys
10+
11+
from cloudinit.stages import Init
12+
from cloudinit.util import (
13+
ProcessExecutionError, chdir, del_dir, del_file, get_config_logfiles, subp)
14+
15+
16+
def error(msg):
17+
sys.stderr.write("ERROR: " + msg + "\n")
18+
19+
20+
def get_parser(parser=None):
21+
"""Build or extend an arg parser for clean utility.
22+
23+
@param parser: Optional existing ArgumentParser instance representing the
24+
clean subcommand which will be extended to support the args of
25+
this utility.
26+
27+
@returns: ArgumentParser with proper argument configuration.
28+
"""
29+
if not parser:
30+
parser = argparse.ArgumentParser(
31+
prog='clean',
32+
description=('Remove logs and artifacts so cloud-init re-runs on '
33+
'a clean system'))
34+
parser.add_argument(
35+
'-l', '--logs', action='store_true', default=False, dest='remove_logs',
36+
help='Remove cloud-init logs.')
37+
parser.add_argument(
38+
'-r', '--reboot', action='store_true', default=False,
39+
help='Reboot system after logs are cleaned so cloud-init re-runs.')
40+
parser.add_argument(
41+
'-s', '--seed', action='store_true', default=False, dest='remove_seed',
42+
help='Remove cloud-init seed directory /var/lib/cloud/seed.')
43+
return parser
44+
45+
46+
def remove_artifacts(remove_logs, remove_seed=False):
47+
"""Helper which removes artifacts dir and optionally log files.
48+
49+
@param: remove_logs: Boolean. Set True to delete the cloud_dir path. False
50+
preserves them.
51+
@param: remove_seed: Boolean. Set True to also delete seed subdir in
52+
paths.cloud_dir.
53+
@returns: 0 on success, 1 otherwise.
54+
"""
55+
init = Init(ds_deps=[])
56+
init.read_cfg()
57+
if remove_logs:
58+
for log_file in get_config_logfiles(init.cfg):
59+
del_file(log_file)
60+
61+
if not os.path.isdir(init.paths.cloud_dir):
62+
return 0 # Artifacts dir already cleaned
63+
with chdir(init.paths.cloud_dir):
64+
for path in os.listdir('.'):
65+
if path == 'seed' and not remove_seed:
66+
continue
67+
try:
68+
if os.path.isdir(path):
69+
del_dir(path)
70+
else:
71+
del_file(path)
72+
except OSError as e:
73+
error('Could not remove {0}: {1}'.format(path, str(e)))
74+
return 1
75+
return 0
76+
77+
78+
def handle_clean_args(name, args):
79+
"""Handle calls to 'cloud-init clean' as a subcommand."""
80+
exit_code = remove_artifacts(args.remove_logs, args.remove_seed)
81+
if exit_code == 0 and args.reboot:
82+
cmd = ['shutdown', '-r', 'now']
83+
try:
84+
subp(cmd, capture=False)
85+
except ProcessExecutionError as e:
86+
error(
87+
'Could not reboot this system using "{0}": {1}'.format(
88+
cmd, str(e)))
89+
exit_code = 1
90+
return exit_code
91+
92+
93+
def main():
94+
"""Tool to collect and tar all cloud-init related logs."""
95+
parser = get_parser()
96+
sys.exit(handle_clean_args('clean', parser.parse_args()))
97+
98+
99+
if __name__ == '__main__':
100+
main()
101+
102+
# vi: ts=4 expandtab

cloudinit/cmd/main.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,12 @@ def main(sysv_args=None):
767767
parser_collect_logs = subparsers.add_parser(
768768
'collect-logs', help='Collect and tar all cloud-init debug info')
769769

770+
parser_clean = subparsers.add_parser(
771+
'clean', help='Remove logs and artifacts so cloud-init can re-run.')
772+
773+
parser_status = subparsers.add_parser(
774+
'status', help='Report cloud-init status or wait on completion.')
775+
770776
if sysv_args:
771777
# Only load subparsers if subcommand is specified to avoid load cost
772778
if sysv_args[0] == 'analyze':
@@ -783,6 +789,18 @@ def main(sysv_args=None):
783789
logs_parser(parser_collect_logs)
784790
parser_collect_logs.set_defaults(
785791
action=('collect-logs', handle_collect_logs_args))
792+
elif sysv_args[0] == 'clean':
793+
from cloudinit.cmd.clean import (
794+
get_parser as clean_parser, handle_clean_args)
795+
clean_parser(parser_clean)
796+
parser_clean.set_defaults(
797+
action=('clean', handle_clean_args))
798+
elif sysv_args[0] == 'status':
799+
from cloudinit.cmd.status import (
800+
get_parser as status_parser, handle_status_args)
801+
status_parser(parser_status)
802+
parser_status.set_defaults(
803+
action=('status', handle_status_args))
786804

787805
args = parser.parse_args(args=sysv_args)
788806

cloudinit/cmd/status.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Copyright (C) 2017 Canonical Ltd.
2+
#
3+
# This file is part of cloud-init. See LICENSE file for license information.
4+
5+
"""Define 'status' utility and handler as part of cloud-init commandline."""
6+
7+
import argparse
8+
import os
9+
import sys
10+
from time import gmtime, strftime, sleep
11+
12+
from cloudinit.distros import uses_systemd
13+
from cloudinit.stages import Init
14+
from cloudinit.util import get_cmdline, load_file, load_json
15+
16+
CLOUDINIT_DISABLED_FILE = '/etc/cloud/cloud-init.disabled'
17+
18+
# customer visible status messages
19+
STATUS_ENABLED_NOT_RUN = 'not run'
20+
STATUS_RUNNING = 'running'
21+
STATUS_DONE = 'done'
22+
STATUS_ERROR = 'error'
23+
STATUS_DISABLED = 'disabled'
24+
25+
26+
def get_parser(parser=None):
27+
"""Build or extend an arg parser for status utility.
28+
29+
@param parser: Optional existing ArgumentParser instance representing the
30+
status 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='status',
38+
description='Report run status of cloud init')
39+
parser.add_argument(
40+
'-l', '--long', action='store_true', default=False,
41+
help=('Report long format of statuses including run stage name and'
42+
' error messages'))
43+
parser.add_argument(
44+
'-w', '--wait', action='store_true', default=False,
45+
help='Block waiting on cloud-init to complete')
46+
return parser
47+
48+
49+
def handle_status_args(name, args):
50+
"""Handle calls to 'cloud-init status' as a subcommand."""
51+
# Read configured paths
52+
init = Init(ds_deps=[])
53+
init.read_cfg()
54+
55+
status, status_detail, time = _get_status_details(init.paths)
56+
if args.wait:
57+
while status in (STATUS_ENABLED_NOT_RUN, STATUS_RUNNING):
58+
sys.stdout.write('.')
59+
sys.stdout.flush()
60+
status, status_detail, time = _get_status_details(init.paths)
61+
sleep(0.25)
62+
sys.stdout.write('\n')
63+
if args.long:
64+
print('status: {0}'.format(status))
65+
if time:
66+
print('time: {0}'.format(time))
67+
print('detail:\n{0}'.format(status_detail))
68+
else:
69+
print('status: {0}'.format(status))
70+
return 1 if status == STATUS_ERROR else 0
71+
72+
73+
def _is_cloudinit_disabled(disable_file, paths):
74+
"""Report whether cloud-init is disabled.
75+
76+
@param disable_file: The path to the cloud-init disable file.
77+
@param paths: An initialized cloudinit.helpers.Paths object.
78+
@returns: A tuple containing (bool, reason) about cloud-init's status and
79+
why.
80+
"""
81+
is_disabled = False
82+
cmdline_parts = get_cmdline().split()
83+
if not uses_systemd():
84+
reason = 'Cloud-init enabled on sysvinit'
85+
elif 'cloud-init=enabled' in cmdline_parts:
86+
reason = 'Cloud-init enabled by kernel command line cloud-init=enabled'
87+
elif os.path.exists(disable_file):
88+
is_disabled = True
89+
reason = 'Cloud-init disabled by {0}'.format(disable_file)
90+
elif 'cloud-init=disabled' in cmdline_parts:
91+
is_disabled = True
92+
reason = 'Cloud-init disabled by kernel parameter cloud-init=disabled'
93+
elif not os.path.exists(os.path.join(paths.run_dir, 'enabled')):
94+
is_disabled = True
95+
reason = 'Cloud-init disabled by cloud-init-generator'
96+
return (is_disabled, reason)
97+
98+
99+
def _get_status_details(paths):
100+
"""Return a 3-tuple of status, status_details and time of last event.
101+
102+
@param paths: An initialized cloudinit.helpers.paths object.
103+
104+
Values are obtained from parsing paths.run_dir/status.json.
105+
"""
106+
107+
status = STATUS_ENABLED_NOT_RUN
108+
status_detail = ''
109+
status_v1 = {}
110+
111+
status_file = os.path.join(paths.run_dir, 'status.json')
112+
113+
(is_disabled, reason) = _is_cloudinit_disabled(
114+
CLOUDINIT_DISABLED_FILE, paths)
115+
if is_disabled:
116+
status = STATUS_DISABLED
117+
status_detail = reason
118+
if os.path.exists(status_file):
119+
status_v1 = load_json(load_file(status_file)).get('v1', {})
120+
errors = []
121+
latest_event = 0
122+
for key, value in sorted(status_v1.items()):
123+
if key == 'stage':
124+
if value:
125+
status_detail = 'Running in stage: {0}'.format(value)
126+
elif key == 'datasource':
127+
status_detail = value
128+
elif isinstance(value, dict):
129+
errors.extend(value.get('errors', []))
130+
finished = value.get('finished') or 0
131+
if finished == 0:
132+
status = STATUS_RUNNING
133+
event_time = max(value.get('start', 0), finished)
134+
if event_time > latest_event:
135+
latest_event = event_time
136+
if errors:
137+
status = STATUS_ERROR
138+
status_detail = '\n'.join(errors)
139+
elif status == STATUS_ENABLED_NOT_RUN and latest_event > 0:
140+
status = STATUS_DONE
141+
if latest_event:
142+
time = strftime('%a, %d %b %Y %H:%M:%S %z', gmtime(latest_event))
143+
else:
144+
time = ''
145+
return status, status_detail, time
146+
147+
148+
def main():
149+
"""Tool to report status of cloud-init."""
150+
parser = get_parser()
151+
sys.exit(handle_status_args('status', parser.parse_args()))
152+
153+
154+
if __name__ == '__main__':
155+
main()
156+
157+
# vi: ts=4 expandtab

cloudinit/cmd/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)