diff --git a/cloudbaseinit/osutils/base.py b/cloudbaseinit/osutils/base.py index 0bc795b7..52d65381 100644 --- a/cloudbaseinit/osutils/base.py +++ b/cloudbaseinit/osutils/base.py @@ -57,6 +57,10 @@ class BaseOSUtils(object): def rename_user(self, username, new_username): raise NotImplementedError() + def set_user_info(self, username, full_name=None, + disabled=False, expire_interval=None): + raise NotImplementedError() + def enum_users(self): raise NotImplementedError() @@ -69,6 +73,12 @@ class BaseOSUtils(object): def add_user_to_local_group(self, username, groupname): raise NotImplementedError() + def group_exists(self, group): + raise NotImplementedError() + + def create_group(self, group, description=None): + raise NotImplementedError() + def set_host_name(self, new_host_name): raise NotImplementedError() diff --git a/cloudbaseinit/osutils/windows.py b/cloudbaseinit/osutils/windows.py index e9715a5c..605ffa1f 100644 --- a/cloudbaseinit/osutils/windows.py +++ b/cloudbaseinit/osutils/windows.py @@ -474,6 +474,35 @@ class WindowsUtils(base.BaseOSUtils): raise exception.CloudbaseInitException( "Renaming user failed: %s" % ex.args[2]) + def set_user_info(self, username, full_name=None, + disabled=False, expire_interval=None): + + user_info = self._get_user_info(username, 2) + + if full_name: + user_info["full_name"] = full_name + + if disabled: + user_info["flags"] |= win32netcon.UF_ACCOUNTDISABLE + else: + user_info["flags"] &= ~win32netcon.UF_ACCOUNTDISABLE + + if expire_interval is not None: + user_info["acct_expires"] = int(expire_interval) + else: + user_info["acct_expires"] = win32netcon.TIMEQ_FOREVER + + try: + win32net.NetUserSetInfo(None, username, 2, user_info) + except win32net.error as ex: + if ex.args[0] == self.NERR_UserNotFound: + raise exception.ItemNotFoundException( + "User not found: %s" % username) + else: + LOG.debug(ex) + raise exception.CloudbaseInitException( + "Setting user info failed: %s" % ex.args[2]) + def enum_users(self): usernames = [] resume_handle = 0 @@ -531,6 +560,34 @@ class WindowsUtils(base.BaseOSUtils): raise exception.CloudbaseInitException( "Setting password expiration failed: %s" % ex.args[2]) + def group_exists(self, group): + try: + self._get_group_info(group, 1) + return True + except exception.ItemNotFoundException: + # Group not found + return False + + def _get_group_info(self, group, level): + try: + return win32net.NetLocalGroupGetInfo(None, group, level) + except win32net.error as ex: + if ex.args[0] == self.NERR_GroupNotFound: + raise exception.ItemNotFoundException( + "Group not found: %s" % group) + else: + raise exception.CloudbaseInitException( + "Failed to get group info: %s" % ex.args[2]) + + def create_group(self, group, description=None): + group_info = {"name": group} + + try: + win32net.NetLocalGroupAdd(None, 0, group_info) + except win32net.error as ex: + raise exception.CloudbaseInitException( + "Create group failed: %s" % ex.args[2]) + @staticmethod def _get_cch_referenced_domain_name(domain_name): return wintypes.DWORD( @@ -562,11 +619,13 @@ class WindowsUtils(base.BaseOSUtils): 3, ctypes.pointer(lmi), 1) if ret_val == self.NERR_GroupNotFound: - raise exception.CloudbaseInitException('Group not found') + raise exception.CloudbaseInitException("Group '%s' not found" + % groupname) elif ret_val == self.ERROR_ACCESS_DENIED: raise exception.CloudbaseInitException('Access denied') elif ret_val == self.ERROR_NO_SUCH_MEMBER: - raise exception.CloudbaseInitException('Username not found') + raise exception.CloudbaseInitException("Username '%s' not found" + % username) elif ret_val == self.ERROR_MEMBER_IN_ALIAS: # The user is already a member of the group pass diff --git a/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/factory.py b/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/factory.py index 5ae3ab9e..9ab41855 100644 --- a/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/factory.py +++ b/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/factory.py @@ -32,6 +32,10 @@ PLUGINS = collections.OrderedDict([ 'cloudconfigplugins.set_hostname.SetHostnamePlugin'), ('ntp', 'cloudbaseinit.plugins.common.userdataplugins.' 'cloudconfigplugins.set_ntp.SetNtpPlugin'), + ('groups', 'cloudbaseinit.plugins.common.userdataplugins.' + 'cloudconfigplugins.groups.GroupsPlugin'), + ('users', 'cloudbaseinit.plugins.common.userdataplugins.' + 'cloudconfigplugins.users.UsersPlugin'), ('runcmd', 'cloudbaseinit.plugins.common.userdataplugins.' 'cloudconfigplugins.runcmd.RunCmdPlugin'), ]) diff --git a/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/groups.py b/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/groups.py new file mode 100644 index 00000000..1186829f --- /dev/null +++ b/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/groups.py @@ -0,0 +1,77 @@ +# Copyright 2019 Cloudbase Solutions Srl +# +# 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 six + +from oslo_log import log as oslo_logging + +from cloudbaseinit import conf as cloudbaseinit_conf +from cloudbaseinit import exception +from cloudbaseinit.osutils import factory as osutils_factory +from cloudbaseinit.plugins.common.userdataplugins.cloudconfigplugins import ( + base +) + +CONF = cloudbaseinit_conf.CONF +LOG = oslo_logging.getLogger(__name__) + + +class GroupsPlugin(base.BaseCloudConfigPlugin): + """Creates groups given in cloud-config for the underlying platform.""" + + def process(self, data): + """Process the given data received from the cloud-config userdata. + + It knows to process only lists and dicts. + """ + if not isinstance(data, (list, dict)): + raise exception.CloudbaseInitException( + "Can't process the type of data %r" % type(data)) + + osutils = osutils_factory.get_os_utils() + + for item in data: + group_name = None + group_users = [] + + if isinstance(item, six.string_types): + group_name = item + elif isinstance(item, dict): + try: + group_name = list(item.keys())[0] + group_users = item.get(group_name, []) + except Exception: + LOG.error("Group details could not be parsed") + raise + else: + raise exception.CloudbaseInitException( + "Unrecognized type '%r' in group definition" % type(item)) + + if not group_name: + LOG.warning("Group name cannot be empty") + continue + + try: + if not osutils.group_exists(group_name): + osutils.create_group(group_name) + else: + LOG.warning("Group '%s' already exists" % group_name) + for group_user in group_users: + osutils.add_user_to_local_group(group_user, group_name) + except Exception as exc: + raise exception.CloudbaseInitException( + "Group '%s' could not be configured. Exception code: %s" + % (group_name, exc)) + + return False diff --git a/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/users.py b/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/users.py new file mode 100644 index 00000000..bbecfad2 --- /dev/null +++ b/cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/users.py @@ -0,0 +1,177 @@ +# Copyright 2019 Cloudbase Solutions Srl +# +# 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 datetime +import os +import pytz +import six + +from oslo_log import log as oslo_logging + +from cloudbaseinit import conf as cloudbaseinit_conf +from cloudbaseinit import exception +from cloudbaseinit.osutils import factory as osutils_factory +from cloudbaseinit.plugins.common.userdataplugins.cloudconfigplugins import ( + base +) + +CONF = cloudbaseinit_conf.CONF +LOG = oslo_logging.getLogger(__name__) + + +class UsersPlugin(base.BaseCloudConfigPlugin): + """Creates users given in the cloud-config format.""" + + def _get_groups(self, data): + """Retuns all the group names that the user should be added to. + + :rtype: list + """ + groups = data.get('groups', None) + primary_group = data.get('primary_group', None) + user_groups = [] + if isinstance(groups, six.string_types): + user_groups.extend(groups.split(', ')) + elif isinstance(groups, (list, tuple)): + user_groups.extend(groups) + if isinstance(primary_group, six.string_types): + user_groups.extend(primary_group.split(', ')) + elif isinstance(primary_group, (list, tuple)): + user_groups.extend(primary_group) + return user_groups + + def _get_password(self, data, osutils): + password = data.get('passwd', None) + max_size = osutils.get_maximum_password_length() + + if password and len(password) > max_size: + raise Exception("Password has more than %d characters" % max_size) + + if not password: + password = osutils.generate_random_password( + CONF.user_password_length) + + return password + + def _get_expire_interval(self, data): + expiredate = data.get('expiredate', None) + expire_interval = None + + if isinstance(expiredate, six.string_types): + year, month, day = map(int, expiredate.split('-')) + expiredate = datetime.datetime(year=year, month=month, day=day, + tzinfo=pytz.utc) + # Py2.7 does not support timestamps, this is the + # only way to compute the seconds passed since the unix epoch + unix_time = datetime.datetime(year=1970, month=1, day=1, + tzinfo=pytz.utc) + expire_interval = (expiredate - unix_time).total_seconds() + + return expire_interval + + @staticmethod + def _create_user_logon(user_name, password, osutils): + try: + token = osutils.create_user_logon_session(user_name, + password) + osutils.close_user_logon_session(token) + except Exception: + LOG.exception('Cannot create a user logon session for user: "%s"', + user_name) + + @staticmethod + def _set_ssh_public_keys(user_name, public_keys, osutils): + user_home = osutils.get_user_home(user_name) + if not user_home: + raise exception.CloudbaseInitException("User profile not found!") + + user_ssh_dir = os.path.join(user_home, '.ssh') + if not os.path.exists(user_ssh_dir): + os.makedirs(user_ssh_dir) + + authorized_keys_path = os.path.join(user_ssh_dir, "authorized_keys") + LOG.info("Writing SSH public keys in: %s" % authorized_keys_path) + with open(authorized_keys_path, 'w') as f: + for public_key in public_keys: + f.write(public_key + "\n") + + def _create_user(self, item, osutils): + user_name = item.get('name', None) + password = self._get_password(item, osutils) + user_full_name = item.get('gecos', None) + user_expire_interval = self._get_expire_interval(item) + user_disabled = item.get('inactive', False) + + public_keys = item.get('ssh_authorized_keys', []) + should_create_home = (public_keys or + not item.get('no_create_home ', False)) + if user_disabled and should_create_home: + raise exception.CloudbaseInitException( + "The user is required to be enabled if public_keys " + "or create_home are set") + + groups = self._get_groups(item) + + if osutils.user_exists(user_name): + LOG.warning("User '%s' already exists " % user_name) + osutils.set_user_password(user_name, password) + else: + osutils.create_user(user_name, password) + + osutils.set_user_info(user_name, full_name=user_full_name, + expire_interval=user_expire_interval, + disabled=user_disabled) + + for group in groups: + try: + osutils.add_user_to_local_group(user_name, group) + except Exception: + LOG.exception('Cannot add user "%s" to group "%s"' % + (user_name, group)) + + if not user_disabled and should_create_home: + self._create_user_logon(user_name, password, osutils) + + if public_keys: + self._set_ssh_public_keys(user_name, public_keys, osutils) + + def process(self, data): + """Process the given data received from the cloud-config userdata. + + It knows to process only lists and dicts. + """ + if not isinstance(data, (list, dict)): + raise exception.CloudbaseInitException( + "Can't process the type of data %r" % type(data)) + + osutils = osutils_factory.get_os_utils() + for item in data: + if not isinstance(item, dict): + continue + if not {'name'}.issubset(set(item)): + LOG.warning("Missing name key from user information %s", + item) + continue + user_name = item.get('name', None) + if not user_name: + LOG.warning("Username cannot be empty") + continue + + try: + self._create_user(item, osutils) + except Exception as ex: + LOG.warning("An error occurred during user '%s' creation: '%s" + % (user_name, ex)) + + return False diff --git a/cloudbaseinit/tests/osutils/test_windows.py b/cloudbaseinit/tests/osutils/test_windows.py index c73c331f..f7a99847 100644 --- a/cloudbaseinit/tests/osutils/test_windows.py +++ b/cloudbaseinit/tests/osutils/test_windows.py @@ -248,6 +248,45 @@ class TestWindowsUtils(testutils.CloudbaseInitTestBase): def test_user_does_not_exist(self): self._test_user_exists(exists=False) + @mock.patch('cloudbaseinit.osutils.windows.WindowsUtils' + '._get_group_info') + def _test_group_exists(self, mock_get_group_info, exists): + fake_group_name = 'fake_group' + if not exists: + mock_get_group_info.side_effect = [exception.ItemNotFoundException] + response = self._winutils.group_exists(fake_group_name) + self.assertEqual(False, response) + return + response = self._winutils.group_exists(fake_group_name) + mock_get_group_info.assert_called_once_with(fake_group_name, 1) + self.assertEqual(True, response) + + def test_group_exists(self): + self._test_group_exists(exists=True) + + def test_group_does_not_exist(self): + self._test_group_exists(exists=False) + + def _test_create_group(self, fail=False): + fake_group = "fake_group" + group_info = {"name": fake_group} + + if fail: + self._win32net_mock.NetLocalGroupAdd.side_effect = [ + self._win32net_mock.error(*([mock.Mock()] * 3))] + with self.assertRaises(exception.CloudbaseInitException): + self._winutils.create_group(fake_group) + return + self._winutils.create_group(fake_group) + self._win32net_mock.NetLocalGroupAdd.assert_called_once_with( + None, 0, group_info) + + def test_create_group(self): + self._test_create_group() + + def test_create_group_fail(self): + self._test_create_group(True) + def test_sanitize_shell_input(self): unsanitised = ' " ' response = self._winutils.sanitize_shell_input(unsanitised) @@ -2589,6 +2628,63 @@ class TestWindowsUtils(testutils.CloudbaseInitTestBase): *([mock.Mock()] * 2)) self._test_enum_users(exc=exc) + @mock.patch('cloudbaseinit.osutils.windows.WindowsUtils' + '._get_user_info') + def _test_set_user_info(self, mock_user_info, full_name=None, + disabled=False, expire_interval=None, exc=None): + user_info = { + "username": self._USERNAME, + "full_name": 'fake user', + "flags": 0, + "expire_interval": self._win32netcon_mock.TIMEQ_FOREVER + } + if full_name: + user_info["full_name"] = full_name + if expire_interval: + user_info["acct_expires"] = expire_interval + + if disabled: + user_info["flags"] |= self._win32netcon_mock.UF_ACCOUNTDISABLE + else: + user_info["flags"] &= self._win32netcon_mock.UF_ACCOUNTDISABLE + + mock_user_info.return_value = user_info + userset_mock = self._win32net_mock.NetUserSetInfo + + if exc: + userset_mock.side_effect = [exc] + error_class = ( + exception.ItemNotFoundException if + exc.args[0] == self._winutils.NERR_UserNotFound else + exception.CloudbaseInitException) + with self.assertRaises(error_class): + self._winutils.set_user_info(self._USERNAME, full_name, True, + expire_interval) + return + + self._winutils.set_user_info(self._USERNAME, full_name, True, + expire_interval) + userset_mock.assert_called_once_with( + None, self._USERNAME, 2, user_info) + + def test_set_user_info(self): + self._test_set_user_info() + + def test_set_user_info_full_options(self): + self._test_set_user_info(full_name='fake_user1', + disabled=True, expire_interval=1) + + def test_set_user_info_not_found(self): + exc = self._win32net_mock.error(self._winutils.NERR_UserNotFound, + *([mock.Mock()] * 2)) + self._test_set_user_info(full_name='fake_user1', + disabled=True, expire_interval=1, + exc=exc) + + def test_set_user_info_failed(self): + exc = self._win32net_mock.error(*([mock.Mock()] * 3)) + self._test_set_user_info(exc=exc) + def test_enum_users(self): self._test_enum_users(resume_handle=False) diff --git a/cloudbaseinit/tests/plugins/common/userdataplugins/cloudconfigplugins/test_groups.py b/cloudbaseinit/tests/plugins/common/userdataplugins/cloudconfigplugins/test_groups.py new file mode 100644 index 00000000..408c5a29 --- /dev/null +++ b/cloudbaseinit/tests/plugins/common/userdataplugins/cloudconfigplugins/test_groups.py @@ -0,0 +1,81 @@ +# Copyright 2019 Cloudbase Solutions Srl +# +# 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 unittest + +try: + import unittest.mock as mock +except ImportError: + import mock +from oslo_config import cfg + +from cloudbaseinit import exception +from cloudbaseinit.plugins.common.userdataplugins.cloudconfigplugins import ( + groups +) +from cloudbaseinit.tests import testutils + +CONF = cfg.CONF +MODPATH = ("cloudbaseinit.plugins.common.userdataplugins." + "cloudconfigplugins.groups") + + +class GroupsPluginTests(unittest.TestCase): + + def setUp(self): + self.groups_plugin = groups.GroupsPlugin() + + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + def test_process_group_empty(self, mock_get_os_utils): + fake_data = [''] + + with testutils.LogSnatcher(MODPATH) as snatcher: + res = self.groups_plugin.process(fake_data) + self.assertEqual(['Group name cannot be empty'], snatcher.output) + self.assertEqual(res, False) + + def test_process_group_wrong_content(self): + fake_data = 'fake_group' + + with self.assertRaises(exception.CloudbaseInitException) as cm: + self.groups_plugin.process(fake_data) + expected = "Can't process the type of data %s" % type(fake_data) + self.assertEqual(expected, str(cm.exception)) + + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + def test_process_group(self, mock_get_os_utils): + fake_data = [{'group1': ['usr1', 'usr2']}] + mock_os_util = mock.MagicMock() + mock_os_util.add_user_to_local_group.return_value = True + mock_os_util.group_exists.return_value = True + mock_get_os_utils.return_value = mock_os_util + + with testutils.LogSnatcher(MODPATH) as snatcher: + res = self.groups_plugin.process(fake_data) + self.assertEqual(["Group 'group1' already exists"], snatcher.output) + self.assertEqual(res, False) + + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + def test_process_group_fail(self, mock_get_os_utils): + fake_data = [{'group1': ['usr1']}] + mock_os_util = mock.MagicMock() + mock_os_util.create_group.return_value = True + mock_os_util.add_user_to_local_group.side_effect = Exception + mock_os_util.group_exists.return_value = False + mock_get_os_utils.return_value = mock_os_util + + with self.assertRaises(exception.CloudbaseInitException) as cm: + self.groups_plugin.process(fake_data) + expected = "Group 'group1' could not be configured. Exception code: " + self.assertEqual(expected, str(cm.exception)) diff --git a/cloudbaseinit/tests/plugins/common/userdataplugins/cloudconfigplugins/test_users.py b/cloudbaseinit/tests/plugins/common/userdataplugins/cloudconfigplugins/test_users.py new file mode 100644 index 00000000..6ed4c768 --- /dev/null +++ b/cloudbaseinit/tests/plugins/common/userdataplugins/cloudconfigplugins/test_users.py @@ -0,0 +1,124 @@ +# Copyright 2016 Cloudbase Solutions Srl +# +# 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 unittest + +try: + import unittest.mock as mock +except ImportError: + import mock +from oslo_config import cfg + +from cloudbaseinit import exception +from cloudbaseinit.plugins.common.userdataplugins.cloudconfigplugins import ( + users +) +from cloudbaseinit.tests import testutils + +CONF = cfg.CONF +MODPATH = ("cloudbaseinit.plugins.common.userdataplugins." + "cloudconfigplugins.users") + + +class UsersPluginTests(unittest.TestCase): + + def setUp(self): + self.users_plugin = users.UsersPlugin() + + def test__get_groups(self): + fake_data = {'groups': 'fake1, fake2', 'primary_group': 'fake3'} + res = self.users_plugin._get_groups(fake_data) + self.assertEqual(res, ['fake1', 'fake2', 'fake3']) + + def test__get_password(self): + fake_password = 'Passw0rd' + fake_data = {"passwd": fake_password} + mock_os_utils = mock.MagicMock() + mock_os_utils.get_maximum_password_length.return_value = 10 + res = self.users_plugin._get_password(fake_data, mock_os_utils) + self.assertEqual(res, fake_password) + + def test__get_expire_interval(self): + fake_data = {"expiredate": '2020-01-01'} + res = self.users_plugin._get_expire_interval(fake_data) + self.assertEqual(res, 1577836800) + + @mock.patch(MODPATH + '.UsersPlugin._get_password') + @mock.patch(MODPATH + '.UsersPlugin._create_user_logon') + @mock.patch(MODPATH + '.UsersPlugin._set_ssh_public_keys') + def test__create_user(self, mock_set_ssh_keys, mock_user_logon, + mock_get_pass): + fake_user = { + 'name': 'fake_user', + 'gecos': 'fake user', + 'primary_group': 'Users', + 'groups': 'test', + 'ssh_authorized_keys': ["test2", "test1"], + 'inactive': False, + 'expiredate': '3020-09-01', + 'passwd': 'Passw0rd' + } + mock_get_pass.return_value = 'fake_pass' + mock_os_utils = mock.MagicMock() + mock_os_utils.user_exists.return_value = False + + res = self.users_plugin._create_user(fake_user, mock_os_utils) + self.assertEqual(res, None) + mock_os_utils.create_user.assert_called_with('fake_user', 'fake_pass') + mock_os_utils.set_user_info.assert_called_with( + 'fake_user', disabled=False, expire_interval=33155827200.0, + full_name='fake user') + mock_os_utils.add_user_to_local_group.assert_called_with( + 'fake_user', 'Users') + mock_set_ssh_keys.assert_called_with('fake_user', ['test2', 'test1'], + mock_os_utils) + mock_user_logon.assert_called_with('fake_user', 'fake_pass', + mock_os_utils) + mock_get_pass.assert_called_with(fake_user, mock_os_utils) + + @mock.patch(MODPATH + '.UsersPlugin._get_password') + def test__create_user_inactive_with_create_home(self, mock_get_pass): + fake_user = { + 'inactive': True, + 'expiredate': '3020-09-01', + 'no_create_home': False + } + mock_get_pass.return_value = 'fake_pass' + mock_os_utils = mock.MagicMock() + + with self.assertRaises(exception.CloudbaseInitException) as cm: + self.users_plugin._create_user(fake_user, mock_os_utils) + expected = ("The user is required to be enabled if public_keys " + "or create_home are set") + self.assertEqual(expected, str(cm.exception)) + + @mock.patch(MODPATH + '.UsersPlugin._create_user') + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + def test_process_user(self, mock_get_os_utils, mock_create_user): + fake_data = [ + { + 'name': 'fake_user', + 'gecos': 'fake user name', + 'primary_group': 'Users', + 'groups': 'test', + 'ssh_authorized_keys': ["test2", "test1"], + 'inactive': False, + 'expiredate': '2020-09-01', + 'passwd': 'Passw0rd' + } + ] + with testutils.LogSnatcher(MODPATH) as snatcher: + res = self.users_plugin.process(fake_data) + self.assertEqual([], snatcher.output) + self.assertEqual(res, False) diff --git a/doc/source/userdata.rst b/doc/source/userdata.rst index ba63dbee..f4c80cf0 100644 --- a/doc/source/userdata.rst +++ b/doc/source/userdata.rst @@ -154,6 +154,59 @@ The following cloud-config directives are supported: #cloud-config set_hostname: newhostname +* groups - Create local groups and add existing users to those local groups. + + The definition of the groups consists of a list in the format: + + : [, ] + + The list of users can be empty, when creating a group without members. + + *Example:* + + .. code-block:: yaml + + groups: + - windows-group: [user1, user2] + - cloud-users + +* users - Create and configure local users. + + The users are defined as a list. Each element from the list represents a user. + Each user can have the the following attributes defined: + + 1. name - The username (required string). + 2. gecos - the user description. + 3. primary_group - the user's primary group. + 4. groups - the user's groups. On Windows, primary_group and groups are concatenated. + 5. passwd - the user's password. On Linux, the password is a hashed string, + whereas on Windows the password is a plaintext string. + If the password is not defined, a random password will be set. + 6. inactive - boolean value, defaults to False. If set to True, the user will + be disabled. + 7. expiredate - a string in the format --. Example: 2020-10-01. + 8. ssh_authorized_keys - a list of SSH public keys, that will be set in + ~/.ssh/authorized_keys. + + *Example:* + + .. code-block:: yaml + + users: + - + name: Admin + - + name: brian + gecos: 'Brian Cohen' + primary_group: Users + groups: cloud-users + passwd: StrongPassw0rd + inactive: False + expiredate: 2020-10-01 + ssh_authorized_keys: + - ssh-rsa AAAB...byV + - ssh-rsa AAAB...ctV + * ntp - Set NTP servers. The definition is a dict with the following attributes: