18390 allow packaging of editable files delivered by other packages
authorShawn Walker <shawn.walker@oracle.com>
Wed, 08 Jun 2011 16:46:31 -0700
changeset 2400 8786abc4c255
parent 2399 9daf47deb9c7
child 2401 e9061090d198
18390 allow packaging of editable files delivered by other packages
src/man/pkg.5.txt
src/modules/actions/file.py
src/modules/client/api_errors.py
src/modules/client/image.py
src/modules/client/imageplan.py
src/modules/client/linkedimage/common.py
src/tests/cli/t_pkg_install.py
--- a/src/man/pkg.5.txt	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/man/pkg.5.txt	Wed Jun 08 16:46:31 2011 -0700
@@ -135,6 +135,30 @@
                above, such as 'strawberry'), then the existing file will
                be left alone, and the new file will not be installed.
 
+     overlay   This specifies whether the action allows other packages to
+               deliver a file at the same location or whether it delivers
+               a file intended to overlay another.  This functionality is
+               intended for use with configuration files that don't
+               participate in any self-assembly (e.g. /etc/motd) and that
+               can be safely overwritten.
+
+               If overlay is not specified, multiple packages may not deliver
+               files to the same location.
+
+               If the value of overlay is 'allow', one other package is
+               allowed to deliver a file to the same location.  This value
+               has no effect unless the preserve attribute is also set.
+
+               If the value of overlay is 'true', the file delivered by the
+               action will overwrite any other action that has specified
+               'allow'.  Changes to the installed file will be preserved
+               based on the value of the preserve attribute of the overlaying
+               file.  On removal, the contents of the file will be preserved
+               if the action being overlayed is still installed regardless of
+               whether the preserve attribute was specified.  Only one action
+               may overlay another, and the 'mode', 'owner', and 'group'
+               attributes must match.
+
      Files may also be 'tasted', and depending on the flavor, may have
      additional interesting attributes.  For ELF files, the following
      attributes are recognized:
--- a/src/modules/actions/file.py	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/modules/actions/file.py	Wed Jun 08 16:46:31 2011 -0700
@@ -445,7 +445,7 @@
                 # system expected it to be, then we preserve the original file
                 # in some way, depending on the value of preserve.  If the
                 # action is an overlay, then we always overwrite.
-                overlay = self.attrs.get("overlay", "false") == "true"
+                overlay = self.attrs.get("overlay") == "true"
                 if is_file and not overlay:
                         chash, cdata = misc.get_data_digest(final_path)
                         if not orig or chash != orig.hash:
--- a/src/modules/client/api_errors.py	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/modules/client/api_errors.py	Wed Jun 08 16:46:31 2011 -0700
@@ -699,7 +699,8 @@
                         ua = dict(
                             (k, v)
                             for k, v in action.attrs.iteritems()
-                            if k in action.unique_attrs
+                            if k in action.unique_attrs and
+                                not (k == "preserve" and "overlay" in action.attrs)
                         )
                         action.attrs = ua
                         return action
--- a/src/modules/client/image.py	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/modules/client/image.py	Wed Jun 08 16:46:31 2011 -0700
@@ -3220,7 +3220,9 @@
                                         continue
                                 for key in act.attrs.keys():
                                         if (act.unique_attrs and
-                                            key not in act.unique_attrs) or \
+                                            key not in act.unique_attrs and
+                                            not (act.name == "file" and
+                                                key == "overlay")) or \
                                             key.startswith("variant.") or \
                                             key.startswith("facet."):
                                                 del act.attrs[key]
--- a/src/modules/client/imageplan.py	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/modules/client/imageplan.py	Wed Jun 08 16:46:31 2011 -0700
@@ -608,20 +608,31 @@
                         # look through all the packages, looking for our files
                         # we could use search for this.
 
-                        def peelslash(a):
-                                if os.path.isabs(a):
-                                        return a[1:]
-                                return a
-                        revertpaths = set([peelslash(a) for a in args])
+                        revertpaths = set([a.lstrip(os.path.sep) for a in args])
+                        overlaypaths = set()
                         for f in self.image.gen_installed_pkgs():
                                 self.__progtrack.evaluate_progress()
                                 m = self.image.get_manifest(f)
                                 for act in m.gen_actions_by_type("file",
                                     self.__new_excludes):
