From 11db1e91ddc047728b0161eb1da30e54084ae5eb Mon Sep 17 00:00:00 2001 From: Garrett Holmstrom Date: Tue, 18 Sep 2012 10:27:41 -0700 Subject: Add support for useradd --selinux-user'' --- doc/examples/cloud-config-user-groups.txt | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'doc') diff --git a/doc/examples/cloud-config-user-groups.txt b/doc/examples/cloud-config-user-groups.txt index d0b3e2ff..1da0d717 100644 --- a/doc/examples/cloud-config-user-groups.txt +++ b/doc/examples/cloud-config-user-groups.txt @@ -12,6 +12,7 @@ users: gecos: Foo B. Bar primary-group: foobar groups: users + selinux-user: staff_u expiredate: 2012-09-01 ssh-import-id: foobar lock-passwd: false @@ -38,6 +39,9 @@ users: # primary-group: define the primary group. Defaults to a new group created # named after the user. # groups: Optional. Additional groups to add the user to. Defaults to none +# selinux-user: Optional. The SELinux user for the user's login, such as +# "staff_u". When this is omitted the system will select the default +# SELinux user. # lock-passwd: Defaults to true. Lock the password to disable password login # inactive: Create the user as inactive # passwd: The hash -- not the password itself -- of the password you want -- cgit v1.2.3 From 3912209cdb075b7af8f87c1e41170fd8614ca520 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Wed, 19 Sep 2012 20:19:15 -0400 Subject: doc: document 'tags' as comma delimited strings LP: #1042764 --- doc/examples/cloud-config-landscape.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'doc') diff --git a/doc/examples/cloud-config-landscape.txt b/doc/examples/cloud-config-landscape.txt index 12c8101f..e4d23cc9 100644 --- a/doc/examples/cloud-config-landscape.txt +++ b/doc/examples/cloud-config-landscape.txt @@ -4,10 +4,12 @@ # will be basically rendered into a ConfigObj formated file # under the '[client]' section of /etc/landscape/client.conf # +# Note: 'tags' should be specified as a comma delimited string +# rather than a list. landscape: client: url: "https://landscape.canonical.com/message-system" ping_url: "http://landscape.canonical.com/ping" data_path: "/var/lib/landscape/client" http_proxy: "http://my.proxy.com/foobar" - tags: [ server, cloud ] + tags: "server,cloud" -- cgit v1.2.3 From 62631d30aae55a42b77d326af75d5e476d4baf36 Mon Sep 17 00:00:00 2001 From: Joshua Harlow Date: Fri, 21 Sep 2012 14:15:09 -0700 Subject: 1. Cleanup the user creation so that the distro class is responsible only for creating users and groups and normalizing a input configuration into a normalized format that splits up the user list, the group list and the default user listsand let the add user/group config module handle calling those methods to add its own users/groups and the default user (if any). 2. Also add in tests for this normalization process to ensure that it is pretty bug free and works with the different types of formats that users/groups/defaults + options can take. --- cloudinit/config/cc_users_groups.py | 77 ++++--------- cloudinit/distros/__init__.py | 184 ++++++++++++++++++++++-------- cloudinit/util.py | 16 +++ doc/examples/cloud-config-user-groups.txt | 8 +- 4 files changed, 181 insertions(+), 104 deletions(-) (limited to 'doc') diff --git a/cloudinit/config/cc_users_groups.py b/cloudinit/config/cc_users_groups.py index 418f3330..273c5068 100644 --- a/cloudinit/config/cc_users_groups.py +++ b/cloudinit/config/cc_users_groups.py @@ -16,63 +16,34 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from cloudinit import util + from cloudinit.settings import PER_INSTANCE frequency = PER_INSTANCE def handle(name, cfg, cloud, log, _args): - user_zero = None - - if 'groups' in cfg: - for group in cfg['groups']: - if isinstance(group, dict): - for name, values in group.iteritems(): - if isinstance(values, list): - cloud.distro.create_group(name, values) - elif isinstance(values, str): - cloud.distro.create_group(name, values.split(',')) - else: - cloud.distro.create_group(group, []) - - if 'users' in cfg: - user_zero = None - - for user_config in cfg['users']: - - # Handle the default user creation - if 'default' in user_config: - log.info("Creating default user") - - # Create the default user if so defined - try: - cloud.distro.add_default_user() - - if not user_zero: - user_zero = cloud.distro.get_default_user() - - except NotImplementedError: - - if user_zero == name: - user_zero = None - - log.warn("Distro has not implemented default user " - "creation. No default user will be created") - - elif isinstance(user_config, dict) and 'name' in user_config: - - name = user_config['name'] - if not user_zero: - user_zero = name - - # Make options friendly for distro.create_user - new_opts = {} - if isinstance(user_config, dict): - for opt in user_config: - new_opts[opt.replace('-', '_')] = user_config[opt] - - cloud.distro.create_user(**new_opts) - else: - # create user with no configuration - cloud.distro.create_user(user_config) + distro = cloud.distro + ((users, default_user), groups) = distro.normalize_users_groups(cfg) + for (name, members) in groups.items(): + distro.create_group(name, members) + + if default_user: + user = default_user['name'] + config = default_user['config'] + def_base_config = { + 'name': user, + 'plain_text_passwd': user, + 'home': "/home/%s" % user, + 'shell': "/bin/bash", + 'lock_passwd': True, + 'gecos': "%s%s" % (user.title()), + 'sudo': "ALL=(ALL) NOPASSWD:ALL", + } + u_config = util.mergemanydict([def_base_config, config]) + distro.create_user(**u_config) + + for (user, config) in users.items(): + distro.create_user(user, **config) diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 3e9d934d..4fb1d8c2 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -24,9 +24,7 @@ from StringIO import StringIO import abc -import grp import os -import pwd import re from cloudinit import importer @@ -54,34 +52,6 @@ class Distro(object): self._cfg = cfg self.name = name - def add_default_user(self): - # Adds the distro user using the rules: - # - Password is same as username but is locked - # - nopasswd sudo access - - user = self.get_default_user() - groups = self.get_default_user_groups() - - if not user: - raise NotImplementedError("No Default user") - - user_dict = { - 'name': user, - 'plain_text_passwd': user, - 'home': "/home/%s" % user, - 'shell': "/bin/bash", - 'lock_passwd': True, - 'gecos': "%s%s" % (user[0:1].upper(), user[1:]), - 'sudo': "ALL=(ALL) NOPASSWD:ALL", - } - - if groups: - user_dict['groups'] = groups - - self.create_user(**user_dict) - - LOG.info("Added default '%s' user with passwordless sudo", user) - @abc.abstractmethod def install_packages(self, pkglist): raise NotImplementedError() @@ -204,18 +174,19 @@ class Distro(object): util.logexc(LOG, "Running interface command %s failed", cmd) return False - def isuser(self, name): - try: - if pwd.getpwnam(name): - return True - except KeyError: - return False - def get_default_user(self): return self.default_user def get_default_user_groups(self): - return self.default_user_groups + if not self.default_user_groups: + return [] + def_groups = [] + if isinstance(self.default_user_groups, (str, basestring)): + def_groups = self.default_user_groups.split(",") + else: + def_groups = list(self.default_user_groups) + def_groups = list(sorted(set(def_groups))) + return def_groups def create_user(self, name, **kwargs): """ @@ -272,7 +243,7 @@ class Distro(object): adduser_cmd.append('-m') # Create the user - if self.isuser(name): + if util.is_user(name): LOG.warn("User %s already exists, skipping." % name) else: LOG.debug("Creating name %s" % name) @@ -323,6 +294,130 @@ class Distro(object): return True + def _normalize_groups(self, grp_cfg): + groups = {} + if isinstance(grp_cfg, (str, basestring)): + grp_cfg = grp_cfg.strip().split(",") + + if isinstance(grp_cfg, (list)): + for g in grp_cfg: + g = g.strip() + if g: + groups[g] = [] + elif isinstance(grp_cfg, (dict)): + for grp_name, grp_members in grp_cfg.items(): + if isinstance(grp_members, (str, basestring)): + r_grp_members = [] + for gc in grp_members.strip().split(','): + gc = gc.strip() + if gc and gc not in r_grp_members: + r_grp_members.append(gc) + grp_members = r_grp_members + elif not isinstance(grp_members, (list)): + raise TypeError(("Group member config must be list " + " or string types only and not %s") % + util.obj_name(grp_members)) + groups[grp_name] = grp_members + else: + raise TypeError(("Group config must be list, dict " + " or string types only and not %s") % + util.obj_name(grp_cfg)) + return groups + + def _normalize_users(self, u_cfg): + if isinstance(u_cfg, (dict)): + ad_ucfg = [] + for (k, v) in u_cfg.items(): + if isinstance(v, (bool, int, basestring, str)): + if util.is_true(v): + ad_ucfg.append(str(k)) + elif isinstance(v, (dict)): + v['name'] = k + ad_ucfg.append(v) + else: + raise TypeError(("Unmappable user value type %s" + " for key %s") % (util.obj_name(v), k)) + u_cfg = ad_ucfg + + users = {} + for user_config in u_cfg: + if isinstance(user_config, (str, basestring)): + for u in user_config.strip().split(","): + u = u.strip() + if u and u not in users: + users[u] = {} + elif isinstance(user_config, (dict)): + if 'name' in user_config: + n = user_config.pop('name') + prev_config = users.get(n) or {} + users[n] = util.mergemanydict([prev_config, + user_config]) + else: + # Assume the default user then + prev_config = users.get('default') or {} + users['default'] = util.mergemanydict([prev_config, + user_config]) + elif isinstance(user_config, (bool, int)): + pass + else: + raise TypeError(("User config must be dictionary " + " or string types only and not %s") % + util.obj_name(user_config)) + + # Ensure user options are in the right python friendly format + if users: + c_users = {} + for (uname, uconfig) in users.items(): + c_uconfig = {} + for (k, v) in uconfig.items(): + k = k.replace('-', '_').strip() + if k: + c_uconfig[k] = v + c_users[uname] = c_uconfig + users = c_users + + # Fixup the default user into the real + # default user name and extract it + default_user = {} + if users and 'default' in users: + try: + def_config = users.pop('default') + def_user = self.get_default_user() + def_groups = self.get_default_user_groups() + if def_user: + u_config = users.pop(def_user, None) or {} + u_groups = u_config.get('groups') or [] + if isinstance(u_groups, (str, basestring)): + u_groups = u_groups.strip().split(",") + u_groups.extend(def_groups) + u_groups = set([x.strip() for x in u_groups if x.strip()]) + u_config['groups'] = ",".join(sorted(u_groups)) + default_user = { + 'name': def_user, + 'config': util.mergemanydict([def_config, u_config]), + } + else: + LOG.warn(("Distro has not provided a default user " + "creation. No default user will be normalized.")) + users.pop('default', None) + except NotImplementedError: + LOG.warn(("Distro has not implemented default user " + "creation. No default user will be normalized.")) + users.pop('default', None) + + return (default_user, users) + + def normalize_users_groups(self, ug_cfg): + users = {} + groups = {} + default_user = {} + if 'groups' in ug_cfg: + groups = self._normalize_groups(ug_cfg['groups']) + + if 'users' in ug_cfg: + default_user, users = self._normalize_users(ug_cfg['users']) + return ((users, default_user), groups) + def write_sudo_rules(self, user, rules, @@ -349,18 +444,11 @@ class Distro(object): util.logexc(LOG, "Failed to write %s" % sudo_file, e) raise e - def isgroup(self, name): - try: - if grp.getgrnam(name): - return True - except: - return False - def create_group(self, name, members): group_add_cmd = ['groupadd', name] # Check if group exists, and then add it doesn't - if self.isgroup(name): + if util.is_group(name): LOG.warn("Skipping creation of existing group '%s'" % name) else: try: @@ -372,7 +460,7 @@ class Distro(object): # Add members to the group, if so defined if len(members) > 0: for member in members: - if not self.isuser(member): + if not util.is_user(member): LOG.warn("Unable to add group member '%s' to group '%s'" "; user does not exist." % (member, name)) continue diff --git a/cloudinit/util.py b/cloudinit/util.py index 33da73eb..94b17dfa 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -1104,6 +1104,22 @@ def hash_blob(blob, routine, mlen=None): return digest +def is_user(name): + try: + if pwd.getpwnam(name): + return True + except KeyError: + return False + + +def is_group(name): + try: + if grp.getgrnam(name): + return True + except KeyError: + return False + + def rename(src, dest): LOG.debug("Renaming %s to %s", src, dest) # TODO(harlowja) use a se guard here?? diff --git a/doc/examples/cloud-config-user-groups.txt b/doc/examples/cloud-config-user-groups.txt index 1da0d717..073fbd8f 100644 --- a/doc/examples/cloud-config-user-groups.txt +++ b/doc/examples/cloud-config-user-groups.txt @@ -81,14 +81,16 @@ users: # directive. # system: Create the user as a system user. This means no home directory. # -# Default user creation: Ubuntu Only -# Unless you define users, you will get a Ubuntu user on Ubuntu systems with the +# Default user creation: +# +# Unless you define users, you will get a 'ubuntu' user on buntu systems with the # legacy permission (no password sudo, locked user, etc). If however, you want # to have the ubuntu user in addition to other users, you need to instruct # cloud-init that you also want the default user. To do this use the following # syntax: # users: -# default: True +# - default +# - bob # foobar: ... # # users[0] (the first user in users) overrides the user directive. -- cgit v1.2.3 From 009faa0546ffbcadbbcaa9692d6842890e6f2e10 Mon Sep 17 00:00:00 2001 From: Joshua Harlow Date: Fri, 21 Sep 2012 15:12:11 -0700 Subject: Fix some docs + pylint warnings + log on default created in the module. --- cloudinit/config/cc_users_groups.py | 5 ++--- cloudinit/distros/__init__.py | 3 ++- doc/examples/cloud-config-user-groups.txt | 10 ++++++---- .../test_distros/test_user_data_normalize.py | 22 +++++++++++----------- 4 files changed, 21 insertions(+), 19 deletions(-) (limited to 'doc') diff --git a/cloudinit/config/cc_users_groups.py b/cloudinit/config/cc_users_groups.py index 273c5068..a6ce49ac 100644 --- a/cloudinit/config/cc_users_groups.py +++ b/cloudinit/config/cc_users_groups.py @@ -24,7 +24,6 @@ frequency = PER_INSTANCE def handle(name, cfg, cloud, log, _args): - distro = cloud.distro ((users, default_user), groups) = distro.normalize_users_groups(cfg) for (name, members) in groups.items(): @@ -34,7 +33,6 @@ def handle(name, cfg, cloud, log, _args): user = default_user['name'] config = default_user['config'] def_base_config = { - 'name': user, 'plain_text_passwd': user, 'home': "/home/%s" % user, 'shell': "/bin/bash", @@ -43,7 +41,8 @@ def handle(name, cfg, cloud, log, _args): 'sudo': "ALL=(ALL) NOPASSWD:ALL", } u_config = util.mergemanydict([def_base_config, config]) - distro.create_user(**u_config) + distro.create_user(user, **u_config) + log.info("Added default '%s' user with passwordless sudo", user) for (user, config) in users.items(): distro.create_user(user, **config) diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 361d2c05..3de5be36 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -400,7 +400,8 @@ class Distro(object): } else: LOG.warn(("Distro has not provided a default user " - "creation. No default user will be normalized.")) + "for creation. No default user will be " + "normalized.")) users.pop('default', None) except NotImplementedError: LOG.warn(("Distro has not implemented default user " diff --git a/doc/examples/cloud-config-user-groups.txt b/doc/examples/cloud-config-user-groups.txt index 073fbd8f..1a46c540 100644 --- a/doc/examples/cloud-config-user-groups.txt +++ b/doc/examples/cloud-config-user-groups.txt @@ -1,11 +1,11 @@ -# add groups to the system +# Add groups to the system # The following example adds the ubuntu group with members foo and bar and # the group cloud-users. groups: - ubuntu: [foo,bar] - cloud-users -# add users to the system. Users are added after groups are added. +# Add users to the system. Users are added after groups are added. users: - default - name: foobar @@ -81,16 +81,18 @@ users: # directive. # system: Create the user as a system user. This means no home directory. # + # Default user creation: # -# Unless you define users, you will get a 'ubuntu' user on buntu systems with the +# Unless you define users, you will get a 'ubuntu' user on ubuntu systems with the # legacy permission (no password sudo, locked user, etc). If however, you want -# to have the ubuntu user in addition to other users, you need to instruct +# to have the 'ubuntu' user in addition to other users, you need to instruct # cloud-init that you also want the default user. To do this use the following # syntax: # users: # - default # - bob +# - .... # foobar: ... # # users[0] (the first user in users) overrides the user directive. diff --git a/tests/unittests/test_distros/test_user_data_normalize.py b/tests/unittests/test_distros/test_user_data_normalize.py index caf479cd..d636bb84 100644 --- a/tests/unittests/test_distros/test_user_data_normalize.py +++ b/tests/unittests/test_distros/test_user_data_normalize.py @@ -78,21 +78,21 @@ class TestUGNormalize(MockerTestCase): 'default': True, } } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((_users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertEquals('bob', def_user['name']) ug_cfg = { 'users': { 'default': 'yes', } } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((_users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertEquals('bob', def_user['name']) ug_cfg = { 'users': { 'default': '1', } } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((_users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertEquals('bob', def_user['name']) def test_users_simple_dict_no(self): @@ -102,14 +102,14 @@ class TestUGNormalize(MockerTestCase): 'default': False, } } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((_users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertEquals({}, def_user) ug_cfg = { 'users': { 'default': 'no', } } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((_users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertEquals({}, def_user) def test_users_simple_csv(self): @@ -117,7 +117,7 @@ class TestUGNormalize(MockerTestCase): ug_cfg = { 'users': 'joe,bob', } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((users, _def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertIn('joe', users) self.assertIn('bob', users) self.assertEquals({}, users['joe']) @@ -131,7 +131,7 @@ class TestUGNormalize(MockerTestCase): 'bob' ], } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((users, _def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertIn('joe', users) self.assertIn('bob', users) self.assertEquals({}, users['joe']) @@ -144,7 +144,7 @@ class TestUGNormalize(MockerTestCase): {'name': 'default', 'blah': True} ], } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertIn('bob', def_user['name']) self.assertEquals(",".join(distro.get_default_user_groups()), def_user['config']['groups']) @@ -159,7 +159,7 @@ class TestUGNormalize(MockerTestCase): 'default', ], } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((users, def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertIn('bob', def_user['name']) self.assertEquals(",".join(distro.get_default_user_groups()), def_user['config']['groups']) @@ -174,7 +174,7 @@ class TestUGNormalize(MockerTestCase): {'name': 'bob'}, ], } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((users, _def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertIn('joe', users) self.assertIn('bob', users) self.assertEquals({'tr_me': True}, users['joe']) @@ -188,7 +188,7 @@ class TestUGNormalize(MockerTestCase): {'name': 'bob'}, ], } - ((users, def_user), groups) = distro.normalize_users_groups(ug_cfg) + ((users, _def_user), _groups) = distro.normalize_users_groups(ug_cfg) self.assertIn('joe', users) self.assertIn('bob', users) self.assertEquals({}, users['joe']) -- cgit v1.2.3 From 180620470ba9aae4aac804b8bd66d3af8bd71ee4 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Wed, 7 Nov 2012 10:29:20 -0500 Subject: adjust documentation to account for apt/package aliases --- doc/examples/cloud-config.txt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) (limited to 'doc') diff --git a/doc/examples/cloud-config.txt b/doc/examples/cloud-config.txt index 56a6c35a..04bb5df1 100644 --- a/doc/examples/cloud-config.txt +++ b/doc/examples/cloud-config.txt @@ -3,15 +3,20 @@ # (ie run apt-get update) # # Default: true -# -apt_update: false +# Aliases: apt_update +package_update: false # Upgrade the instance on first boot # (ie run apt-get upgrade) # # Default: false -# -apt_upgrade: true +# Aliases: apt_upgrade +package_upgrade: true + +# Reboot after package install/update if necessary +# Default: false +# Aliases: apt_reboot_if_required +package_reboot_if_required: true # Add apt repositories # -- cgit v1.2.3 From 7ea02ab1d8ee0f400a84ee2d688e67ffbc449bf0 Mon Sep 17 00:00:00 2001 From: Joshua Harlow Date: Wed, 7 Nov 2012 21:00:33 -0800 Subject: Add a makefile yaml checking target and fix the cases where the cc yaml is not correct. --- Makefile | 9 ++++++++- doc/examples/cloud-config.txt | 3 +-- tests/configs/sample1.yaml | 1 - tools/validate-yaml.py | 20 ++++++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100755 tools/validate-yaml.py (limited to 'doc') diff --git a/Makefile b/Makefile index 88c90b9b..2a6be961 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ CWD=$(shell pwd) PY_FILES=$(shell find cloudinit bin tests tools -name "*.py" -type f ) PY_FILES+="bin/cloud-init" +YAML_FILES=$(shell find cloudinit bin tests tools -name "*.yaml" -type f ) +YAML_FILES+=$(shell find doc/examples -name "cloud-config*.txt" -type f ) + + all: test pep8: @@ -23,11 +27,14 @@ clean: rm -rf /var/log/cloud-init.log \ /var/lib/cloud/ +yaml: + @$(CWD)/tools/validate-yaml.py $(YAML_FILES) + rpm: ./packages/brpm deb: ./packages/bddeb -.PHONY: test pylint pyflakes 2to3 clean pep8 rpm deb +.PHONY: test pylint pyflakes 2to3 clean pep8 rpm deb yaml diff --git a/doc/examples/cloud-config.txt b/doc/examples/cloud-config.txt index 04bb5df1..12bf2c91 100644 --- a/doc/examples/cloud-config.txt +++ b/doc/examples/cloud-config.txt @@ -355,8 +355,7 @@ rsyslog: - ':syslogtag, isequal, "[CLOUDINIT]" /var/log/cloud-foo.log' - content: "*.* @@192.0.2.1:10514" - filename: 01-examplecom.conf - content: | - *.* @@syslogd.example.com + content: "*.* @@syslogd.example.com" # resize_rootfs should the / filesytem be resized on first boot # this allows you to launch an instance with a larger disk / partition diff --git a/tests/configs/sample1.yaml b/tests/configs/sample1.yaml index 24e874ee..6231f293 100644 --- a/tests/configs/sample1.yaml +++ b/tests/configs/sample1.yaml @@ -50,4 +50,3 @@ runcmd: byobu_by_default: user -output: {all: '| tee -a /var/log/cloud-init-output.log'} diff --git a/tools/validate-yaml.py b/tools/validate-yaml.py new file mode 100755 index 00000000..d3218e40 --- /dev/null +++ b/tools/validate-yaml.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python + +"""Try to read a YAML file and report any errors. +""" + +import sys + +import yaml + + +if __name__ == "__main__": + for fn in sys.argv[1:]: + sys.stdout.write("%s" % (fn)) + try: + fh = open(fn, 'r') + yaml.safe_load(fh.read()) + fh.close() + sys.stdout.write(" - ok\n") + except Exception, e: + sys.stdout.write(" - bad (%s)\n" % (e)) -- cgit v1.2.3 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 (limited to 'doc') 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 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 (limited to 'doc') 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 cbd1ca764ed265460c3a79729a27ca8e3841390c Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Wed, 12 Dec 2012 10:39:43 -0500 Subject: add 'omnibus' as an install type for chef. Thanks to Anatoliy Dobrosynets --- ChangeLog | 1 + cloudinit/config/cc_chef.py | 15 ++++++++++++++- doc/examples/cloud-config-chef.txt | 9 ++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) (limited to 'doc') diff --git a/ChangeLog b/ChangeLog index fbfd3385..af1e024d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,7 @@ - fix sudoers writing when entry is a string (LP: #1079002) - tools/write-ssh-key-fingerprints: use '-s' rather than '--stderr' option (LP: #1083715) + - support omnibus installer for chef [Anatoliy Dobrosynets] 0.7.1: - sysvinit: fix missing dependency in cloud-init job for RHEL 5.6 - config-drive: map hostname to local-hostname (LP: #1061964) diff --git a/cloudinit/config/cc_chef.py b/cloudinit/config/cc_chef.py index 7a3d6a31..607f789e 100644 --- a/cloudinit/config/cc_chef.py +++ b/cloudinit/config/cc_chef.py @@ -22,6 +22,7 @@ import json import os from cloudinit import templater +from cloudinit import url_helper from cloudinit import util RUBY_VERSION_DEFAULT = "1.8" @@ -35,6 +36,8 @@ CHEF_DIRS = [ '/var/run/chef', ] +OMNIBUS_URL = "https://www.opscode.com/chef/install.sh" + def handle(name, cfg, cloud, log, _args): @@ -83,7 +86,9 @@ def handle(name, cfg, cloud, log, _args): util.write_file('/etc/chef/firstboot.json', json.dumps(initial_json)) # If chef is not installed, we install chef based on 'install_type' - if not os.path.isfile('/usr/bin/chef-client'): + if (not os.path.isfile('/usr/bin/chef-client') or + util.get_cfg_option_bool(chef_cfg, 'force_install', default=False)): + install_type = util.get_cfg_option_str(chef_cfg, 'install_type', 'packages') if install_type == "gems": @@ -99,6 +104,14 @@ def handle(name, cfg, cloud, log, _args): elif install_type == 'packages': # this will install and run the chef-client from packages cloud.distro.install_packages(('chef',)) + elif install_type == 'omnibus': + url = util.get_cfg_option_str(chef_cfg, "omnibus_url", OMNIBUS_URL) + content = url_helper.readurl(url=url, retries=5) + with util.tempdir() as tmpd: + # use tmpd over tmpfile to avoid 'Text file busy' on execute + tmpf = "%s/chef-omnibus-install" % tmpd + util.write_file(tmpf, content, mode=0700) + util.subp([tmpf], capture=False) else: log.warn("Unknown chef install type %s", install_type) diff --git a/doc/examples/cloud-config-chef.txt b/doc/examples/cloud-config-chef.txt index f87472ec..4edad653 100644 --- a/doc/examples/cloud-config-chef.txt +++ b/doc/examples/cloud-config-chef.txt @@ -47,9 +47,13 @@ apt_sources: chef: - # Valid values are 'gems' and 'packages' + # Valid values are 'gems' and 'packages' and 'omnibus' install_type: "packages" + # Boolean: run 'install_type' code even if chef-client + # appears already installed. + force_install: false + # Chef settings server_url: "https://chef.yourorg.com:4000" @@ -80,6 +84,9 @@ chef: maxclients: 100 keepalive: "off" + # if install_type is 'omnibus', change the url to download + omnibus_url: "https://www.opscode.com/chef/install.sh" + # Capture all subprocess output into a logfile # Useful for troubleshooting cloud-init issues -- cgit v1.2.3