Implement cloud-config users and groups plugins

Add support for cloud-config users and groups creation.

For Windows, the following format is supported:

groups:
  - windows-group: [user1, user2]
  - cloud-users

users:
  -
    name: brian
    gecos: 'Brian Cohen'
    primary_group: Users
    groups: cloud-users
    passwd: StrongPassw0rd
    inactive: False
    expiredate: 2020-10-01
    ssh_authorized_keys:
      - first key
      - second key

The passwords for Windows users are required to be in
plain text. On *nix systems, the passwords are hashed.

If the password is not present, a random password will
be set.

Fixes: https://github.com/cloudbase/cloudbase-init/issues/26

Change-Id: I035f92849a59a8370df30a6de41f66f5fb2300af
This commit is contained in:
Adrian Vladu 2019-12-04 18:08:11 +02:00
parent 08641ae8b3
commit 6ede055475
9 changed files with 683 additions and 2 deletions

View File

@ -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()

View File

@ -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

View File

@ -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'),
])

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -154,6 +154,59 @@ The following cloud-config directives are supported:
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:
<group_name>: [<user1>, <user2>]
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 <year>-<month>-<day>. 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
Multi-part content
------------------