-                                        if act.attrs["path"] in revertpaths:
-                                                revert_dict[(f,m)].append(act)
-                                                revertpaths.remove(
-                                                    act.attrs["path"])
+                                        path = act.attrs["path"]
+                                        if path in revertpaths or \
+                                            path in overlaypaths:
+                                                revert_dict[(f, m)].append(act)
+                                                if act.attrs.get("overlay") == \
+                                                    "allow":
+                                                        # Action allows overlay,
+                                                        # all matching actions
+                                                        # must be collected.
+                                                        # The imageplan will
+                                                        # automatically handle
+                                                        # the overlaid action
+                                                        # if an overlaying
+                                                        # action is present.
+                                                        overlaypaths.add(path)
+                                                revertpaths.discard(path)
+
+                        revertpaths.difference_update(overlaypaths)
                         if revertpaths:
                                 raise api_errors.PlanCreationException(
                                     nofiles=list(revertpaths))
@@ -929,18 +940,45 @@
                     all(o[0] == n[0] for o, n, in zip(oactions, actions)):
                         return "nothing", None
 
-                # Some very rare cases require us to allow two actions to be
-                # delivered at a single point in their namespace.  Don't use
-                # this unless you know you're supposed to!
-                if DebugValues["allow-overlays"] and \
-                    any(act[0].attrs.get("overlay") == "true" for act in actions):
-                        return None
+                # For file actions, delivery of two actions to a single point is
+                # permitted if:
+                #   * there are only two actions in conflict
+                #   * one action has 'preserve' set and 'overlay=allow'
+                #   * the other action has 'overlay=true'
+                if len(actions) == 2:
+                        overlayable = overlay = None
+                        for act, ignored in actions:
+                                if (act.name == "file" and
+                                    act.attrs.get("overlay") == "allow" and
+                                    "preserve" in act.attrs):
+                                        overlayable = act
+                                elif (act.name == "file" and
+                                    act.attrs.get("overlay") == "true"):
+                                        overlay = act
+                        if overlayable and overlay:
+                                # Found both an overlayable action and the
+                                # action that overlays it.
+                                errors = self.__find_inconsistent_attrs(actions,
+                                    ignore=["preserve"])
+                                if errors:
+                                        # overlay is not permitted if unique
+                                        # attributes (except 'preserve') are
+                                        # inconsistent
+                                        return ("error", actions,
+                                            api_errors.InconsistentActionAttributeError)
+                                return "overlay", None
 
                 return "error", actions
 
         @staticmethod
-        def __find_inconsistent_attrs(actions):
-                """Find all the problem action pairs."""
+        def __find_inconsistent_attrs(actions, ignore=misc.EmptyI):
+                """Find all the problem Action pairs.
+
+                'ignore' is an optional list of attributes to ignore when
+                checking for inconsistent attributes.  By default, all
+                attributes listed in the 'unique_attrs' property of an
+                Action are checked.
+                """
 
                 # We iterate over all pairs of actions to see if any conflict
                 # with the rest.  If two actions are "safe" together, then we
@@ -969,7 +1007,10 @@
 
                         # If none of the different attributes is one that must
                         # be identical, then we can skip this action.
-                        if not any(d for d in diffs if d in a1[0].unique_attrs):
+                        if not any(
+                            d for d in diffs
+                            if (d in a1[0].unique_attrs and
+                                d not in ignore)):
                                 seen.add(a2)
                                 continue
 
@@ -1042,22 +1083,57 @@
                 if ret is None:
                         return False
 
-                msg, actions = ret
+                if len(ret) == 3:
+                        # Allow checking functions to override default errclass.
+                        msg, actions, errclass = ret
+                else:
+                        msg, actions = ret
 
                 if not isinstance(msg, basestring):
                         return False
 
                 if msg == "nothing":
                         for i, ap in enumerate(self.removal_actions):
