summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ChangeLog2
-rw-r--r--cloudinit/config/cc_power_state_change.py155
-rw-r--r--config/cloud.cfg1
-rw-r--r--doc/examples/cloud-config.txt21
-rw-r--r--tests/unittests/test_handler/test_handler_power_state.py88
5 files changed, 267 insertions, 0 deletions
diff --git a/ChangeLog b/ChangeLog
index 3735b1e1..cc0dc607 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -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))