diff options
| -rw-r--r-- | ChangeLog | 2 | ||||
| -rw-r--r-- | cloudinit/config/cc_power_state_change.py | 155 | ||||
| -rw-r--r-- | config/cloud.cfg | 1 | ||||
| -rw-r--r-- | doc/examples/cloud-config.txt | 21 | ||||
| -rw-r--r-- | tests/unittests/test_handler/test_handler_power_state.py | 88 | 
5 files changed, 267 insertions, 0 deletions
| @@ -42,6 +42,8 @@     instead of trying to make additional url calls which will fail (LP: #1068801)   - use a set of helper/parsing classes to perform system configuration     for easier test.  (/etc/sysconfig, /etc/hostname, resolv.conf, /etc/hosts) + - 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 new file mode 100644 index 00000000..aefa3aff --- /dev/null +++ b/cloudinit/config/cc_power_state_change.py @@ -0,0 +1,155 @@ +# vi: ts=4 expandtab +# +#    Copyright (C) 2011 Canonical Ltd. +# +#    Author: Scott Moser <scott.moser@canonical.com> +# +#    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 <http://www.gnu.org/licenses/>. + +from cloudinit.settings import PER_INSTANCE +from cloudinit import util + +import errno +import os +import re +import subprocess +import time + +frequency = PER_INSTANCE + +EXIT_FAIL = 254 + + +def handle(_name, cfg, _cloud, log, _args): + +    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" % mypid) + +    if not cmdline: +        log.warn("power_state: failed to get cmdline of current process") +        return + +    devnull_fp = open(os.devnull, "w") + +    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.") + +    opt_map = {'halt': '-H', 'poweroff': '-P', 'reboot': '-r'} + +    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).") + +    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 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: +        doexit(EXIT_FAIL) +    doexit(ret) + + +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 = None +    end_time = time.time() + timeout + +    cmdline_f = "/proc/%s/cmdline" % pid + +    def fatal(msg): +        if log: +            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 +            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 in known_errnos: +                msg = "pidfile '%s' gone [%d]" % (cmdline_f, ioerr.errno) +            else: +                fatal("IOError during wait: %s" % ioerr) +            break + +        except Exception as e: +            fatal("Unexpected Exception: %s" % e) + +        time.sleep(.25) + +    if not msg: +        fatal("Unexpected error in run_after_pid_gone") + +    if log: +        log.debug(msg) +    func(*args) diff --git a/config/cloud.cfg b/config/cloud.cfg index 15cdb473..d49ef7b8 100644 --- a/config/cloud.cfg +++ b/config/cloud.cfg @@ -69,6 +69,7 @@ cloud_final_modules:   - keys-to-console   - phone-home   - final-message + - power-state-change  # 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..09298655 100644 --- a/doc/examples/cloud-config.txt +++ b/doc/examples/cloud-config.txt @@ -578,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)) | 