-                                if ap and ap.src.attrs.get(ap.src.key_attr, None) == key:
+                                if ap and ap.src.attrs.get(ap.src.key_attr,
+                                    None) == key:
                                         self.removal_actions[i] = None
+                elif msg == "overlay":
+                        pp_needs_trimming = {}
+                        for al in (self.install_actions, self.update_actions):
+                                for i, ap in enumerate(al):
+                                        if not (ap and ap.dst.attrs.get(
+                                            ap.dst.key_attr, None) == key):
+                                            continue
+                                        if ap.dst.attrs.get("overlay") == \
+                                            "allow":
+                                                # Remove overlaid actions from
+                                                # plan.
+                                                al[i] = None
+                                                pp_needs_trimming.setdefault(id(ap.p),
+                                                    { "plan": ap.p, "trim": [] })
+                                                pp_needs_trimming[id(ap.p)]["trim"].append(
+                                                    id(ap.dst))
+                                                break
+
+                        for entry in pp_needs_trimming.values():
+                                p = entry["plan"]
+                                trim = entry["trim"]
+                                # Can't modify the p.actions tuple, so modify
+                                # the added member in-place.
+                                for prop in ("added", "changed"):
+                                        pval = getattr(p.actions, prop)
+                                        pval[:] = [
+                                            a
+                                            for a in pval
+                                            if id(a) not in trim
+                                        ]
                 elif msg == "fixup":
                         self.__propose_fixup(*actions)
                 elif msg == "error":
                         errs.append(errclass(actions))
                 else:
                         assert False, "%s() returned something other than " \
-                            "'nothing', 'error', or 'fixup': '%s'" % \
+                            "'nothing', 'overlay', 'error', or 'fixup': '%s'" % \
                             (func.__name__, msg)
 
                 return True
@@ -1563,7 +1639,6 @@
                 for i, ap in enumerate(self.removal_actions):
                         if ap is None:
                                 continue
-
                         self.__progtrack.evaluate_progress()
 
                         # If the action type needs to be reference-counted, make
@@ -1640,6 +1715,8 @@
 
                 new_updates = []
                 for i, ap in enumerate(self.install_actions):
+                        if ap is None:
+                                continue
                         self.__progtrack.evaluate_progress()
                         # In order to handle editable files that move their path
                         # or change pkgs, for all new files with original_name
@@ -1745,17 +1822,14 @@
 
                 del dest_pkgplans, nu_chg
 
-                self.removal_actions = [
-                    a
-                    for a in self.removal_actions
-                    if a is not None
-                ]
-
-                self.install_actions = [
-                    a
-                    for a in self.install_actions
-                    if a is not None
-                ]
+                for prop in ("removal_actions", "install_actions",
+                    "update_actions"):
+                        pval = getattr(self, prop)
+                        pval[:] = [
+                            a
+                            for a in pval
+                            if a is not None
+                        ]
 
                 self.__progtrack.evaluate_progress()
                 # Go over update actions
--- a/src/modules/client/linkedimage/common.py	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/modules/client/linkedimage/common.py	Wed Jun 08 16:46:31 2011 -0700
@@ -2160,7 +2160,6 @@
 
                 # propagate certain debug options
                 for k in [
-                    "allow-overlays",
                     "broken-conflicting-action-handling",
                     "disp_linked_cmds",
                     "plan"]:
--- a/src/tests/cli/t_pkg_install.py	Wed Jun 08 11:19:13 2011 -0700
+++ b/src/tests/cli/t_pkg_install.py	Wed Jun 08 16:46:31 2011 -0700
@@ -5947,7 +5947,14 @@
 
         pkg_overlaid = """
             open overlaid@0,5.11-0
-            add file tmp/file1 path=etc/pam.conf mode=644 owner=root group=sys preserve=true
+            add file tmp/file1 path=etc/pam.conf mode=644 owner=root group=sys preserve=true overlay=allow
+            close
+        """
+
+        # 'overlay' is ignored unless 'preserve' is also set.
+        pkg_invalid_overlaid = """
+            open invalid-overlaid@0,5.11-0
+            add file tmp/file1 path=etc/pam.conf mode=644 owner=root group=sys overlay=allow
             close
         """
 
