From e2085dc02669b9e25510edba7b4014b007af4ca5 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Thu, 28 Jun 2018 16:25:39 +0200 Subject: [PATCH] Support creating users via configdrive Change-Id: I93259ccd98d4153f1ed370541e2208124ddb0ca1 --- metalsmith/_cmd.py | 5 + metalsmith/_config.py | 33 ++++++ metalsmith/test/test_cmd.py | 53 +++++++++ metalsmith/test/test_config.py | 104 ++++++++++++++++++ roles/metalsmith_deployment/defaults/main.yml | 1 + roles/metalsmith_deployment/tasks/main.yml | 4 + 6 files changed, 200 insertions(+) create mode 100644 metalsmith/test/test_config.py diff --git a/metalsmith/_cmd.py b/metalsmith/_cmd.py index 1ada7c2..2d2a671 100644 --- a/metalsmith/_cmd.py +++ b/metalsmith/_cmd.py @@ -53,6 +53,8 @@ def _do_deploy(api, args, formatter): raise RuntimeError("%s cannot be used as a hostname" % args.hostname) config = _config.InstanceConfig(ssh_keys=ssh_keys) + if args.user_name: + config.add_user(args.user_name, sudo=args.passwordless_sudo) node = api.reserve_node(args.resource_class, capabilities=capabilities) instance = api.provision_node(node, @@ -124,6 +126,9 @@ def _parse_args(args, config): 'Node\'s name or UUID') deploy.add_argument('--resource-class', required=True, help='node resource class to deploy') + deploy.add_argument('--user-name', help='Name of the admin user to create') + deploy.add_argument('--passwordless-sudo', action='store_true', + help='allow password-less sudo for the user') undeploy = subparsers.add_parser('undeploy') undeploy.set_defaults(func=_do_undeploy) diff --git a/metalsmith/_config.py b/metalsmith/_config.py index ab76a28..8511da3 100644 --- a/metalsmith/_config.py +++ b/metalsmith/_config.py @@ -27,10 +27,34 @@ class InstanceConfig(object): to the instance's first boot script (e.g. cloud-init). :ivar ssh_keys: List of SSH public keys. + :ivar users: Users to add on first boot. """ def __init__(self, ssh_keys=None): self.ssh_keys = ssh_keys or [] + self.users = [] + + def add_user(self, name, admin=True, password_hash=None, sudo=False, + **kwargs): + """Add a user to be created on first boot. + + :param name: user name. + :param admin: whether to add the user to the admin group (wheel). + :param password_hash: user password hash, if password authentication + is expected. + :param sudo: whether to allow the user sudo without password. + :param kwargs: other arguments to pass. + """ + kwargs['name'] = name + if admin: + kwargs.setdefault('groups', []).append('wheel') + if password_hash: + kwargs['passwd'] = password_hash + if sudo: + kwargs['sudo'] = 'ALL=(ALL) NOPASSWD:ALL' + if self.ssh_keys: + kwargs.setdefault('ssh_authorized_keys', self.ssh_keys) + self.users.append(kwargs) @contextlib.contextmanager def build_configdrive_directory(self, node, hostname): @@ -50,6 +74,10 @@ class InstanceConfig(object): 'availability_zone': '', 'files': [], 'meta': {}} + user_data = {} + if self.users: + user_data['users'] = self.users + for version in ('2012-08-10', 'latest'): subdir = os.path.join(d, 'openstack', version) if not os.path.exists(subdir): @@ -58,6 +86,11 @@ class InstanceConfig(object): with open(os.path.join(subdir, 'meta_data.json'), 'w') as fp: json.dump(metadata, fp) + if user_data: + with open(os.path.join(subdir, 'user_data'), 'w') as fp: + fp.write("#cloud-config\n") + json.dump(user_data, fp) + yield d finally: shutil.rmtree(d) diff --git a/metalsmith/test/test_cmd.py b/metalsmith/test/test_cmd.py index 5e59bc2..0705e26 100644 --- a/metalsmith/test/test_cmd.py +++ b/metalsmith/test/test_cmd.py @@ -22,6 +22,7 @@ import six import testtools from metalsmith import _cmd +from metalsmith import _config from metalsmith import _instance from metalsmith import _provisioner @@ -409,6 +410,58 @@ class TestDeploy(testtools.TestCase): config = mock_pr.return_value.provision_node.call_args[1]['config'] self.assertEqual(['foo'], config.ssh_keys) + @mock.patch.object(_config.InstanceConfig, 'add_user', autospec=True) + def test_args_user_name(self, mock_add_user, mock_os_conf, mock_pr): + args = ['deploy', '--network', 'mynet', '--image', 'myimg', + '--user-name', 'banana', '--resource-class', 'compute'] + _cmd.main(args) + mock_pr.assert_called_once_with( + cloud_region=mock_os_conf.return_value.get_one.return_value, + dry_run=False) + mock_pr.return_value.reserve_node.assert_called_once_with( + resource_class='compute', + capabilities={} + ) + mock_pr.return_value.provision_node.assert_called_once_with( + mock_pr.return_value.reserve_node.return_value, + image='myimg', + nics=[{'network': 'mynet'}], + root_disk_size=None, + config=mock.ANY, + hostname=None, + netboot=False, + wait=1800) + config = mock_pr.return_value.provision_node.call_args[1]['config'] + self.assertEqual([], config.ssh_keys) + mock_add_user.assert_called_once_with(config, 'banana', sudo=False) + + @mock.patch.object(_config.InstanceConfig, 'add_user', autospec=True) + def test_args_user_name_with_sudo(self, mock_add_user, mock_os_conf, + mock_pr): + args = ['deploy', '--network', 'mynet', '--image', 'myimg', + '--user-name', 'banana', '--resource-class', 'compute', + '--passwordless-sudo'] + _cmd.main(args) + mock_pr.assert_called_once_with( + cloud_region=mock_os_conf.return_value.get_one.return_value, + dry_run=False) + mock_pr.return_value.reserve_node.assert_called_once_with( + resource_class='compute', + capabilities={} + ) + mock_pr.return_value.provision_node.assert_called_once_with( + mock_pr.return_value.reserve_node.return_value, + image='myimg', + nics=[{'network': 'mynet'}], + root_disk_size=None, + config=mock.ANY, + hostname=None, + netboot=False, + wait=1800) + config = mock_pr.return_value.provision_node.call_args[1]['config'] + self.assertEqual([], config.ssh_keys) + mock_add_user.assert_called_once_with(config, 'banana', sudo=True) + def test_args_port(self, mock_os_conf, mock_pr): args = ['deploy', '--port', 'myport', '--image', 'myimg', '--resource-class', 'compute'] diff --git a/metalsmith/test/test_config.py b/metalsmith/test/test_config.py new file mode 100644 index 0000000..ba9c497 --- /dev/null +++ b/metalsmith/test/test_config.py @@ -0,0 +1,104 @@ +# Copyright 2018 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os + +import mock +import testtools + +from metalsmith import _config + + +class TestInstanceConfig(testtools.TestCase): + def setUp(self): + super(TestInstanceConfig, self).setUp() + self.node = mock.Mock(uuid='1234') + self.node.name = 'node name' + + def _check(self, config, expected_metadata, expected_userdata=None): + expected_m = {'public_keys': [], + 'uuid': '1234', + 'name': 'node name', + 'hostname': 'example.com', + 'launch_index': 0, + 'availability_zone': '', + 'files': [], + 'meta': {}} + expected_m.update(expected_metadata) + + with config.build_configdrive_directory(self.node, 'example.com') as d: + for version in ('2012-08-10', 'latest'): + with open(os.path.join(d, 'openstack', version, + 'meta_data.json')) as fp: + metadata = json.load(fp) + + self.assertEqual(expected_m, metadata) + user_data = os.path.join(d, 'openstack', version, 'user_data') + if expected_userdata is None: + self.assertFalse(os.path.exists(user_data)) + else: + with open(user_data) as fp: + lines = list(fp) + self.assertEqual('#cloud-config\n', lines[0]) + user_data = json.loads(''.join(lines[1:])) + self.assertEqual(expected_userdata, user_data) + + self.assertFalse(os.path.exists(d)) + + def test_default(self): + config = _config.InstanceConfig() + self._check(config, {}) + + def test_ssh_keys(self): + config = _config.InstanceConfig(ssh_keys=['abc', 'def']) + self._check(config, {'public_keys': ['abc', 'def']}) + + def test_add_user(self): + config = _config.InstanceConfig() + config.add_user('admin') + self._check(config, {}, + {'users': [{'name': 'admin', + 'groups': ['wheel']}]}) + + def test_add_user_admin(self): + config = _config.InstanceConfig() + config.add_user('admin', admin=False) + self._check(config, {}, + {'users': [{'name': 'admin'}]}) + + def test_add_user_sudo(self): + config = _config.InstanceConfig() + config.add_user('admin', sudo=True) + self._check(config, {}, + {'users': [{'name': 'admin', + 'groups': ['wheel'], + 'sudo': 'ALL=(ALL) NOPASSWD:ALL'}]}) + + def test_add_user_passwd(self): + config = _config.InstanceConfig() + config.add_user('admin', password_hash='123') + self._check(config, {}, + {'users': [{'name': 'admin', + 'groups': ['wheel'], + 'passwd': '123'}]}) + + def test_add_user_with_keys(self): + config = _config.InstanceConfig(ssh_keys=['abc', 'def']) + config.add_user('admin') + self._check(config, {'public_keys': ['abc', 'def']}, + {'users': [{'name': 'admin', + 'groups': ['wheel'], + 'ssh_authorized_keys': ['abc', 'def']}]}) diff --git a/roles/metalsmith_deployment/defaults/main.yml b/roles/metalsmith_deployment/defaults/main.yml index 92e8c43..bdb3e17 100644 --- a/roles/metalsmith_deployment/defaults/main.yml +++ b/roles/metalsmith_deployment/defaults/main.yml @@ -5,3 +5,4 @@ metalsmith_netboot: false metalsmith_nics: [] metalsmith_root_size: metalsmith_ssh_public_keys: [] +metalsmith_user_name: metalsmith diff --git a/roles/metalsmith_deployment/tasks/main.yml b/roles/metalsmith_deployment/tasks/main.yml index b6f46f8..f935d87 100644 --- a/roles/metalsmith_deployment/tasks/main.yml +++ b/roles/metalsmith_deployment/tasks/main.yml @@ -22,6 +22,9 @@ {% if netboot %} --netboot {% endif %} + {% if user_name %} + --user-name {{ user_name }} + {% endif %} --resource-class {{ resource_class }} when: state == 'present' vars: @@ -34,6 +37,7 @@ root_size: "{{ instance.root_size | default(metalsmith_root_size) }}" ssh_public_keys: "{{ instance.ssh_public_keys | default(metalsmith_ssh_public_keys) }}" state: "{{ instance.state | default('present') }}" + user_name: "{{ instance.user_name | default(metalsmith_user_name) }}" with_items: "{{ metalsmith_instances }}" loop_control: label: "{{ instance.hostname or instance }}"