From 949e1759342b1e60c100855aaf250165bcb9997e Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Mon, 12 Nov 2012 22:15:29 -0500 Subject: add 'finalcmd' module for running code after cloud-init-final This allows the user to easily run stuff even after cloud-init-final has finished. The initial reason for it is to be able to run /sbin/poweroff and not have cloud-init complain loudly that it is being killed. LP: #1064665 --- ChangeLog | 2 + cloudinit/config/cc_finalcmd.py | 139 ++++++++++++++++++++++++++++++++++++++++ config/cloud.cfg | 1 + doc/examples/cloud-config.txt | 18 ++++++ 4 files changed, 160 insertions(+) create mode 100644 cloudinit/config/cc_finalcmd.py diff --git a/ChangeLog b/ChangeLog index 4cae8b32..93c3af04 100644 --- a/ChangeLog +++ b/ChangeLog @@ -46,6 +46,8 @@ dictionary and force it to full expand so that if cloud-init blocks the ec2 metadata port the lazy loaded dictionary will continue working properly instead of trying to make additional url calls which will fail (LP: #1068801) + - add 'finalcmd' config module to execute 'finalcmd' entries like + 'runcmd' but detached from cloud-init (LP: #1064665) 0.7.0: - add a 'exception_cb' argument to 'wait_for_url'. If provided, this method will be called back with the exception received and the message. diff --git a/cloudinit/config/cc_finalcmd.py b/cloudinit/config/cc_finalcmd.py new file mode 100644 index 00000000..442ad12b --- /dev/null +++ b/cloudinit/config/cc_finalcmd.py @@ -0,0 +1,139 @@ +# vi: ts=4 expandtab +# +# Copyright (C) 2011 Canonical Ltd. +# +# Author: Scott Moser +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from cloudinit.settings import PER_INSTANCE +from cloudinit import util + +import errno +import os +import subprocess +import sys +import time + +frequency = PER_INSTANCE + + +def handle(_name, cfg, _cloud, log, _args): + + finalcmds = cfg.get("finalcmd") + + if not finalcmds: + log.debug("No final commands") + return + + mypid = os.getpid() + cmdline = util.load_file("/proc/%s/cmdline") + + if not cmdline: + log.warn("Failed to get cmdline of current process") + return + + try: + timeout = float(cfg.get("finalcmd_timeout", 30.0)) + except ValueError: + log.warn("failed to convert finalcmd_timeout '%s' to float" % + cfg.get("finalcmd_timeout", 30.0)) + return + + devnull_fp = open("/dev/null", "w") + + shellcode = util.shellify(finalcmds) + + # note, after the fork, we do not use any of cloud-init's functions + # that would attempt to log. The primary reason for that is + # to allow the 'finalcmd' the ability to do just about anything + # and not depend on syslog services. + # Basically, it should "just work" to have finalcmd of: + # - sleep 30 + # - /sbin/poweroff + finalcmd_d = os.path.join(cloud.get_ipath_cur(), "finalcmds") + + util.fork_cb(run_after_pid_gone, mypid, cmdline, timeout, + runfinal, (shellcode, finalcmd_d, devnull_fp)) + + +def execmd(exe_args, data_in=None, output=None): + try: + proc = subprocess.Popen(exe_args, stdin=subprocess.PIPE, + stdout=output, stderr=subprocess.STDERR) + proc.communicate(data_in) + except Exception as e: + return 254 + return proc.returncode() + + +def runfinal(shellcode, finalcmd_d, output=None): + ret = execmd(("/bin/sh",), data_in=shellcode, output=output) + if not (finalcmd_d and os.path.isdir(finalcmd_d)): + sys.exit(ret) + + fails = 0 + if ret != 0: + fails = 1 + + # now runparts the final command dir + for exe_name in sorted(os.listdir(finalcmd_d)): + exe_path = os.path.join(finalcmd_d, exe_name) + if os.path.isfile(exe_path) and os.access(exe_path, os.X_OK): + ret = execmd(exe_path, data_in=None, output=output) + if ret != 0: + fails += 1 + sys.exit(fails) + + +def run_after_pid_gone(pid, pidcmdline, timeout, func, args): + # wait until pid, with /proc/pid/cmdline contents of pidcmdline + # is no longer alive. After it is gone, or timeout has passed + # execute func(args) + msg = "ERROR: Uncaught error" + end_time = time.time() + timeout + + cmdline_f = "/proc/%s/cmdline" % pid + + while True: + if time.time() > end_time: + msg = "timeout reached before %s ended" % pid + break + + try: + cmdline = "" + with open(cmdline_f) as fp: + cmdline = fp.read() + if cmdline != pidcmdline: + msg = "cmdline changed for %s [now: %s]" % (pid, cmdline) + break + + except IOError as ioerr: + if ioerr.errno == errno.ENOENT: + msg = "pidfile '%s' gone" % cmdline_f + else: + msg = "ERROR: IOError: %s" % ioerr + raise + break + + except Exception as e: + msg = "ERROR: Exception: %s" % e + raise + + if msg.startswith("ERROR:"): + sys.stderr.write(msg) + sys.stderr.write("Not executing finalcmd") + sys.exit(1) + + sys.stderr.write("calling %s with %s\n" % (func, args)) + sys.exit(func(*args)) diff --git a/config/cloud.cfg b/config/cloud.cfg index ad100fff..249a593d 100644 --- a/config/cloud.cfg +++ b/config/cloud.cfg @@ -69,6 +69,7 @@ cloud_final_modules: - keys-to-console - phone-home - final-message + - finalcmd # System and/or distro specific settings # (not accessible to handlers/transforms) diff --git a/doc/examples/cloud-config.txt b/doc/examples/cloud-config.txt index 12bf2c91..4fc5f351 100644 --- a/doc/examples/cloud-config.txt +++ b/doc/examples/cloud-config.txt @@ -256,6 +256,24 @@ bootcmd: - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts - [ cloud-init-per, once, mymkfs, mkfs, /dev/vdb ] +# final commands +# default: none +# This can be used to execute commands after and fully detached from +# a cloud-init stage. The initial purpose of it was to allow 'poweroff' +# detached from cloud-init. If poweroff was run from 'runcmd' or userdata +# then messages may be spewed from cloud-init about logging failing or other +# issues as a result of the system being turned off. +# +# You probably are better off using 'runcmd' for this. +# +# The output of finalcmd will redirected redirected to /dev/null +# If you want output to be seen, take care to do so in your commands +# themselves. See example. +finalcmd: + - sleep 30 + - "echo $(date -R): powering off > /dev/console" + - /sbin/poweroff + # cloud_config_modules: # default: # cloud_config_modules: -- cgit v1.2.3 From 546b9444158d00875100fdd523fccae76226f346 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Mon, 12 Nov 2012 22:21:20 -0500 Subject: pass execmd a list, not tuple or string --- cloudinit/config/cc_finalcmd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudinit/config/cc_finalcmd.py b/cloudinit/config/cc_finalcmd.py index 442ad12b..d921a444 100644 --- a/cloudinit/config/cc_finalcmd.py +++ b/cloudinit/config/cc_finalcmd.py @@ -78,7 +78,7 @@ def execmd(exe_args, data_in=None, output=None): def runfinal(shellcode, finalcmd_d, output=None): - ret = execmd(("/bin/sh",), data_in=shellcode, output=output) + ret = execmd(["/bin/sh",], data_in=shellcode, output=output) if not (finalcmd_d and os.path.isdir(finalcmd_d)): sys.exit(ret) @@ -90,7 +90,7 @@ def runfinal(shellcode, finalcmd_d, output=None): for exe_name in sorted(os.listdir(finalcmd_d)): exe_path = os.path.join(finalcmd_d, exe_name) if os.path.isfile(exe_path) and os.access(exe_path, os.X_OK): - ret = execmd(exe_path, data_in=None, output=output) + ret = execmd([exe_path], data_in=None, output=output) if ret != 0: fails += 1 sys.exit(fails) -- cgit v1.2.3 From 71928e8c70900843dce4aa3ae84fcd278e4b887a Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 08:57:57 -0500 Subject: rename module 'finalcmd' to power-state-change --- cloudinit/config/cc_finalcmd.py | 139 ------------------------------ cloudinit/config/cc_power_state_change.py | 139 ++++++++++++++++++++++++++++++ config/cloud.cfg | 2 +- 3 files changed, 140 insertions(+), 140 deletions(-) delete mode 100644 cloudinit/config/cc_finalcmd.py create mode 100644 cloudinit/config/cc_power_state_change.py diff --git a/cloudinit/config/cc_finalcmd.py b/cloudinit/config/cc_finalcmd.py deleted file mode 100644 index d921a444..00000000 --- a/cloudinit/config/cc_finalcmd.py +++ /dev/null @@ -1,139 +0,0 @@ -# vi: ts=4 expandtab -# -# Copyright (C) 2011 Canonical Ltd. -# -# Author: Scott Moser -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3, as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from cloudinit.settings import PER_INSTANCE -from cloudinit import util - -import errno -import os -import subprocess -import sys -import time - -frequency = PER_INSTANCE - - -def handle(_name, cfg, _cloud, log, _args): - - finalcmds = cfg.get("finalcmd") - - if not finalcmds: - log.debug("No final commands") - return - - mypid = os.getpid() - cmdline = util.load_file("/proc/%s/cmdline") - - if not cmdline: - log.warn("Failed to get cmdline of current process") - return - - try: - timeout = float(cfg.get("finalcmd_timeout", 30.0)) - except ValueError: - log.warn("failed to convert finalcmd_timeout '%s' to float" % - cfg.get("finalcmd_timeout", 30.0)) - return - - devnull_fp = open("/dev/null", "w") - - shellcode = util.shellify(finalcmds) - - # note, after the fork, we do not use any of cloud-init's functions - # that would attempt to log. The primary reason for that is - # to allow the 'finalcmd' the ability to do just about anything - # and not depend on syslog services. - # Basically, it should "just work" to have finalcmd of: - # - sleep 30 - # - /sbin/poweroff - finalcmd_d = os.path.join(cloud.get_ipath_cur(), "finalcmds") - - util.fork_cb(run_after_pid_gone, mypid, cmdline, timeout, - runfinal, (shellcode, finalcmd_d, devnull_fp)) - - -def execmd(exe_args, data_in=None, output=None): - try: - proc = subprocess.Popen(exe_args, stdin=subprocess.PIPE, - stdout=output, stderr=subprocess.STDERR) - proc.communicate(data_in) - except Exception as e: - return 254 - return proc.returncode() - - -def runfinal(shellcode, finalcmd_d, output=None): - ret = execmd(["/bin/sh",], data_in=shellcode, output=output) - if not (finalcmd_d and os.path.isdir(finalcmd_d)): - sys.exit(ret) - - fails = 0 - if ret != 0: - fails = 1 - - # now runparts the final command dir - for exe_name in sorted(os.listdir(finalcmd_d)): - exe_path = os.path.join(finalcmd_d, exe_name) - if os.path.isfile(exe_path) and os.access(exe_path, os.X_OK): - ret = execmd([exe_path], data_in=None, output=output) - if ret != 0: - fails += 1 - sys.exit(fails) - - -def run_after_pid_gone(pid, pidcmdline, timeout, func, args): - # wait until pid, with /proc/pid/cmdline contents of pidcmdline - # is no longer alive. After it is gone, or timeout has passed - # execute func(args) - msg = "ERROR: Uncaught error" - end_time = time.time() + timeout - - cmdline_f = "/proc/%s/cmdline" % pid - - while True: - if time.time() > end_time: - msg = "timeout reached before %s ended" % pid - break - - try: - cmdline = "" - with open(cmdline_f) as fp: - cmdline = fp.read() - if cmdline != pidcmdline: - msg = "cmdline changed for %s [now: %s]" % (pid, cmdline) - break - - except IOError as ioerr: - if ioerr.errno == errno.ENOENT: - msg = "pidfile '%s' gone" % cmdline_f - else: - msg = "ERROR: IOError: %s" % ioerr - raise - break - - except Exception as e: - msg = "ERROR: Exception: %s" % e - raise - - if msg.startswith("ERROR:"): - sys.stderr.write(msg) - sys.stderr.write("Not executing finalcmd") - sys.exit(1) - - sys.stderr.write("calling %s with %s\n" % (func, args)) - sys.exit(func(*args)) diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py new file mode 100644 index 00000000..d921a444 --- /dev/null +++ b/cloudinit/config/cc_power_state_change.py @@ -0,0 +1,139 @@ +# vi: ts=4 expandtab +# +# Copyright (C) 2011 Canonical Ltd. +# +# Author: Scott Moser +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from cloudinit.settings import PER_INSTANCE +from cloudinit import util + +import errno +import os +import subprocess +import sys +import time + +frequency = PER_INSTANCE + + +def handle(_name, cfg, _cloud, log, _args): + + finalcmds = cfg.get("finalcmd") + + if not finalcmds: + log.debug("No final commands") + return + + mypid = os.getpid() + cmdline = util.load_file("/proc/%s/cmdline") + + if not cmdline: + log.warn("Failed to get cmdline of current process") + return + + try: + timeout = float(cfg.get("finalcmd_timeout", 30.0)) + except ValueError: + log.warn("failed to convert finalcmd_timeout '%s' to float" % + cfg.get("finalcmd_timeout", 30.0)) + return + + devnull_fp = open("/dev/null", "w") + + shellcode = util.shellify(finalcmds) + + # note, after the fork, we do not use any of cloud-init's functions + # that would attempt to log. The primary reason for that is + # to allow the 'finalcmd' the ability to do just about anything + # and not depend on syslog services. + # Basically, it should "just work" to have finalcmd of: + # - sleep 30 + # - /sbin/poweroff + finalcmd_d = os.path.join(cloud.get_ipath_cur(), "finalcmds") + + util.fork_cb(run_after_pid_gone, mypid, cmdline, timeout, + runfinal, (shellcode, finalcmd_d, devnull_fp)) + + +def execmd(exe_args, data_in=None, output=None): + try: + proc = subprocess.Popen(exe_args, stdin=subprocess.PIPE, + stdout=output, stderr=subprocess.STDERR) + proc.communicate(data_in) + except Exception as e: + return 254 + return proc.returncode() + + +def runfinal(shellcode, finalcmd_d, output=None): + ret = execmd(["/bin/sh",], data_in=shellcode, output=output) + if not (finalcmd_d and os.path.isdir(finalcmd_d)): + sys.exit(ret) + + fails = 0 + if ret != 0: + fails = 1 + + # now runparts the final command dir + for exe_name in sorted(os.listdir(finalcmd_d)): + exe_path = os.path.join(finalcmd_d, exe_name) + if os.path.isfile(exe_path) and os.access(exe_path, os.X_OK): + ret = execmd([exe_path], data_in=None, output=output) + if ret != 0: + fails += 1 + sys.exit(fails) + + +def run_after_pid_gone(pid, pidcmdline, timeout, func, args): + # wait until pid, with /proc/pid/cmdline contents of pidcmdline + # is no longer alive. After it is gone, or timeout has passed + # execute func(args) + msg = "ERROR: Uncaught error" + end_time = time.time() + timeout + + cmdline_f = "/proc/%s/cmdline" % pid + + while True: + if time.time() > end_time: + msg = "timeout reached before %s ended" % pid + break + + try: + cmdline = "" + with open(cmdline_f) as fp: + cmdline = fp.read() + if cmdline != pidcmdline: + msg = "cmdline changed for %s [now: %s]" % (pid, cmdline) + break + + except IOError as ioerr: + if ioerr.errno == errno.ENOENT: + msg = "pidfile '%s' gone" % cmdline_f + else: + msg = "ERROR: IOError: %s" % ioerr + raise + break + + except Exception as e: + msg = "ERROR: Exception: %s" % e + raise + + if msg.startswith("ERROR:"): + sys.stderr.write(msg) + sys.stderr.write("Not executing finalcmd") + sys.exit(1) + + sys.stderr.write("calling %s with %s\n" % (func, args)) + sys.exit(func(*args)) diff --git a/config/cloud.cfg b/config/cloud.cfg index 249a593d..c1d8ea0d 100644 --- a/config/cloud.cfg +++ b/config/cloud.cfg @@ -69,7 +69,7 @@ cloud_final_modules: - keys-to-console - phone-home - final-message - - finalcmd + - power-state-change # System and/or distro specific settings # (not accessible to handlers/transforms) -- cgit v1.2.3 From 58886d757ebd832e8c0de45981a51242370d40fc Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 08:59:21 -0500 Subject: pep8 and pylint --- cloudinit/config/cc_power_state_change.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index d921a444..67e0316b 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -28,7 +28,7 @@ import time frequency = PER_INSTANCE -def handle(_name, cfg, _cloud, log, _args): +def handle(_name, cfg, cloud, log, _args): finalcmds = cfg.get("finalcmd") @@ -70,15 +70,15 @@ def handle(_name, cfg, _cloud, log, _args): def execmd(exe_args, data_in=None, output=None): try: proc = subprocess.Popen(exe_args, stdin=subprocess.PIPE, - stdout=output, stderr=subprocess.STDERR) + stdout=output, stderr=subprocess.STDOUT) proc.communicate(data_in) - except Exception as e: + except Exception: return 254 return proc.returncode() def runfinal(shellcode, finalcmd_d, output=None): - ret = execmd(["/bin/sh",], data_in=shellcode, output=output) + ret = execmd(["/bin/sh"], data_in=shellcode, output=output) if not (finalcmd_d and os.path.isdir(finalcmd_d)): sys.exit(ret) -- cgit v1.2.3 From 2113e89b6816d2c9d442103698414cd189ca3412 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 11:18:22 -0500 Subject: implement power_state with tests. --- ChangeLog | 4 +- cloudinit/config/cc_power_state_change.py | 122 +++++++++++---------- doc/examples/cloud-config.txt | 39 ++++--- .../test_handler/test_handler_power_state.py | 88 +++++++++++++++ 4 files changed, 175 insertions(+), 78 deletions(-) create mode 100644 tests/unittests/test_handler/test_handler_power_state.py diff --git a/ChangeLog b/ChangeLog index 57bfc1a4..23612228 100644 --- a/ChangeLog +++ b/ChangeLog @@ -43,8 +43,8 @@ - Added dependency on distribute's python-pkg-resources - use a set of helper/parsing classes to perform system configuration for easier test. (/etc/sysconfig, /etc/hostname, resolv.conf, /etc/hosts) - - add 'finalcmd' config module to execute 'finalcmd' entries like - 'runcmd' but detached from cloud-init (LP: #1064665) + - add power_state_change config module for shutting down stystem after + cloud-init finishes. (LP: #1064665) 0.7.0: - add a 'exception_cb' argument to 'wait_for_url'. If provided, this method will be called back with the exception received and the message. diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index 67e0316b..07de548c 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -21,6 +21,7 @@ from cloudinit import util import errno import os +import re import subprocess import sys import time @@ -28,83 +29,91 @@ import time frequency = PER_INSTANCE -def handle(_name, cfg, cloud, log, _args): +def handle(_name, cfg, _cloud, log, _args): - finalcmds = cfg.get("finalcmd") - - if not finalcmds: - log.debug("No final commands") + try: + (args, timeout) = load_power_state(cfg) + if args is None: + log.debug("no power_state provided. doing nothing") + return + except Exception as e: + log.warn("%s Not performing power state change!" % str(e)) return mypid = os.getpid() cmdline = util.load_file("/proc/%s/cmdline") if not cmdline: - log.warn("Failed to get cmdline of current process") - return - - try: - timeout = float(cfg.get("finalcmd_timeout", 30.0)) - except ValueError: - log.warn("failed to convert finalcmd_timeout '%s' to float" % - cfg.get("finalcmd_timeout", 30.0)) + log.warn("power_state: failed to get cmdline of current process") return devnull_fp = open("/dev/null", "w") - shellcode = util.shellify(finalcmds) + log.debug("After pid %s ends, will execute: %s" % (mypid, ' '.join(args))) + + util.fork_cb(run_after_pid_gone, mypid, cmdline, timeout, log, execmd, + [args, devnull_fp]) + + +def load_power_state(cfg): + # returns a tuple of shutdown_command, timeout + # shutdown_command is None if no config found + pstate = cfg.get('power_state') + + if pstate is None: + return (None, None) + + if not isinstance(pstate, dict): + raise TypeError("power_state is not a dict.") - # note, after the fork, we do not use any of cloud-init's functions - # that would attempt to log. The primary reason for that is - # to allow the 'finalcmd' the ability to do just about anything - # and not depend on syslog services. - # Basically, it should "just work" to have finalcmd of: - # - sleep 30 - # - /sbin/poweroff - finalcmd_d = os.path.join(cloud.get_ipath_cur(), "finalcmds") + opt_map = {'halt': '-H', 'poweroff': '-P', 'reboot': '-r'} - util.fork_cb(run_after_pid_gone, mypid, cmdline, timeout, - runfinal, (shellcode, finalcmd_d, devnull_fp)) + mode = pstate.get("mode") + if mode not in opt_map: + raise TypeError("power_state[mode] required, must be one of: %s." % + ','.join(opt_map.keys())) + delay = pstate.get("delay", "now") + if delay != "now" and not re.match("\+[0-9]+", delay): + raise TypeError("power_state[delay] must be 'now' or '+m' (minutes).") -def execmd(exe_args, data_in=None, output=None): + args = ["shutdown", opt_map[mode], delay] + if pstate.get("message"): + args.append(pstate.get("message")) + + try: + timeout = float(pstate.get('timeout', 30.0)) + except ValueError: + raise ValueError("failed to convert timeout '%s' to float." % + pstate['timeout']) + + return (args, timeout) + + +def execmd(exe_args, output=None, data_in=None): try: proc = subprocess.Popen(exe_args, stdin=subprocess.PIPE, stdout=output, stderr=subprocess.STDOUT) proc.communicate(data_in) except Exception: - return 254 - return proc.returncode() + sys.exit(254) + sys.exit(proc.returncode()) -def runfinal(shellcode, finalcmd_d, output=None): - ret = execmd(["/bin/sh"], data_in=shellcode, output=output) - if not (finalcmd_d and os.path.isdir(finalcmd_d)): - sys.exit(ret) - - fails = 0 - if ret != 0: - fails = 1 - - # now runparts the final command dir - for exe_name in sorted(os.listdir(finalcmd_d)): - exe_path = os.path.join(finalcmd_d, exe_name) - if os.path.isfile(exe_path) and os.access(exe_path, os.X_OK): - ret = execmd([exe_path], data_in=None, output=output) - if ret != 0: - fails += 1 - sys.exit(fails) - - -def run_after_pid_gone(pid, pidcmdline, timeout, func, args): +def run_after_pid_gone(pid, pidcmdline, timeout, log, func, args): # wait until pid, with /proc/pid/cmdline contents of pidcmdline # is no longer alive. After it is gone, or timeout has passed # execute func(args) - msg = "ERROR: Uncaught error" + msg = None end_time = time.time() + timeout cmdline_f = "/proc/%s/cmdline" % pid + def fatal(msg): + if log: + log.warn(msg) + sys.exit(254) + while True: if time.time() > end_time: msg = "timeout reached before %s ended" % pid @@ -122,18 +131,15 @@ def run_after_pid_gone(pid, pidcmdline, timeout, func, args): if ioerr.errno == errno.ENOENT: msg = "pidfile '%s' gone" % cmdline_f else: - msg = "ERROR: IOError: %s" % ioerr - raise + fatal("IOError during wait: %s" % ioerr) break except Exception as e: - msg = "ERROR: Exception: %s" % e - raise + fatal("Unexpected Exception: %s" % e) - if msg.startswith("ERROR:"): - sys.stderr.write(msg) - sys.stderr.write("Not executing finalcmd") - sys.exit(1) + if not msg: + fatal("Unexpected error in run_after_pid_gone") - sys.stderr.write("calling %s with %s\n" % (func, args)) - sys.exit(func(*args)) + if log: + log.debug(msg) + func(*args) diff --git a/doc/examples/cloud-config.txt b/doc/examples/cloud-config.txt index 4fc5f351..09298655 100644 --- a/doc/examples/cloud-config.txt +++ b/doc/examples/cloud-config.txt @@ -256,24 +256,6 @@ bootcmd: - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts - [ cloud-init-per, once, mymkfs, mkfs, /dev/vdb ] -# final commands -# default: none -# This can be used to execute commands after and fully detached from -# a cloud-init stage. The initial purpose of it was to allow 'poweroff' -# detached from cloud-init. If poweroff was run from 'runcmd' or userdata -# then messages may be spewed from cloud-init about logging failing or other -# issues as a result of the system being turned off. -# -# You probably are better off using 'runcmd' for this. -# -# The output of finalcmd will redirected redirected to /dev/null -# If you want output to be seen, take care to do so in your commands -# themselves. See example. -finalcmd: - - sleep 30 - - "echo $(date -R): powering off > /dev/console" - - /sbin/poweroff - # cloud_config_modules: # default: # cloud_config_modules: @@ -596,3 +578,24 @@ manual_cache_clean: False # A list of key types (first token of a /etc/ssh/ssh_key_*.pub file) # that should be skipped when outputting key fingerprints and keys # to the console respectively. + +## poweroff or reboot system after finished +# default: none +# +# power_state can be used to make the system shutdown, reboot or +# halt after boot is finished. This same thing can be acheived by +# user-data scripts or by runcmd by simply invoking 'shutdown'. +# +# Doing it this way ensures that cloud-init is entirely finished with +# modules that would be executed, and avoids any error/log messages +# that may go to the console as a result of system services like +# syslog being taken down while cloud-init is running. +# +# delay: form accepted by shutdown. default is 'now'. other format +# accepted is +m (m in minutes) +# mode: required. must be one of 'poweroff', 'halt', 'reboot' +# message: provided as the message argument to 'shutdown'. default is none. +power_state: + delay: 30 + mode: poweroff + message: Bye Bye diff --git a/tests/unittests/test_handler/test_handler_power_state.py b/tests/unittests/test_handler/test_handler_power_state.py new file mode 100644 index 00000000..1149fedc --- /dev/null +++ b/tests/unittests/test_handler/test_handler_power_state.py @@ -0,0 +1,88 @@ +from unittest import TestCase + +from cloudinit.config import cc_power_state_change as psc + + +class TestLoadPowerState(TestCase): + def setUp(self): + super(self.__class__, self).setUp() + + def test_no_config(self): + # completely empty config should mean do nothing + (cmd, _timeout) = psc.load_power_state({}) + self.assertEqual(cmd, None) + + def test_irrelevant_config(self): + # no power_state field in config should return None for cmd + (cmd, _timeout) = psc.load_power_state({'foo': 'bar'}) + self.assertEqual(cmd, None) + + def test_invalid_mode(self): + cfg = {'power_state': {'mode': 'gibberish'}} + self.assertRaises(TypeError, psc.load_power_state, cfg) + + cfg = {'power_state': {'mode': ''}} + self.assertRaises(TypeError, psc.load_power_state, cfg) + + def test_empty_mode(self): + cfg = {'power_state': {'message': 'goodbye'}} + self.assertRaises(TypeError, psc.load_power_state, cfg) + + def test_valid_modes(self): + cfg = {'power_state': {}} + for mode in ('halt', 'poweroff', 'reboot'): + cfg['power_state']['mode'] = mode + check_lps_ret(psc.load_power_state(cfg), mode=mode) + + def test_invalid_delay(self): + cfg = {'power_state': {'mode': 'poweroff', 'delay': 'goodbye'}} + self.assertRaises(TypeError, psc.load_power_state, cfg) + + def test_valid_delay(self): + cfg = {'power_state': {'mode': 'poweroff', 'delay': ''}} + for delay in ("now", "+1", "+30"): + cfg['power_state']['delay'] = delay + check_lps_ret(psc.load_power_state(cfg)) + + def test_message_present(self): + cfg = {'power_state': {'mode': 'poweroff', 'message': 'GOODBYE'}} + ret = psc.load_power_state(cfg) + check_lps_ret(psc.load_power_state(cfg)) + self.assertIn(cfg['power_state']['message'], ret[0]) + + def test_no_message(self): + # if message is not present, then no argument should be passed for it + cfg = {'power_state': {'mode': 'poweroff'}} + (cmd, _timeout) = psc.load_power_state(cfg) + self.assertNotIn("", cmd) + check_lps_ret(psc.load_power_state(cfg)) + self.assertTrue(len(cmd) == 3) + + +def check_lps_ret(psc_return, mode=None): + if len(psc_return) != 2: + raise TypeError("length returned = %d" % len(psc_return)) + + errs = [] + cmd = psc_return[0] + timeout = psc_return[1] + + if not 'shutdown' in psc_return[0][0]: + errs.append("string 'shutdown' not in cmd") + + if mode is not None: + opt = {'halt': '-H', 'poweroff': '-P', 'reboot': '-r'}[mode] + if opt not in psc_return[0]: + errs.append("opt '%s' not in cmd: %s" % (opt, cmd)) + + if len(cmd) != 3 and len(cmd) != 4: + errs.append("Invalid command length: %s" % len(cmd)) + + try: + float(timeout) + except: + errs.append("timeout failed convert to float") + + if len(errs): + lines = ["Errors in result: %s" % str(psc_return)] + errs + raise Exception('\n'.join(lines)) -- cgit v1.2.3 From d8b4f9245331cb8b6f04d63a3a62e45563486512 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 13:09:45 -0500 Subject: fix read of /proc/cmdline --- cloudinit/config/cc_power_state_change.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index 07de548c..7f6e780d 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -41,7 +41,7 @@ def handle(_name, cfg, _cloud, log, _args): return mypid = os.getpid() - cmdline = util.load_file("/proc/%s/cmdline") + cmdline = util.load_file("/proc/%s/cmdline" % mypid) if not cmdline: log.warn("power_state: failed to get cmdline of current process") -- cgit v1.2.3 From efd02682c7cfb054490308443f7f9facf83363b5 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 13:24:37 -0500 Subject: clean up exit to single function call --- cloudinit/config/cc_power_state_change.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index 7f6e780d..22f1aade 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -28,6 +28,8 @@ import time frequency = PER_INSTANCE +EXIT_FAIL = 254 + def handle(_name, cfg, _cloud, log, _args): @@ -90,14 +92,19 @@ def load_power_state(cfg): return (args, timeout) +def doexit(sysexit): + os._exit(sysexit) # pylint: disable=W0212 + + def execmd(exe_args, output=None, data_in=None): try: proc = subprocess.Popen(exe_args, stdin=subprocess.PIPE, stdout=output, stderr=subprocess.STDOUT) proc.communicate(data_in) + ret = proc.returncode except Exception: - sys.exit(254) - sys.exit(proc.returncode()) + doexit(EXIT_FAIL) + doexit(ret) def run_after_pid_gone(pid, pidcmdline, timeout, log, func, args): @@ -112,7 +119,7 @@ def run_after_pid_gone(pid, pidcmdline, timeout, log, func, args): def fatal(msg): if log: log.warn(msg) - sys.exit(254) + doexit(EXIT_FAIL) while True: if time.time() > end_time: -- cgit v1.2.3 From 8239df4493d81db1d245eaa51fdfe5458e2d1e4f Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 14:02:14 -0500 Subject: add sleep, allow errno '3' (ESRCH) on the open of /proc/pid/cmdline The sleep is added here simply to not completely spin cpu on waiting for the parent pid to die. the allowing of errno 3 is because I was getting this. I dont have a perfect explanation, but I suspect that the 'open' was actually getting this back from the /proc filesystem after the pid had died. Possibly in the window between when the 'open' was done and the 'read()' was done. --- cloudinit/config/cc_power_state_change.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index 22f1aade..02434322 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -121,6 +121,8 @@ def run_after_pid_gone(pid, pidcmdline, timeout, log, func, args): log.warn(msg) doexit(EXIT_FAIL) + known_errnos = (errno.ENOENT, errno.ESRCH) + while True: if time.time() > end_time: msg = "timeout reached before %s ended" % pid @@ -135,8 +137,8 @@ def run_after_pid_gone(pid, pidcmdline, timeout, log, func, args): break except IOError as ioerr: - if ioerr.errno == errno.ENOENT: - msg = "pidfile '%s' gone" % cmdline_f + if ioerr.errno in known_errnos: + msg = "pidfile '%s' gone [%d]" % (cmdline_f, ioerr.errno) else: fatal("IOError during wait: %s" % ioerr) break @@ -144,6 +146,8 @@ def run_after_pid_gone(pid, pidcmdline, timeout, log, func, args): except Exception as e: fatal("Unexpected Exception: %s" % e) + time.sleep(.25) + if not msg: fatal("Unexpected error in run_after_pid_gone") -- cgit v1.2.3 From df38cb7a9f99fbb5fdaddeb08e43d74af30372c8 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 13 Nov 2012 14:15:07 -0500 Subject: use os.devnull [trivial] --- cloudinit/config/cc_power_state_change.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index 02434322..a2e6920d 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -49,7 +49,7 @@ def handle(_name, cfg, _cloud, log, _args): log.warn("power_state: failed to get cmdline of current process") return - devnull_fp = open("/dev/null", "w") + devnull_fp = open(os.devnull, "w") log.debug("After pid %s ends, will execute: %s" % (mypid, ' '.join(args))) -- cgit v1.2.3