src/modules/actions/user.py
author Bart Smaalders <Bart.Smaalders@Oracle.COM>
Thu, 30 Mar 2017 17:05:02 -0700
changeset 3537 03bba058e598
parent 3449 02fe4aa118c4
permissions -rw-r--r--
20973899 Installation of zones in parallel may fail with: [Errno 17] File exists

#!/usr/bin/python
#
# CDDL HEADER START
#
# The contents of this file are subject to the terms of the
# Common Development and Distribution License (the "License").
# You may not use this file except in compliance with the License.
#
# You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE
# or http://www.opensolaris.org/os/licensing.
# See the License for the specific language governing permissions
# and limitations under the License.
#
# When distributing Covered Code, include this CDDL HEADER in each
# file and include the License file at usr/src/OPENSOLARIS.LICENSE.
# If applicable, add the following below this CDDL HEADER, with the
# fields enclosed by brackets "[]" replaced with your own identifying
# information: Portions Copyright [yyyy] [name of copyright owner]
#
# CDDL HEADER END
#

#
# Copyright (c) 2008, 2016, Oracle and/or its affiliates. All rights reserved.
#

"""module describing a user packaging object

This module contains the UserAction class, which represents a user
packaging object.  This contains the attributes necessary to create
a new user."""

from . import generic
try:
        from pkg.cfgfiles import *
        have_cfgfiles = True
except ImportError:
        have_cfgfiles = False

import pkg.client.api_errors as apx
import pkg.actions