@@ -5957,6 +5964,34 @@
             close
         """
 
+        pkg_multi_overlayer = """
+            open multi-overlayer@0,5.11-0
+            add file tmp/file2 path=etc/pam.conf mode=644 owner=root group=sys preserve=true overlay=true
+            close
+        """
+
+        # overlaying file is treated as conflicting file if its mode, owner, and
+        # group attributes don't match the action being overlaid
+        pkg_mismatch_overlayer = """
+            open mismatch-overlayer@0,5.11-0
+            add file tmp/file2 path=etc/pam.conf mode=640 owner=root group=bin preserve=true overlay=true
+            close
+        """
+
+        # overlaying file is treated as conflicting file if it doesn't set overlay=true
+        # even if file being overlaid allows overlay.
+        pkg_invalid_overlayer = """
+            open invalid-overlayer@0,5.11-0
+            add file tmp/file2 path=etc/pam.conf mode=644 owner=root group=sys preserve=true
+            close
+        """
+
+        pkg_unpreserved_overlayer = """
+            open unpreserved-overlayer@0,5.11-0
+            add file tmp/file2 path=etc/pam.conf mode=644 owner=root group=sys overlay=true
+            close
+        """
+
         pkgremote_pkg1 = """
             open pkg1@0,5.11-0
             add file tmp/file1 path=remote mode=644 owner=root group=sys
@@ -6535,30 +6570,14 @@
                 self.pkg("uninstall dupfilesp2 dupotherfilesp2")
                 self.pkg("verify")
 
-                # Make sure the overlay hack works.
+                # Re-use the overlay packages for some preserve testing.
                 self.pkg("install overlaid")
-                self.pkg("install overlayer", exit=1)
-                self.pkg("-D allow-overlays=1 install overlayer")
-                # Verify won't work here, on a preserve=true file!
-                self.file_contains("etc/pam.conf", "file2")
-                self.pkg("uninstall overlayer")
-
-                # If we make a change to the file after installing the
-                # overlayer, we shouldn't touch the file on uninstall.
-                self.pkg("-D allow-overlays=1 install overlayer")
-                self.file_append("etc/pam.conf", "zigit")
-                self.pkg("uninstall overlayer")
-                self.file_contains("etc/pam.conf", "zigit")
-                self.file_contains("etc/pam.conf", "file2")
-
-                # Re-use the overlay packages for some preserve testing.
-                self.pkg("revert /etc/pam.conf")
-                self.file_contains("etc/pam.conf", "file1")
-                self.pkg("-D broken-conflicting-action-handling=1 install overlayer")
+                self.pkg("-D broken-conflicting-action-handling=1 install "
+                    "invalid-overlayer")
                 # We may have been able to lay down the package, but because the
                 # file is marked preserve=true, we didn't actually overwrite
                 self.file_contains("etc/pam.conf", "file2")
-                self.pkg("uninstall overlayer")
+                self.pkg("uninstall invalid-overlayer")
                 self.file_contains("etc/pam.conf", "file2")
 
                 # Make sure we get rid of all implicit directories.
@@ -6597,6 +6616,120 @@
                 self.pkg("uninstall pkg2", exit=1)
                 self.pkg("verify pkg2")
 
