diff --git a/cloudinit/config/cc_users_groups.py b/cloudinit/config/cc_users_groups.py index 507fee5776b..c8c086ab036 100644 --- a/cloudinit/config/cc_users_groups.py +++ b/cloudinit/config/cc_users_groups.py @@ -7,7 +7,9 @@ """Users and Groups: Configure users and groups""" import logging +from typing import List, Union +from cloudinit import lifecycle from cloudinit.cloud import Cloud # Ensure this is aliased to a name not 'distros' @@ -32,6 +34,43 @@ NEED_HOME = ("ssh_authorized_keys", "ssh_import_id", "ssh_redirect_user") +def _normalize_user_groups( + user: str, groups: Union[str, List[str], dict] +) -> List[str]: + """Process user groups from config, returning a list of group names. + + Emit deprecation logs or warnings on deprecated group value types. + + :raises: TypeError for unsupported group values in configuration. + """ + if not groups: + return [] + + if isinstance(groups, str): + return [group.strip() for group in groups.split(",") if group.strip()] + + if isinstance(groups, dict): + lifecycle.deprecate( + deprecated=f"The user {user} has a 'groups' config value " + "of type dict", + deprecated_version="22.3", + extra_message="Use a comma-delimited string or " + "array instead: group1,group2.", + ) + return list(group.strip() for group in groups if group.strip()) + + if isinstance(groups, list): + if not all(isinstance(group, str) for group in groups): + raise TypeError( + f"Not creating user {user}. 'groups' must contain only " + "string values." + ) + return [group.strip() for group in groups if group.strip()] + raise TypeError( + f"Not creating user {user}. 'groups' must be a string, list, or dict." + ) + + def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: (users, groups) = ug_util.normalize_users_groups(cfg, cloud.distro) (default_user, _user_config) = ug_util.extract_default(users) @@ -77,4 +116,5 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: config["ssh_redirect_user"] = default_user config["cloud_public_ssh_keys"] = cloud_keys - cloud.distro.create_user(user, **config) + user_groups = _normalize_user_groups(user, config.pop("groups", [])) + cloud.distro.create_user(user, groups=user_groups, **config) diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 9ed5540d7d7..aa1a415a65f 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -509,8 +509,7 @@ def _apply_hostname(self, hostname): try: subp.subp(["hostname", hostname]) except subp.ProcessExecutionError: - util.logexc( - LOG, + LOG.warning( "Failed to non-persistently adjust the system hostname to %s", hostname, ) @@ -659,27 +658,69 @@ def preferred_ntp_clients(self): def get_default_user(self): return self.get_option("default_user") - def add_user(self, name, **kwargs) -> bool: - """ - Add a user to the system using standard GNU tools + def add_user( + self, + name: str, + *, + groups: List[str], + create_groups: Optional[bool] = True, + primary_group: Optional[str] = None, + **kwargs, + ) -> None: + """Add a user to the system.""" - This should be overridden on distros where useradd is not desirable or - not available. + if groups and primary_group: + groups = groups + [primary_group] + + if create_groups and groups: + for group in groups: + if not util.is_group(group): + self.create_group(group) + LOG.debug("created group '%s' for user '%s'", group, name) - Returns False if user already exists, otherwise True. + if "uid" in kwargs: + kwargs["uid"] = str(kwargs["uid"]) + + LOG.debug("Adding user %s", name) + self._add_user( + name, + groups=groups, + primary_group=primary_group, + create_groups=create_groups, + **kwargs, + ) + + def _add_user( + self, + name: str, + *, + groups: List[str], + **kwargs, + ) -> None: + """Internal user-creation implementation; override in subclasses. + + Subclasses should override this instead of add_user to plug in + distro-specific user-creation tools while letting add_user handle + group setup. """ - # XXX need to make add_user idempotent somehow as we - # still want to add groups or modify SSH keys on pre-existing - # users in the image. - if util.is_user(name): - LOG.info("User %s already exists, skipping.", name) - return False + cmd, log_cmd = self._build_add_user_cmd(name, groups, **kwargs) + try: + subp.subp(cmd, logstring=log_cmd) + except subp.ProcessExecutionError: + LOG.warning("Failed to create user %s", name) + raise + self._post_add_user(name, groups, **kwargs) - if "create_groups" in kwargs: - create_groups = kwargs.pop("create_groups") - else: - create_groups = True + def _build_add_user_cmd( + self, name: str, groups: List[str], **kwargs + ) -> Tuple[List[str], List[str]]: + """Build the useradd command for GNU/Linux systems. + + Overridden for distro-specific user-creation tools. + Returns a (cmd, log_cmd) tuple where log_cmd has sensitive values + redacted. + """ useradd_cmd = ["useradd", name] log_useradd_cmd = ["useradd", name] if util.system_is_snappy(): @@ -694,7 +735,6 @@ def add_user(self, name, **kwargs) -> bool: "homedir": "--home", "primary_group": "--gid", "uid": "--uid", - "groups": "--groups", "passwd": "--password", "shell": "--shell", "expiredate": "--expiredate", @@ -710,42 +750,10 @@ def add_user(self, name, **kwargs) -> bool: redact_opts = ["passwd"] - # support kwargs having groups=[list] or groups="g1,g2" - groups = kwargs.get("groups") - if groups: - if isinstance(groups, str): - groups = groups.split(",") - - if isinstance(groups, dict): - lifecycle.deprecate( - deprecated=f"The user {name} has a 'groups' config value " - "of type dict", - deprecated_version="22.3", - extra_message="Use a comma-delimited string or " - "array instead: group1,group2.", - ) - - # remove any white spaces in group names, most likely - # that came in as a string like: groups: group1, group2 - groups = [g.strip() for g in groups] - - # kwargs.items loop below wants a comma delimited string - # that can go right through to the command. - kwargs["groups"] = ",".join(groups) - - primary_group = kwargs.get("primary_group") - if primary_group: - groups.append(primary_group) - - if create_groups and groups: - for group in groups: - if not util.is_group(group): - self.create_group(group) - LOG.debug("created group '%s' for user '%s'", group, name) - if "uid" in kwargs.keys(): - kwargs["uid"] = str(kwargs["uid"]) - # Check the values and create the command + if groups: + useradd_cmd.extend(["--groups", ",".join(groups)]) + log_useradd_cmd.extend(["--groups", ",".join(groups)]) for key, val in sorted(kwargs.items()): if key in useradd_opts and val and isinstance(val, str): useradd_cmd.extend([useradd_opts[key], val]) @@ -769,16 +777,13 @@ def add_user(self, name, **kwargs) -> bool: useradd_cmd.append("-m") log_useradd_cmd.append("-m") - # Run the command - LOG.debug("Adding user %s", name) - try: - subp.subp(useradd_cmd, logstring=log_useradd_cmd) - except Exception as e: - util.logexc(LOG, "Failed to create user %s", name) - raise e + return useradd_cmd, log_useradd_cmd - # Indicate that a new user was created - return True + def _post_add_user(self, name: str, groups: List[str], **kwargs) -> None: + """Hook called after the user-creation command succeeds. + + Overridden to perform distro-specific post-creation steps. + """ def add_snap_user(self, name, **kwargs): """ @@ -802,13 +807,9 @@ def add_snap_user(self, name, **kwargs): create_user_cmd, logstring=create_user_cmd, capture=True ) LOG.debug("snap create-user returned: %s:%s", out, err) - jobj = util.load_json(out) - username = jobj.get("username", None) - except Exception as e: - util.logexc(LOG, "Failed to create snap user %s", name) - raise e - - return username + except subp.ProcessExecutionError: + LOG.warning("Failed to create snap user %s", name) + raise def _shadow_file_has_empty_user_password(self, username) -> bool: """ @@ -844,7 +845,14 @@ def _shadow_file_has_empty_user_password(self, username) -> bool: return True return False - def create_user(self, name, **kwargs): + def create_user( + self, + name: str, + *, + groups: List[str], + create_groups: Optional[bool] = True, + **kwargs, + ): """ Creates or partially updates the ``name`` user in the system. @@ -869,8 +877,13 @@ def create_user(self, name, **kwargs): if "snapuser" in kwargs: return self.add_snap_user(name, **kwargs) - # Add the user - pre_existing_user = not self.add_user(name, **kwargs) + pre_existing_user = util.is_user(name) + if pre_existing_user: + LOG.info("Skipping '%s' user creation: user already exists.", name) + else: + self.add_user( + name, groups=groups, create_groups=create_groups, **kwargs + ) has_existing_password = False ud_blank_password_specified = False @@ -1021,7 +1034,6 @@ def create_user(self, name, **kwargs): ssh_util.setup_user_keys( set(cloud_keys), name, options=disable_option ) - return True def lock_passwd(self, name): """ @@ -1038,9 +1050,9 @@ def lock_passwd(self, name): ) from e try: subp.subp(cmd) - except Exception as e: - util.logexc(LOG, "Failed to disable password for user %s", name) - raise e + except subp.ProcessExecutionError: + LOG.warning("Failed to disable password for user %s", name) + raise def unlock_passwd(self, name: str): """ @@ -1057,9 +1069,9 @@ def unlock_passwd(self, name: str): ) from e try: _, err = subp.subp(cmd, rcs=[0, 3]) - except Exception as e: - util.logexc(LOG, "Failed to enable password for user %s", name) - raise e + except subp.ProcessExecutionError: + LOG.warning("Failed to enable password for user %s", name) + raise if err: # if "passwd" or "usermod" are unable to unlock an account with # an empty password then they display a message on stdout. In @@ -1080,18 +1092,16 @@ def unlock_passwd(self, name: str): ) from e try: subp.subp(cmd) - except Exception as e: - util.logexc( - LOG, "Failed to set blank password for user %s", name - ) - raise e + except subp.ProcessExecutionError: + LOG.warning("Failed to set blank password for user %s", name) + raise def expire_passwd(self, user): try: subp.subp(["passwd", "--expire", user]) - except Exception as e: - util.logexc(LOG, "Failed to set 'expire' for %s", user) - raise e + except subp.ProcessExecutionError: + LOG.warning("Failed to set 'expire' for %s", user) + raise def set_passwd(self, user, passwd, hashed=False): pass_string = "%s:%s" % (user, passwd) @@ -1107,13 +1117,13 @@ def set_passwd(self, user, passwd, hashed=False): subp.subp( cmd, data=pass_string, logstring="chpasswd for %s" % user ) - except Exception as e: - util.logexc(LOG, "Failed to set password for %s", user) - raise e + except subp.ProcessExecutionError: + LOG.warning("Failed to set password for %s", user) + raise return True - def chpasswd(self, plist_in: list, hashed: bool): + def chpasswd(self, plist_in: List[Tuple[str, str]], hashed: bool): payload = ( "\n".join( (":".join([name, password]) for name, password in plist_in) @@ -1303,8 +1313,8 @@ def create_group(self, name, members=None): try: subp.subp(group_add_cmd) LOG.info("Created new group %s", name) - except Exception: - util.logexc(LOG, "Failed to create group %s", name) + except subp.ProcessExecutionError: + LOG.warning("Failed to create group %s", name) # Add members to the group, if so defined if len(members) > 0: @@ -1322,9 +1332,9 @@ def create_group(self, name, members=None): LOG.info("Added user '%s' to group '%s'", member, name) @classmethod - def shutdown_command(cls, *, mode, delay, message): - # called from cc_power_state_change.load_power_state - command = ["shutdown", cls.shutdown_options_map[mode]] + def shutdown_command( + cls, *, mode: str, delay: Union[int, str], message: str + ) -> List[str]: try: if delay != "now": delay = "+%d" % int(delay) @@ -1333,10 +1343,16 @@ def shutdown_command(cls, *, mode, delay, message): "power_state[delay] must be 'now' or '+m' (minutes)." " found '%s'." % (delay,) ) from e - args = command + [delay] + return cls._build_shutdown_command(mode, delay, message) # type: ignore[arg-type] + + @classmethod + def _build_shutdown_command( + cls, mode: str, delay: str, message: str + ) -> List[str]: + command = ["shutdown", cls.shutdown_options_map[mode], delay] if message: - args.append(message) - return args + command.append(message) + return command @classmethod def reload_init(cls, rcs=None): diff --git a/cloudinit/distros/alpine.py b/cloudinit/distros/alpine.py index 19912d3724f..430b1f79b29 100644 --- a/cloudinit/distros/alpine.py +++ b/cloudinit/distros/alpine.py @@ -11,9 +11,9 @@ import re import stat from datetime import datetime -from typing import Any, Dict, Optional +from typing import List, Optional -from cloudinit import distros, helpers, lifecycle, subp, util +from cloudinit import distros, helpers, subp, util from cloudinit.distros.parsers.hostname import HostnameConf from cloudinit.settings import PER_ALWAYS, PER_INSTANCE @@ -205,29 +205,37 @@ def preferred_ntp_clients(self): return self._preferred_ntp_clients - def add_user(self, name, **kwargs) -> bool: - """ - Add a user to the system using standard tools - - On Alpine this may use either 'useradd' or 'adduser' depending - on whether the 'shadow' package is installed. - - Returns False if user already exists, otherwise True. - """ - if util.is_user(name): - LOG.info("User %s already exists, skipping.", name) - return False - - if "selinux_user" in kwargs: + def _add_user( + self, + name: str, + *, + groups: List[str], + selinux_user: Optional[str] = None, + passwd: Optional[str] = None, + no_create_home: Optional[bool] = False, + system: Optional[bool] = False, + expiredate: Optional[str] = None, + inactive: Optional[str] = None, + **kwargs, + ) -> None: + if selinux_user: LOG.warning("Ignoring selinux_user parameter for Alpine Linux") - del kwargs["selinux_user"] - # If 'useradd' is available then use the generic - # add_user function from __init__.py instead. + # If 'useradd' is available (e.g. shadow package installed) use the + # generic GNU implementation. if subp.which("useradd"): - return super().add_user(name, **kwargs) - - create_groups = kwargs.pop("create_groups", True) + super()._add_user( + name, + groups=groups, + passwd=passwd, + selinux_user=selinux_user, + no_create_home=no_create_home, + system=system, + expiredate=expiredate, + inactive=inactive, + **kwargs, + ) + return adduser_cmd = ["adduser", "-D"] @@ -242,104 +250,43 @@ def add_user(self, name, **kwargs) -> bool: "uid": "-u", } - adduser_flags = {"system": "-S"} - - # support kwargs having groups=[list] or groups="g1,g2" - groups = kwargs.get("groups") - if groups: - if isinstance(groups, str): - groups = groups.split(",") - elif isinstance(groups, dict): - lifecycle.deprecate( - deprecated=f"The user {name} has a 'groups' config value " - "of type dict", - deprecated_version="22.3", - extra_message="Use a comma-delimited string or " - "array instead: group1,group2.", - ) - - # remove any white spaces in group names, most likely - # that came in as a string like: groups: group1, group2 - groups = [g.strip() for g in groups] - - # kwargs.items loop below wants a comma delimited string - # that can go right through to the command. - kwargs["groups"] = ",".join(groups) - - if kwargs.get("primary_group"): - groups.append(kwargs["primary_group"]) - - if create_groups and groups: - for group in groups: - if not util.is_group(group): - self.create_group(group) - LOG.debug("created group '%s' for user '%s'", group, name) - if "uid" in kwargs: - kwargs["uid"] = str(kwargs["uid"]) - - unsupported_busybox_values: Dict[str, Any] = { - "groups": [], - "expiredate": None, - "inactive": None, - "passwd": None, - } - - # Check the values and create the command for key, val in sorted(kwargs.items()): if key in adduser_opts and val and isinstance(val, str): adduser_cmd.extend([adduser_opts[key], val]) - elif ( - key in unsupported_busybox_values - and val - and isinstance(val, str) - ): - # Busybox's 'adduser' does not support specifying these - # options so store them for use via alternative means. - if key == "groups": - unsupported_busybox_values[key] = val.split(",") - else: - unsupported_busybox_values[key] = val - elif key in adduser_flags and val: - adduser_cmd.append(adduser_flags[key]) - - # Don't create the home directory if directed so - # or if the user is a system user - if kwargs.get("no_create_home") or kwargs.get("system"): + if system: + adduser_cmd.append("-S") + + # Don't create the home directory no_create_home is True or if the + # user is a system user. + if no_create_home or system: adduser_cmd.append("-H") # Busybox's 'adduser' puts username at end of command adduser_cmd.append(name) - # Run the command - LOG.debug("Adding user %s", name) try: - subp.subp(adduser_cmd) - except subp.ProcessExecutionError as e: + subp.subp(adduser_cmd, logstring=adduser_cmd) + except subp.ProcessExecutionError: LOG.warning("Failed to create user %s", name) - raise e - - # Process remaining options that Busybox's 'adduser' does not support + raise # Separately add user to each additional group as Busybox's # 'adduser' does not support specifying additional groups. - for addn_group in unsupported_busybox_values[ - "groups" - ]: # pylint: disable=E1133 + for addn_group in groups: + addn_group = addn_group.strip() LOG.debug("Adding user to group %s", addn_group) try: subp.subp(["addgroup", name, addn_group]) - except subp.ProcessExecutionError as e: - util.logexc( - LOG, "Failed to add user %s to group %s", name, addn_group + except subp.ProcessExecutionError: + LOG.warning( + "Failed to add user %s to group %s", name, addn_group ) - raise e + raise - if unsupported_busybox_values["passwd"]: + if passwd: # Separately set password as Busybox's 'adduser' does # not support passing password as CLI option. - super().set_passwd( - name, unsupported_busybox_values["passwd"], hashed=True - ) + super().set_passwd(name, passwd, hashed=True) # Busybox's 'adduser' is hardcoded to always set the following field # values (numbered from "0") in /etc/shadow unlike 'useradd': @@ -356,16 +303,12 @@ def add_user(self, name, **kwargs) -> bool: # values directly in /etc/shadow file as Busybox's 'adduser' # does not support passing these as CLI options. - expiredate = unsupported_busybox_values["expiredate"] - inactive = unsupported_busybox_values["inactive"] - - shadow_contents = None shadow_file = self.shadow_fn try: shadow_contents = util.load_text_file(shadow_file) - except FileNotFoundError as e: + except FileNotFoundError: LOG.warning("Failed to read %s file, file not found", shadow_file) - raise e + raise # Find the line in /etc/shadow for the user original_line = None @@ -420,9 +363,6 @@ def add_user(self, name, **kwargs) -> bool: LOG, "Failed to update %s for user %s", shadow_file, name ) - # Indicate that a new user was created - return True - def lock_passwd(self, name): """ Lock the password of a user, i.e., disable password logins @@ -573,8 +513,10 @@ def create_group(self, name, members=None): subp.subp(["addgroup", member, name]) LOG.info("Added user '%s' to group '%s'", member, name) - def shutdown_command(self, mode="poweroff", delay="now", message=None): - # called from cc_power_state_change.load_power_state + @classmethod + def _build_shutdown_command( + cls, mode: str, delay: str, message: str + ) -> List[str]: # Alpine has halt/poweroff/reboot, with the following specifics: # - we use them rather than the generic "shutdown" # - delay is given with "-d [integer]" @@ -589,13 +531,7 @@ def shutdown_command(self, mode="poweroff", delay="now", message=None): # Alpine's commands do not understand "now". command += ["0"] else: - try: - command.append(str(int(delay) * 60)) - except ValueError as e: - raise TypeError( - "power_state[delay] must be 'now' or '+m' (minutes)." - " found '%s'." % (delay,) - ) from e + command.append(str(int(delay) * 60)) return command @@ -608,7 +544,7 @@ def uses_systemd(): @classmethod def manage_service( - self, action: str, service: str, *extra_args: str, rcs=None + cls, action: str, service: str, *extra_args: str, rcs=None ): """ Perform the requested action on a service. This handles OpenRC diff --git a/cloudinit/distros/freebsd.py b/cloudinit/distros/freebsd.py index 3eb4fbbf214..0b04db59cf1 100644 --- a/cloudinit/distros/freebsd.py +++ b/cloudinit/distros/freebsd.py @@ -8,6 +8,7 @@ import os import re from io import StringIO +from typing import List, Optional, Tuple import cloudinit.distros.bsd from cloudinit import subp, util @@ -97,16 +98,9 @@ def manage_service( def _get_add_member_to_group_cmd(self, member_name, group_name): return ["pw", "usermod", "-n", member_name, "-G", group_name] - def add_user(self, name, **kwargs) -> bool: - """ - Add a user to the system using standard tools - - Returns False if user already exists, otherwise True. - """ - if util.is_user(name): - LOG.info("User %s already exists, skipping.", name) - return False - + def _build_add_user_cmd( + self, name: str, groups: List[str], **kwargs + ) -> Tuple[List[str], List[str]]: pw_useradd_cmd = ["pw", "useradd", "-n", name] log_pw_useradd_cmd = ["pw", "useradd", "-n", name] @@ -114,7 +108,6 @@ def add_user(self, name, **kwargs) -> bool: "homedir": "-d", "gecos": "-c", "primary_group": "-g", - "groups": "-G", "shell": "-s", "inactive": "-E", "uid": "-u", @@ -125,6 +118,8 @@ def add_user(self, name, **kwargs) -> bool: "no_log_init": "--no-log-init", } + if groups: + pw_useradd_cmd.extend(["-G", ",".join(groups)]) for key, val in kwargs.items(): if key in pw_useradd_opts and val and isinstance(val, (str, int)): pw_useradd_cmd.extend([pw_useradd_opts[key], str(val)]) @@ -143,21 +138,19 @@ def add_user(self, name, **kwargs) -> bool: log_pw_useradd_cmd.append("-d" + homedir) log_pw_useradd_cmd.append("-m") - # Run the command - LOG.info("Adding user %s", name) - try: - subp.subp(pw_useradd_cmd, logstring=log_pw_useradd_cmd) - except Exception: - util.logexc(LOG, "Failed to create user %s", name) - raise - # Set the password if it is provided - # For security consideration, only hashed passwd is assumed - passwd_val = kwargs.get("passwd", None) - if passwd_val is not None: - self.set_passwd(name, passwd_val, hashed=True) - - # Indicate that a new user was created - return True + return pw_useradd_cmd, log_pw_useradd_cmd + + def _post_add_user( + self, + name: str, + groups: List[str], + passwd: Optional[str] = None, + **kwargs, + ) -> None: + # Set the password if it is provided. + # For security consideration, only hashed passwd is assumed. + if passwd is not None: + self.set_passwd(name, passwd, hashed=True) def expire_passwd(self, user): try: diff --git a/cloudinit/distros/netbsd.py b/cloudinit/distros/netbsd.py index 157aba06924..71595159805 100644 --- a/cloudinit/distros/netbsd.py +++ b/cloudinit/distros/netbsd.py @@ -6,7 +6,7 @@ import logging import os import platform -from typing import Any +from typing import Any, List, Optional, Tuple import cloudinit.distros.bsd from cloudinit import subp, util @@ -74,16 +74,9 @@ def __init__(self, name, cfg, paths): def _get_add_member_to_group_cmd(self, member_name, group_name): return ["usermod", "-G", group_name, member_name] - def add_user(self, name, **kwargs) -> bool: - """ - Add a user to the system using standard tools - - Returns False if user already exists, otherwise True. - """ - if util.is_user(name): - LOG.info("User %s already exists, skipping.", name) - return False - + def _build_add_user_cmd( + self, name: str, groups: List[str], **kwargs + ) -> Tuple[List[str], List[str]]: adduser_cmd = ["useradd"] log_adduser_cmd = ["useradd"] @@ -91,7 +84,6 @@ def add_user(self, name, **kwargs) -> bool: "homedir": "-d", "gecos": "-c", "primary_group": "-g", - "groups": "-G", "shell": "-s", } adduser_flags = { @@ -100,6 +92,8 @@ def add_user(self, name, **kwargs) -> bool: "no_log_init": "--no-log-init", } + if groups: + adduser_cmd.extend(["-G", ",".join(groups)]) for key, val in kwargs.items(): if key in adduser_opts and val and isinstance(val, str): adduser_cmd.extend([adduser_opts[key], val]) @@ -115,21 +109,19 @@ def add_user(self, name, **kwargs) -> bool: adduser_cmd += [name] log_adduser_cmd += [name] - # Run the command - LOG.info("Adding user %s", name) - try: - subp.subp(adduser_cmd, logstring=log_adduser_cmd) - except Exception: - util.logexc(LOG, "Failed to create user %s", name) - raise - # Set the password if it is provided - # For security consideration, only hashed passwd is assumed - passwd_val = kwargs.get("passwd", None) - if passwd_val is not None: - self.set_passwd(name, passwd_val, hashed=True) - - # Indicate that a new user was created - return True + return adduser_cmd, log_adduser_cmd + + def _post_add_user( + self, + name: str, + groups: List[str], + passwd: Optional[str] = None, + **kwargs, + ) -> None: + # Set the password if it is provided. + # For security consideration, only hashed passwd is assumed. + if passwd is not None: + self.set_passwd(name, passwd, hashed=True) def set_passwd(self, user, passwd, hashed=False): if hashed: diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index a6cf0f98585..b1807ac4082 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -5,6 +5,7 @@ # This file is part of cloud-init. See LICENSE file for license information. import logging +from typing import List from cloudinit import net, subp from cloudinit.distros import debian @@ -67,20 +68,7 @@ def apply_locale(self, locale, out_fn=None, keyname="LANG"): else: LOG.error("Failed to set locale %s", locale) - def add_user(self, name, **kwargs) -> bool: - """ - Add a user to the system using standard GNU tools - - This should be overridden on distros where useradd is not desirable or - not available. - - Returns False if user already exists, otherwise True. - """ - result = super().add_user(name, **kwargs) - - if not result: - return result - + def _post_add_user(self, name: str, groups: List[str], **kwargs) -> None: try: subp.subp( [ @@ -93,9 +81,7 @@ def add_user(self, name, **kwargs) -> bool: except subp.ProcessExecutionError as e: LOG.error("Failed to setup user: %s", e) - return False - - return True + raise def generate_fallback_config(self): # Based on Photon OS implementation diff --git a/tests/unittests/config/test_cc_users_groups.py b/tests/unittests/config/test_cc_users_groups.py index 3f8f799e6e4..be636fb8161 100644 --- a/tests/unittests/config/test_cc_users_groups.py +++ b/tests/unittests/config/test_cc_users_groups.py @@ -44,6 +44,46 @@ def test_handle_no_cfg_creates_no_users_or_groups(self, m_user, m_group): m_user.assert_not_called() m_group.assert_not_called() + @pytest.mark.parametrize( + "groups_cfg,normalized_groups,deprecation_log", + ( + pytest.param( + {"grp1": True, "grp2 ": True, " ": True}, + ["grp1", "grp2"], + "The user me2 has a 'groups' config value of type dict is" + " deprecated in 22.3", + id="groups-as-dict-ignores-whitespace", + ), + pytest.param( + " grp1, ,grp2", + ["grp1", "grp2"], + None, + id="groups-comma-separated-with-whitespace", + ), + ), + ) + def test_handle_users_in_cfg_normalizes_group_values( + self, + m_user, + m_group, + groups_cfg, + normalized_groups, + deprecation_log, + caplog, + ): + """Normalize values group config from str, dict and list.""" + cfg = {"users": [{"name": "me2", "groups": groups_cfg}]} + cloud = get_cloud(distro="ubuntu", sys_cfg={}, metadata={}) + cc_users_groups.handle("modulename", cfg, cloud, None) + assert_count_equal( + m_user.call_args_list, + [ + mock.call("me2", groups=normalized_groups, default=False), + ], + ) + if deprecation_log: + assert deprecation_log in caplog.text + def test_handle_users_in_cfg_calls_create_users(self, m_user, m_group): """When users in config, create users with distro.create_user.""" cfg = {"users": ["default", {"name": "me2"}]} # merged cloud-config @@ -64,11 +104,11 @@ def test_handle_users_in_cfg_calls_create_users(self, m_user, m_group): [ mock.call( "ubuntu", - groups="lxd,sudo", + groups=["lxd", "sudo"], lock_passwd=True, shell="/bin/bash", ), - mock.call("me2", default=False), + mock.call("me2", groups=[], default=False), ], ) m_group.assert_not_called() @@ -110,12 +150,12 @@ def test_handle_users_in_cfg_calls_create_users_on_bsd( [ mock.call( "freebsd", - groups="wheel", + groups=["wheel"], lock_passwd=True, shell="/bin/tcsh", homedir="/home/freebsd", ), - mock.call("me2", uid=1234, default=False), + mock.call("me2", groups=[], uid=1234, default=False), ], ) m_fbsd_group.assert_not_called() @@ -144,13 +184,14 @@ def test_users_with_ssh_redirect_user_passes_keys(self, m_user, m_group): [ mock.call( "ubuntu", - groups="lxd,sudo", + groups=["lxd", "sudo"], lock_passwd=True, shell="/bin/bash", ), mock.call( "me2", cloud_public_ssh_keys=["key1"], + groups=[], default=False, ssh_redirect_user="ubuntu", ), @@ -183,12 +224,13 @@ def test_users_with_ssh_redirect_user_default_str(self, m_user, m_group): [ mock.call( "ubuntu", - groups="lxd,sudo", + groups=["lxd", "sudo"], lock_passwd=True, shell="/bin/bash", ), mock.call( "me2", + groups=[], cloud_public_ssh_keys=["key1"], default=False, ssh_redirect_user="ubuntu", @@ -264,11 +306,11 @@ def test_users_with_ssh_redirect_user_default_false(self, m_user, m_group): [ mock.call( "ubuntu", - groups="lxd,sudo", + groups=["lxd", "sudo"], lock_passwd=True, shell="/bin/bash", ), - mock.call("me2", default=False), + mock.call("me2", groups=[], default=False), ], ) m_group.assert_not_called() @@ -285,7 +327,7 @@ def test_users_ssh_redirect_user_and_no_default( metadata = {} # no public-keys defined cloud = get_cloud(distro="ubuntu", sys_cfg=sys_cfg, metadata=metadata) cc_users_groups.handle("modulename", cfg, cloud, None) - m_user.assert_called_once_with("me2", default=False) + m_user.assert_called_once_with("me2", groups=[], default=False) m_group.assert_not_called() assert [ ( @@ -297,6 +339,34 @@ def test_users_ssh_redirect_user_and_no_default( ) ] == caplog.record_tuples + @pytest.mark.parametrize( + "groups_val", ("group1, group2", {"group1": True, "group2": True}) + ) + @mock.patch(MODPATH + ".lifecycle.deprecate") + def test_user_groups_normalized_to_list( + self, m_deprecate, m_user, m_group, groups_val, caplog, capsys + ): + cfg = {"users": [{"name": "me2", "groups": groups_val}]} + cloud = get_cloud(distro="ubuntu", sys_cfg={}, metadata={}) + + cc_users_groups.handle("modulename", cfg, cloud, None) + + m_user.assert_called_once_with( + "me2", default=False, groups=["group1", "group2"] + ) + m_group.assert_not_called() + if isinstance(groups_val, dict): + m_deprecate.assert_called_once_with( + deprecated=( + "The user me2 has a 'groups' config value of type dict" + ), + deprecated_version="22.3", + extra_message=( + "Use a comma-delimited string or array instead:" + " group1,group2." + ), + ) + class TestUsersGroupsSchema: @pytest.mark.parametrize( diff --git a/tests/unittests/distros/test_alpine.py b/tests/unittests/distros/test_alpine.py index 6e301d404af..a767c08fd97 100644 --- a/tests/unittests/distros/test_alpine.py +++ b/tests/unittests/distros/test_alpine.py @@ -39,9 +39,11 @@ def test_busybox_add_user(self, m_which, m_subp, tmpdir): distro.shadow_fn = shadow_file - distro.add_user(user, lock_passwd=True) + distro.add_user(user, groups=[], lock_passwd=True) - m_subp.assert_called_with(["adduser", "-D", user]) + m_subp.assert_called_with( + ["adduser", "-D", user], logstring=["adduser", "-D", user] + ) contents = util.load_text_file(shadow_file) expected = root_entry + "\n" + user + ":!:19848::::::" + "\n" @@ -70,7 +72,7 @@ def test_shadow_add_group(self, m_which, m_subp): def test_shadow_add_user(self, m_which, m_subp): user = "me2" - self.distro.add_user(user) + self.distro.add_user(user, groups=[]) m_subp.assert_called_with( ["useradd", user, "-m"], logstring=["useradd", user, "-m"] diff --git a/tests/unittests/distros/test_create_users.py b/tests/unittests/distros/test_create_users.py index 095924b106b..b2fee518491 100644 --- a/tests/unittests/distros/test_create_users.py +++ b/tests/unittests/distros/test_create_users.py @@ -235,7 +235,7 @@ def test_create_options( mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=is_snappy ) - dist.create_user(name=USER, **create_kwargs) + dist.create_user(name=USER, groups=[], **create_kwargs) assert m_subp.call_args_list == expected @pytest.mark.parametrize( @@ -391,7 +391,7 @@ def test_avoid_unlock_preexisting_user_empty_password( ) shadow_file.write_text(content) unlock_passwd = mocker.patch.object(dist, "unlock_passwd") - dist.create_user(name=USER, lock_passwd=False) + dist.create_user(name=USER, groups=[], lock_passwd=False) for log in expected_logs: assert log in caplog.text unlock_passwd.assert_not_called() @@ -443,7 +443,7 @@ def test_create_passwd_existing_user( mocker, ): """When user exists, don't unlock on empty or locked passwords.""" - dist.create_user(name=USER, **create_kwargs) + dist.create_user(name=USER, groups=[], **create_kwargs) for log in expected_logs: assert log in caplog.text assert m_subp.call_args_list == expected @@ -512,75 +512,6 @@ def test_snappy_only_new_group_added( ] assert m_subp.call_args_list == expected - @mock.patch("cloudinit.distros.util.is_group") - def test_create_groups_with_whitespace_string( - self, m_is_group, m_subp, dist, mocker - ): - # groups supported as a comma delimited string even with white space - mocker.patch( - "cloudinit.distros.util.system_is_snappy", return_value=False - ) - m_is_group.return_value = False - dist.create_user(USER, groups="group1, group2") - expected = [ - mock.call(["groupadd", "group1"]), - mock.call(["groupadd", "group2"]), - _useradd2call([USER, "--groups", "group1,group2", "-m"]), - mock.call(["passwd", "-l", USER]), - ] - assert m_subp.call_args_list == expected - - @mock.patch("cloudinit.distros.util.is_group") - def test_snappy_create_groups_with_whitespace_string( - self, m_is_group, m_subp, dist, mocker - ): - # groups supported as a comma delimited string even with white space - mocker.patch( - "cloudinit.distros.util.system_is_snappy", return_value=True - ) - m_is_group.return_value = False - dist.create_user(USER, groups="group1, group2") - expected = [ - mock.call(["groupadd", "group1", "--extrausers"]), - mock.call(["groupadd", "group2", "--extrausers"]), - _useradd2call( - [USER, "--extrausers", "--groups", "group1,group2", "-m"] - ), - mock.call(["passwd", "-l", USER]), - ] - assert m_subp.call_args_list == expected - - @mock.patch("cloudinit.distros.util.is_group", return_value=False) - def test_create_groups_with_dict_deprecated( - self, m_is_group, m_subp, dist, caplog, mocker - ): - """users.groups supports a dict value, but emit deprecation log.""" - mocker.patch( - "cloudinit.distros.util.system_is_snappy", return_value=False - ) - dist.create_user(USER, groups={"group1": None, "group2": None}) - expected = [ - mock.call(["groupadd", "group1"]), - mock.call(["groupadd", "group2"]), - _useradd2call([USER, "--groups", "group1,group2", "-m"]), - mock.call(["passwd", "-l", USER]), - ] - assert m_subp.call_args_list == expected - - expected_levels = ( - ["WARNING", "DEPRECATED"] - if lifecycle.should_log_deprecation( - "23.1", features.DEPRECATION_INFO_BOUNDARY - ) - else ["INFO"] - ) - assert caplog.records[0].levelname in expected_levels - assert ( - "The user foo_user has a 'groups' config value of type dict" - in caplog.records[0].message - ) - assert "Use a comma-delimited" in caplog.records[0].message - @mock.patch("cloudinit.distros.util.is_group", return_value=False) def test_create_groups_with_list( self, m_is_group, m_subp, dist, caplog, mocker @@ -625,7 +556,7 @@ def test_explicit_sudo_false(self, m_subp, dist, caplog, mocker): mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=False ) - dist.create_user(USER, sudo=False) + dist.create_user(USER, groups=[], sudo=False) assert m_subp.call_args_list == [ _useradd2call([USER, "-m"]), mock.call(["passwd", "-l", USER]), @@ -638,18 +569,23 @@ def test_explicit_sudo_false(self, m_subp, dist, caplog, mocker): ) else ["INFO"] ) - assert caplog.records[1].levelname in expected_levels - assert ( + deprecation_msg = ( "The value of 'false' in user foo_user's 'sudo' " "config is deprecated in 22.2 and scheduled to be removed" " in 27.2. Use 'null' instead." - ) in caplog.text + ) + deprecation_record = None + for record in caplog.records: + if deprecation_msg in record.msg: + deprecation_record = record + assert deprecation_record, "Missing deprecation log" + assert deprecation_record.levelname in expected_levels def test_explicit_sudo_none(self, m_subp, dist, caplog, mocker): mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=False ) - dist.create_user(USER, sudo=None) + dist.create_user(USER, groups=[], sudo=None) assert m_subp.call_args_list == [ _useradd2call([USER, "-m"]), mock.call(["passwd", "-l", USER]), @@ -661,7 +597,7 @@ def test_snappy_explicit_sudo_none(self, m_subp, dist, caplog, mocker): mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=True ) - dist.create_user(USER, sudo=None) + dist.create_user(USER, groups=[], sudo=None) assert m_subp.call_args_list == [ _useradd2call([USER, "--extrausers", "-m"]), mock.call(["passwd", "-l", USER]), @@ -677,7 +613,7 @@ def test_setup_ssh_authorized_keys_with_string( mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=False ) - dist.create_user(USER, ssh_authorized_keys="mykey") + dist.create_user(USER, groups=[], ssh_authorized_keys="mykey") assert m_subp.call_args_list == [ _useradd2call([USER, "-m"]), mock.call(["passwd", "-l", USER]), @@ -692,7 +628,7 @@ def test_snappy_setup_ssh_authorized_keys_with_string( mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=True ) - dist.create_user(USER, ssh_authorized_keys="mykey") + dist.create_user(USER, groups=[], ssh_authorized_keys="mykey") assert m_subp.call_args_list == [ _useradd2call([USER, "--extrausers", "-m"]), mock.call(["passwd", "-l", USER]), @@ -707,7 +643,7 @@ def test_setup_ssh_authorized_keys_with_list( mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=False ) - dist.create_user(USER, ssh_authorized_keys=["key1", "key2"]) + dist.create_user(USER, groups=[], ssh_authorized_keys=["key1", "key2"]) assert m_subp.call_args_list == [ _useradd2call([USER, "-m"]), mock.call(["passwd", "-l", USER]), @@ -722,7 +658,7 @@ def test_snappy_setup_ssh_authorized_keys_with_list( mocker.patch( "cloudinit.distros.util.system_is_snappy", return_value=True ) - dist.create_user(USER, ssh_authorized_keys=["key1", "key2"]) + dist.create_user(USER, groups=[], ssh_authorized_keys=["key1", "key2"]) assert m_subp.call_args_list == [ _useradd2call([USER, "--extrausers", "-m"]), mock.call(["passwd", "-l", USER]), @@ -734,25 +670,32 @@ def test_setup_ssh_authorized_keys_with_integer( self, m_setup_user_keys, m_subp, dist, caplog ): """ssh_authorized_keys warns on non-iterable/string type.""" - dist.create_user(USER, ssh_authorized_keys=-1) + dist.create_user(USER, groups=[], ssh_authorized_keys=-1) m_setup_user_keys.assert_called_once_with(set([]), USER) - assert caplog.records[1].levelname in ["WARNING", "DEPRECATED"] - assert ( + deprecation_msg = ( "Invalid type '' detected for 'ssh_authorized_keys'" - in caplog.text ) + deprecation_record = [ + r for r in caplog.records if deprecation_msg in r.message + ][0] + assert deprecation_record.levelname in ["WARNING", "DEPRECATED"] @mock.patch("cloudinit.ssh_util.setup_user_keys") def test_create_user_with_ssh_redirect_user_no_cloud_keys( self, m_setup_user_keys, m_subp, dist, caplog ): """Log a warning when trying to redirect a user no cloud ssh keys.""" - dist.create_user(USER, ssh_redirect_user="someuser") - assert caplog.records[1].levelname in ["WARNING", "DEPRECATED"] - assert ( + dist.create_user(USER, groups=[], ssh_redirect_user="someuser") + deprecation_msg = ( "Unable to disable SSH logins for foo_user given " - "ssh_redirect_user: someuser. No cloud public-keys present.\n" - ) in caplog.text + "ssh_redirect_user: someuser. No cloud public-keys present." + ) + deprecation_records = [ + record + for record in caplog.records + if deprecation_msg in record.message + ] + assert deprecation_records[0].levelname in ["WARNING", "DEPRECATED"] m_setup_user_keys.assert_not_called() @mock.patch("cloudinit.ssh_util.setup_user_keys") @@ -761,7 +704,10 @@ def test_create_user_with_ssh_redirect_user_with_cloud_keys( ): """Disable ssh when ssh_redirect_user and cloud ssh keys are set.""" dist.create_user( - USER, ssh_redirect_user="someuser", cloud_public_ssh_keys=["key1"] + USER, + groups=[], + ssh_redirect_user="someuser", + cloud_public_ssh_keys=["key1"], ) disable_prefix = ssh_util.DISABLE_USER_OPTS disable_prefix = disable_prefix.replace("$USER", "someuser") @@ -777,6 +723,7 @@ def test_create_user_with_ssh_redirect_user_does_not_disable_auth_keys( """Do not disable ssh_authorized_keys when ssh_redirect_user is set.""" dist.create_user( USER, + groups=[], ssh_authorized_keys="auth1", ssh_redirect_user="someuser", cloud_public_ssh_keys=["key1"], diff --git a/tests/unittests/distros/test_dragonflybsd.py b/tests/unittests/distros/test_dragonflybsd.py index 7222aef792b..641762ffcf8 100644 --- a/tests/unittests/distros/test_dragonflybsd.py +++ b/tests/unittests/distros/test_dragonflybsd.py @@ -10,7 +10,7 @@ class TestDragonFlyBSD: @mock.patch(M_PATH + "subp.subp") def test_add_user(self, m_subp): distro = get_distro("dragonflybsd") - assert True is distro.add_user("me2", uid=1234, default=False) + distro.add_user("me2", groups=[], uid=1234, default=False) assert [ mock.call( [ diff --git a/tests/unittests/distros/test_freebsd.py b/tests/unittests/distros/test_freebsd.py index ce844f10d9c..46263d28f68 100644 --- a/tests/unittests/distros/test_freebsd.py +++ b/tests/unittests/distros/test_freebsd.py @@ -12,7 +12,7 @@ class TestFreeBSD: @mock.patch(M_PATH + "subp.subp") def test_add_user(self, m_subp): distro = get_distro("freebsd") - assert True is distro.add_user("me2", uid=1234, default=False) + distro.add_user("me2", groups=[], uid=1234, default=False) assert [ mock.call( [ diff --git a/tests/unittests/distros/test_netbsd.py b/tests/unittests/distros/test_netbsd.py index e5df07d53da..4551c6cc9bc 100644 --- a/tests/unittests/distros/test_netbsd.py +++ b/tests/unittests/distros/test_netbsd.py @@ -19,7 +19,7 @@ class TestNetBSD: @mock.patch(M_PATH + "subp.subp") def test_add_user(self, m_subp): distro = get_distro("netbsd") - assert True is distro.add_user("me2", uid=1234, default=False) + distro.add_user("me2", groups=[], uid=1234, default=False) assert [ mock.call( ["useradd", "-m", "me2"], logstring=["useradd", "-m", "me2"] diff --git a/tests/unittests/distros/test_openbsd.py b/tests/unittests/distros/test_openbsd.py index 78342c176c0..f7b0919bef8 100644 --- a/tests/unittests/distros/test_openbsd.py +++ b/tests/unittests/distros/test_openbsd.py @@ -9,7 +9,7 @@ class TestOpenBSD: @mock.patch(M_PATH + "subp.subp") def test_add_user(self, m_subp): distro = get_distro("openbsd") - assert True is distro.add_user("me2", uid=1234, default=False) + distro.add_user("me2", groups=[], uid=1234, default=False) assert [ mock.call( ["useradd", "-m", "me2"], logstring=["useradd", "-m", "me2"] diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index ba54cee2f10..acb731e8c22 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -3,6 +3,8 @@ import logging from unittest import mock +import pytest + from cloudinit.distros import fetch from cloudinit.subp import ProcessExecutionError @@ -118,42 +120,39 @@ def test_apply_locale_fallback_to_utf8(self, m_subp): ) @mock.patch(M_PATH + "subp.subp") - def test_add_user_happy_path(self, m_subp): + @mock.patch("cloudinit.distros.util.is_user", return_value=False) + def test_add_user_happy_path(self, m_is_user, m_subp): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) - # Mock the superclass add_user to return True - with mock.patch( - "cloudinit.distros.debian.Distro.add_user", return_value=True + with mock.patch.object( + distro, + "_build_add_user_cmd", + return_value=(["useradd", "pi", "-m"], ["useradd", "pi", "-m"]), ): - assert distro.add_user("pi") is True - m_subp.assert_called_once_with( + distro.add_user("pi", groups=[]) + m_subp.assert_any_call( ["/usr/bin/rename-user", "-f", "-s"], update_env={"SUDO_USER": "pi"}, ) @mock.patch(M_PATH + "subp.subp") - def test_add_user_existing_user(self, m_subp): + @mock.patch("cloudinit.distros.util.is_user", return_value=False) + def test_add_user_rename_fails_logs_error(self, m_is_user, m_subp, caplog): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) - with mock.patch( - "cloudinit.distros.debian.Distro.add_user", return_value=False + # First subp call (useradd) succeeds; second (rename-user) fails. + m_subp.side_effect = [ + None, + ProcessExecutionError("rename-user failed"), + ] + with mock.patch.object( + distro, + "_build_add_user_cmd", + return_value=(["useradd", "pi", "-m"], ["useradd", "pi", "-m"]), ): - assert distro.add_user("pi") is False - m_subp.assert_not_called() - - @mock.patch( - M_PATH + "subp.subp", - side_effect=ProcessExecutionError("rename-user failed"), - ) - @mock.patch("cloudinit.distros.debian.Distro.add_user", return_value=True) - def test_add_user_rename_fails_logs_error( - self, m_super_add_user, m_subp, caplog - ): - cls = fetch("raspberry_pi_os") - distro = cls("raspberry-pi-os", {}, None) - - with caplog.at_level(logging.ERROR): - assert distro.add_user("pi") is False + with caplog.at_level(logging.ERROR): + with pytest.raises(ProcessExecutionError): + distro.add_user("pi", groups=[]) assert "Failed to setup user" in caplog.text @mock.patch( diff --git a/tests/unittests/distros/test_user_data_normalize.py b/tests/unittests/distros/test_user_data_normalize.py index a1a77d1a0e9..b55eafb8aec 100644 --- a/tests/unittests/distros/test_user_data_normalize.py +++ b/tests/unittests/distros/test_user_data_normalize.py @@ -279,12 +279,10 @@ def test_create_snap_user(self, mock_subp): } users, _groups = self._norm(ug_cfg, distro) for user, config in users.items(): - print("user=%s config=%s" % (user, config)) - username = distro.create_user(user, **config) + distro.create_user(user, groups=[], **config) snapcmd = ["snap", "create-user", "--sudoer", "--json", "joe@joe.com"] mock_subp.assert_called_with(snapcmd, capture=True, logstring=snapcmd) - assert username == "joe" @mock.patch("cloudinit.subp.subp") def test_create_snap_user_known(self, mock_subp): @@ -299,8 +297,7 @@ def test_create_snap_user_known(self, mock_subp): } users, _groups = self._norm(ug_cfg, distro) for user, config in users.items(): - print("user=%s config=%s" % (user, config)) - username = distro.create_user(user, **config) + distro.create_user(user, groups=[], **config) snapcmd = [ "snap", @@ -311,7 +308,6 @@ def test_create_snap_user_known(self, mock_subp): "joe@joe.com", ] mock_subp.assert_called_with(snapcmd, capture=True, logstring=snapcmd) - assert username == "joe" @mock.patch("cloudinit.util.system_is_snappy") @mock.patch("cloudinit.util.is_group") @@ -325,7 +321,7 @@ def test_add_user_on_snappy_system( distro = self._make_distro("ubuntu") ug_cfg = { "users": [ - {"name": "joe", "groups": "users", "create_groups": True}, + {"name": "joe", "groups": ["users"], "create_groups": True}, ], } users, _groups = self._norm(ug_cfg, distro)