class UserAction(generic.Action):
        """Class representing a user packaging object."""

        __slots__ = []

        name = "user"
        key_attr = "username"
        globally_identical = True
        ordinality = generic._orderdict[name]

        # if these values are different on disk than in action
        # prefer on-disk version for actual login accounts (root)
        use_existing_attrs = [ "password", "lastchg", "min",
                               "max", "expire", "flag",
                               "warn", "inactive"]
        mutable_passwords = frozenset(("UP", ""))

        def as_set(self, item):
                if isinstance(item, list):
                        return set(item)
                return set([item])

        def merge(self, old_plan, on_disk):
                """ three way attribute merge between old manifest,
                what's on disk and new manifest.  For any values
                on disk that are not in the new plan, use the values
                on disk.  Use new plan values unless attribute is
                in self.use_existing_attrs, or if old manifest and
                on-disk copy match...."""

                out = self.attrs.copy()

                for attr in on_disk:
                        if (attr in out and
                            attr not in self.use_existing_attrs) or \
                            (attr in old_plan and
                            old_plan[attr] == on_disk[attr]):
                                continue

                        # preserve UID if not specified explicitly
                        if attr == "uid" and attr not in out:
                                out[attr] = on_disk[attr]
                                continue
                        
                        # prefer manifest version if not mutable password
                        if attr == "password" and \
                           out[attr] not in self.mutable_passwords:
                                continue

                        # Only prefer on-disk entries if password is
                        # user-settable (e.g. '' or UP).
                        if "password" not in out or out["password"] not in \
                           self.mutable_passwords:
                                continue

                        if attr != "group-list":
                                out[attr] = on_disk[attr]
                        else:
                                out[attr] = list(
                                    self.as_set(out.get(attr, [])) |
                                    self.as_set(on_disk[attr]))
                return out

        def readstate(self, image, username, lock=False):
                """read state of user from files.  May raise KeyError"""
                root = image.get_root()
                pw = PasswordFile(root, lock)
                gr = GroupFile(image)
                ftp = FtpusersFile(root)

                username = self.attrs["username"]

                cur_attrs = pw.getuser(username)
                if "gid" in cur_attrs:
                        cur_attrs["group"] = \
                            image.get_name_by_gid(int(cur_attrs["gid"]))

                grps = gr.getgroups(username)
                if grps:
                        cur_attrs["group-list"] = grps

                cur_attrs["ftpuser"] = str(ftp.getuser(username)).lower()

                return (pw, gr, ftp, cur_attrs)


        def install(self, pkgplan, orig, retry=False):
                """client-side method that adds the user...
                   update any attrs that changed from orig
                   unless the on-disk stuff was changed"""

                if not have_cfgfiles:
                        # The user action is ignored if cfgfiles is not
                        # available.
                        return

                username = self.attrs["username"]

                try:
                        pw, gr, ftp, cur_attrs = \
                            self.readstate(pkgplan.image, username, lock=True)

                        self.attrs["gid"] = str(pkgplan.image.get_group_by_name(
                            self.attrs["group"]))

                        orig_attrs = {}
                        default_attrs = pw.getdefaultvalues()
                        if orig:
                                # Grab default values from files, extend by
                                # specifics from original manifest for
                                # comparisons sake.
                                orig_attrs.update(default_attrs)
                                orig_attrs["group-list"] = []
                                orig_attrs["ftpuser"] = "true"
                                orig_attrs.update(orig.attrs)
                        else:
                                # If we're installing a user for the first time,
                                # we want to override whatever value might be
                                # represented by the presence or absence of the
                                # user in the ftpusers file.  Remove the value
                                # from the representation of the file so that
                                # the new value takes precedence in the merge.
                                del cur_attrs["ftpuser"]

                        # add default values to new attrs if not present
                        for attr in default_attrs:
                                if attr not in self.attrs:
                                        self.attrs[attr] = default_attrs[attr]

                        self.attrs["group-list"] = self.attrlist("group-list")
                        final_attrs = self.merge(orig_attrs, cur_attrs)

                        pw.setvalue(final_attrs)

                        if "group-list" in final_attrs:
                                gr.setgroups(username,
                                    final_attrs["group-list"])

                        ftp.setuser(username,
                            final_attrs.get("ftpuser", "true") == "true")

                        pw.writefile()
                        gr.writefile()
                        ftp.writefile()
                except EnvironmentError as e:
                        if e.errno != errno.ENOENT:
                                raise
                        # If we're in the postinstall phase and the files
                        # *still* aren't there, bail gracefully.
                        if retry:
                                txt = _("User cannot be installed without user "
                                    "database files present.")
                                raise apx.ActionExecutionError(self, error=e,
                                    details=txt, fmri=pkgplan.destination_fmri)
                        img = pkgplan.image
                        img._users.add(self)
                        if "uid" in self.attrs:
                                img._usersbyname[self.attrs["username"]] = \
                                    int(self.attrs["uid"])
                        raise pkg.actions.ActionRetry(self)
                except KeyError as e:
                        # cannot find group
                        self.validate() # should raise error if no group in action
                        txt = _("{group} is an unknown or invalid group").format(
                            group=self.attrs.get("group", "None"))
                        raise apx.ActionExecutionError(self,
                            details=txt, fmri=pkgplan.destination_fmri)

                finally:
                        if "pw" in locals():
                                pw.unlock()

        def retry(self, pkgplan, orig):
                users = pkgplan.image._users
                if users:
                        assert self in users
                        self.install(pkgplan, orig, retry=True)

        def verify(self, img, **args):
                """Returns a tuple of lists of the form (errors, warnings,
                info).  The error list will be empty if the action has been
                correctly installed in the given image."""

                errors = []
                warnings = []
                info = []

                if not have_cfgfiles:
                        # The user action is ignored if cfgfiles is not
                        # available.
                        return errors, warnings, info

                username = self.attrs["username"]

                try:
                        pw, gr, ftp, cur_attrs = self.readstate(img, username)
                except EnvironmentError as e:
                        if e.errno == errno.EACCES:
                                errors.append(_("Skipping: Permission denied"))
                        else:
                                errors.append(_("Unexpected Error: {0}").format(e))
                        return errors, warnings, info
                except KeyError as e:
                        errors.append(_("{group} is an unknown or invalid group").format(
                            group=self.attrs.get("group", "None")))
                        return errors, warnings, info

                if "group-list" in self.attrs:
                        self.attrs["group-list"] = \
                            sorted(self.attrlist("group-list"))

                # Get the default values if they're non-empty
                pwdefval = dict((
                    (k, v)
                    for k, v in six.iteritems(pw.getdefaultvalues())
                    if v != ""
                ))

                # Certain defaults are dynamic, so we need to ignore what's on
                # disk
                if "gid" not in self.attrs:
                        cur_attrs["gid"] = ""
                if "uid" not in self.attrs:
                        cur_attrs["uid"] = ""
                if "lastchg" not in self.attrs:
                        cur_attrs["lastchg"] = ""
                if "login-shell" not in self.attrs:
                        cur_attrs["login-shell"] = ""

                pwdefval["ftpuser"] = "true"
                should_be = pwdefval.copy()
                should_be.update(self.attrs)

                # ignore changes in certain fields if password is
                # mutable; this indicates that this account is used
                # by a human and logins, timeouts, etc. are changable.
                if should_be["password"] in self.mutable_passwords:
                        for attr in self.use_existing_attrs:
                                if attr in should_be:
                                        cur_attrs[attr] = should_be[attr]
                                else:
                                        if attr in cur_attrs:
                                                del cur_attrs[attr]

                if "shell-change-ok" in self.attrs:
                        del should_be["shell-change-ok"]
                        if self.attrs["shell-change-ok"].lower() == "true":
                                cur_attrs["login-shell"] = should_be["login-shell"]

                # always ignore flag
                if "flag" in cur_attrs:
                        del cur_attrs["flag"]
                # Note where attributes are missing
                for k in should_be:
                        cur_attrs.setdefault(k, "<missing>")
                # Note where attributes should be empty
                for k in cur_attrs:
                        if cur_attrs[k]:
                                should_be.setdefault(k, "<empty>")

                errors.extend(
                    _("{entry}: '{found}' should be '{expected}'").format(
                        entry=a, found=cur_attrs[a],
                        expected=should_be[a])
                    for a in should_be
                    if cur_attrs[a] != should_be[a]
                )
                return errors, warnings, info

        def remove(self, pkgplan):
                """client-side method that removes this user"""
                if not have_cfgfiles:
                        # The user action is ignored if cfgfiles is not
                        # available.
                        return

                root = pkgplan.image.get_root()
                pw = PasswordFile(root, lock=True)
                try:
                        gr = GroupFile(pkgplan.image)
                        ftp = FtpusersFile(root)

                        pw.removevalue(self.attrs)
                        gr.removeuser(self.attrs["username"])

                        # negative logic
                        ftp.setuser(self.attrs["username"], True)

                        pw.writefile()
                        gr.writefile()
                        ftp.writefile()
                except KeyError as e:
                        # Already gone; don't care.
                        if e.args[0] != (self.attrs["username"],):
                                raise
                finally:
                        pw.unlock()

        def generate_indices(self):
                """Generates the indices needed by the search dictionary.  See
                generic.py for a more detailed explanation."""

                return [("user", "name", self.attrs["username"], None)]

        def validate(self, fmri=None):
                """Performs additional validation of action attributes that
                for performance or other reasons cannot or should not be done
                during Action object creation.  An ActionError exception (or
                subclass of) will be raised if any attributes are not valid.
                This is primarily intended for use during publication or during
                error handling to provide additional diagonostics.

                'fmri' is an optional package FMRI (object or string) indicating
                what package contained this action.
                """

                generic.Action._validate(self, fmri=fmri,
                    numeric_attrs=("uid", "lastchg", "min", "max", "warn",
                    "inactive","expire", "flag"), single_attrs=("password",
                    "uid", "group", "gcos-field", "home-dir", "login-shell",
                    "ftpuser", "lastchg", "min", "max", "warn", "inactive",
                    "expire", "flag"),
                    required_attrs=("group",))

        def compare(self, other):
                """Arrange for user actions to be installed in uid order.  This
                will only hold true for actions installed at one time, but that's
                generally what we need on initial install."""
                # put unspecified uids at the end
                a = int(self.attrs.get("uid", 1024))
                b = int(other.attrs.get("uid", 1024))
                return (a > b) - (a < b)