+        def test_overlay_files(self):
+                """Test the behaviour of pkg(1) when actions for editable files
+                overlay other actions."""
+
+                # Ensure that overlay is allowed for file actions when one
+                # action has specified preserve attribute and overlay=allow,
+                # and *one* (only) other action has specified overlay=true
+                # (preserve does not have to be set).
+                self.image_create(self.rurl)
+
+                # Should fail because one action specified overlay=allow,
+                # but not preserve (it isn't editable).
+                self.pkg("install invalid-overlaid")
+                self.pkg("install overlayer", exit=1)
+                self.pkg("uninstall invalid-overlaid")
+
+                # Should fail because one action is overlayable but overlaying
+                # action doesn't declare its intent to overlay.
+                self.pkg("install overlaid")
+                self.file_contains("etc/pam.conf", "file1")
+                self.pkg("install invalid-overlayer", exit=1)
+
+                # Should fail because one action is overlayable but overlaying
+                # action mode, owner, and group attributes don't match.
+                self.pkg("install mismatch-overlayer", exit=1)
+
+                # Should succeed because one action is overlayable and
+                # overlaying action declares its intent to overlay.
+                self.pkg("contents -m overlaid")
+                self.pkg("contents -mr overlayer")
+                self.pkg("install overlayer")
+                self.file_contains("etc/pam.conf", "file2")
+
+                # Should fail because multiple actions are not allowed to
+                # overlay a single action.
+                self.pkg("install multi-overlayer", exit=1)
+
+                # Should succeed even though file is different than originally
+                # delivered since original package permits file modification.
+                self.pkg("verify overlaid overlayer")
+
+                # Should succeed because package delivering overlayable file
+                # permits modification and because package delivering overlay
+                # file permits modification.
+                self.file_append("etc/pam.conf", "zigit")
+                self.pkg("verify overlaid overlayer")
+
+                # Verify that the file isn't touched on uninstall of the
+                # overlaying package if package being overlaid is still
+                # installed.
+                self.pkg("uninstall overlayer")
+                self.file_contains("etc/pam.conf", "zigit")
+                self.file_contains("etc/pam.conf", "file2")
+
+                # Verify that removing the last package delivering an overlaid
+                # file removes the file.
+                self.pkg("uninstall overlaid")
+                self.file_doesnt_exist("etc/pam.conf")
+
+                # Verify that installing both packages at the same time results
+                # in only the overlaying file being delivered.
+                self.pkg("install overlaid overlayer")
+                self.file_contains("etc/pam.conf", "file2")
+
+                # Verify that the file isn't touched on uninstall of the
+                # overlaid package if overlaying package is still installed.
+                self.file_append("etc/pam.conf", "zigit")
+                self.pkg("uninstall overlaid")
+                self.file_contains("etc/pam.conf", "file2")
+                self.file_contains("etc/pam.conf", "zigit")
+
+                # Re-install overlaid package and verify that file content
+                # does not change.
+                self.pkg("install overlaid")
+                self.file_contains("etc/pam.conf", "file2")
+                self.file_contains("etc/pam.conf", "zigit")
+                self.pkg("uninstall overlaid overlayer")
+
+                # Should succeed because one action is overlayable and
+                # overlaying action declares its intent to overlay even
+                # though the overlaying action isn't marked with preserve.
+                self.pkg("install overlaid unpreserved-overlayer")
+                self.file_contains("etc/pam.conf", "file2")
+
+                # Should succeed because overlaid action permits modification
+                # and contents matches overlaying action.
+                self.pkg("verify overlaid unpreserved-overlayer")
+
+                # Should succeed even though file has been modified since
+                # overlaid action permits modification.
+                self.file_append("etc/pam.conf", "zigit")
+                self.pkg("verify overlaid")
+
+                # Should fail because overlaying action does not permit
+                # modification.
+                self.pkg("verify unpreserved-overlayer", exit=1)
+
+                # Should revert to content delivered by overlaying action.
+                self.pkg("fix unpreserved-overlayer")
+                self.file_contains("etc/pam.conf", "file2")
+                self.file_doesnt_contain("etc/pam.conf", "zigit")
+
+                # Should revert to content delivered by overlaying action.
+                self.file_append("etc/pam.conf", "zigit")
+                self.pkg("revert /etc/pam.conf")
+                self.file_contains("etc/pam.conf", "file2")
+                self.file_doesnt_contain("etc/pam.conf", "zigit")
+                self.pkg("uninstall unpreserved-overlayer")
+
+                # Should revert to content delivered by overlaid action.
+                self.file_contains("etc/pam.conf", "file2")
+                self.pkg("revert /etc/pam.conf")
+                self.file_contains("etc/pam.conf", "file1")
+
         def test_different_types(self):
                 """Test the behavior of pkg(1) when multiple actions of
                 different types deliver to the same pathname."""