7137406 pkg image-update could display release notes (or pointer to)
authorBart Smaalders <Bart.Smaalders@Oracle.COM>
Mon, 25 Jun 2012 09:51:09 -0700
changeset 2708 4dac3e277ccf
parent 2707 26c37de57d61
child 2709 14b0ab77bdf1
7137406 pkg image-update could display release notes (or pointer to)
doc/client_api_versions.txt
src/client.py
src/modules/client/actuator.py
src/modules/client/api.py
src/modules/client/history.py
src/modules/client/imageplan.py
src/modules/client/plandesc.py
src/modules/misc.py
src/tests/cli/t_actuators.py
src/tests/pkg5unittest.py
--- a/doc/client_api_versions.txt	Mon Jun 25 07:48:23 2012 +0530
+++ b/doc/client_api_versions.txt	Mon Jun 25 09:51:09 2012 -0700
@@ -1,3 +1,11 @@
+Version 74:
+Compatible with clients using version 73, 72.
+     The PlanDescription now has interfaces to
+     determine whether or not release notes were generated
+     for this operation, whether or not they must be displayed,
+     and a method of retrieving the release notes line by
+     line.
+
 Version 73:
 Compatible with clients using version 72.
 
--- a/src/client.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/client.py	Mon Jun 25 09:51:09 2012 -0700
@@ -58,6 +58,7 @@
         import re
         import socket
         import sys
+        import tempfile
         import textwrap
         import time
         import traceback
@@ -90,7 +91,7 @@
         import sys
         sys.exit(1)
 
-CLIENT_API_VERSION = 73
+CLIENT_API_VERSION = 74
 PKG_CLIENT_NAME = "pkg"
 
 JUST_UNKNOWN = 0
@@ -302,7 +303,7 @@
 
         adv_usage["unset-publisher"] = _("publisher ...")
         adv_usage["publisher"] = _("[-HPn] [-F format] [publisher ...]")
-        adv_usage["history"] = _("[-Hl] [-t [time|time-time],...] [-n number] [-o column,...]")
+        adv_usage["history"] = _("[-HNl] [-t [time|time-time],...] [-n number] [-o column,...]")
         adv_usage["purge-history"] = ""
         adv_usage["rebuild-index"] = ""
         adv_usage["update-format"] = ""
@@ -820,7 +821,7 @@
                 result = list(res)
                 if result:
                         api_inst.progresstracker.verify_start(len(result))
-                
+
                 for entry in result:
                         pfmri = entry[0]
                         entries = []
@@ -919,16 +920,20 @@
 display_plan_options = ["basic", "fmris", "variants/facets", "services",
     "actions", "boot-archive"]
 
-def __display_plan(api_inst, verbose):
+def __display_plan(api_inst, verbose, noexecute):
         """Helper function to display plan to the desired degree.
         Verbose can either be a numerical value, or a list of
         items to display"""
 
         if isinstance(verbose, int):
                 disp = ["basic"]
+
+                if verbose == 0 and noexecute:
+                        disp.append("release-notes")
                 if verbose > 0:
                         disp.extend(["fmris", "mediators", "services",
-                            "variants/facets", "boot-archive"])
+                                     "variants/facets", "boot-archive",
+                                     "release-notes"])
                 if verbose > 1:
                         disp.append("actions")
                 if verbose > 2:
@@ -941,6 +946,9 @@
 
         plan = api_inst.describe()
 
+        if plan.must_display_notes():
+                disp.append("release-notes")
+
 	# If we're a recursive invocation (indicated by client_output_progfd),
 	# we want to elide messages related to BE management.
 	recursive_child = \
@@ -1112,6 +1120,35 @@
                 for a in plan.get_actions():
                         logger.info("  %s" % a)
 
+
+        if plan.has_release_notes():
+                if "release-notes" in disp:
+                        logger.info("Release Notes:")
+                        for a in plan.get_release_notes():
+                                logger.info("  %s", a)
+                else:
+                        if not plan.new_be:
+                                logger.info(_("Release notes can be viewed with 'pkg history -n 1 -N'"))
+                        else:
+                                tmp_path = __write_tmp_release_notes(plan)
+                                if tmp_path:
+                                        logger.info(_("Release notes can be found in %s before rebooting.")
+                                            % tmp_path)
+                                logger.info(_("After rebooting, use 'pkg history -n 1 -N' to view release notes."))
+
+def __write_tmp_release_notes(plan):
+        """write release notes out to a file in /tmp and return the name"""
+        if plan.has_release_notes:
+                try:
+                        fd, path = tempfile.mkstemp(suffix=".txt", prefix="release-notes")
+                        tmpfile = os.fdopen(fd, "w+b")
+                        for a in plan.get_release_notes():
+                                tmpfile.write(a)
+                        tmpfile.close()
+                        return path
+                except Exception:
+                        pass
+
 def __display_parsable_plan(api_inst, parsable_version, child_images=None):
         """Display the parsable version of the plan."""
 
@@ -1138,6 +1175,7 @@
         licenses = []
         if child_images is None:
                 child_images = []
+        release_notes = []
 
         if plan:
                 for rem, add in plan.get_changes():
@@ -1166,6 +1204,10 @@
                 space_required = plan.bytes_added
                 services_affected = plan.services
                 mediators_changed = plan.mediators
+
+                for n in plan.get_release_notes():
+                        release_notes.append(n)
+
                 for dfmri, src_li, dest_li, acc, disp in \
                     plan.get_licenses():
                         src_tup = None
@@ -1199,6 +1241,7 @@
             "change-variants": sorted(variants_changed),
             "affect-services": sorted(services_affected),
             "change-mediators": sorted(mediators_changed),
+            "release-notes": release_notes,
             "image-name": None,
             "child-images": child_images,
             "version": parsable_version,
@@ -1264,7 +1307,7 @@
                 display_plan_licenses(api_inst, show_all=show_licenses)
 
         if not quiet:
-                __display_plan(api_inst, verbose)
+                __display_plan(api_inst, verbose, noexecute)
         if parsable_version is not None:
                 __display_parsable_plan(api_inst, parsable_version,
                     child_image_plans)
@@ -1468,7 +1511,7 @@
         if e_type == api_errors.ConflictingActionErrors:
                 error("\n" + str(e), cmd=op)
                 if verbose:
-                        __display_plan(api_inst, verbose)
+                        __display_plan(api_inst, verbose, noexecute)
                 return EXIT_OOPS
         if e_type in (api_errors.InvalidPlanError,
             api_errors.ReadOnlyFileSystemException,
@@ -2798,7 +2841,7 @@
 
         if not stuff_to_do:
                 if verbose:
-                        __display_plan(api_inst, verbose)
+                        __display_plan(api_inst, verbose, noexecute)
                 if parsable_version is not None:
                         try:
                                 __display_parsable_plan(api_inst,
@@ -2811,7 +2854,7 @@
                 return EXIT_NOP
 
         if not quiet:
-                __display_plan(api_inst, verbose)
+                __display_plan(api_inst, verbose, noexecute)
         if parsable_version is not None:
                 try:
                         __display_parsable_plan(api_inst, parsable_version)
@@ -2873,7 +2916,7 @@
 
         if not stuff_to_do:
                 if verbose:
-                        __display_plan(api_inst, verbose)
+                        __display_plan(api_inst, verbose, noexecute)
                 if parsable_version is not None:
                         try:
                                 __display_parsable_plan(api_inst,
@@ -2886,7 +2929,7 @@
                 return EXIT_NOP
 
         if not quiet:
-                __display_plan(api_inst, verbose)
+                __display_plan(api_inst, verbose, noexecute)
         if parsable_version is not None:
                 try:
                         __display_parsable_plan(api_inst, parsable_version)
@@ -5818,7 +5861,7 @@
         """Display history about the current image.
         """
         # define column name, header, field width and <History> attribute name
-        # we compute 'reason' and 'time' columns ourselves
+        # we compute 'reason', 'time' and 'release_note' columns ourselves
         history_cols = {
             "be": (_("BE"), "%-20s", "operation_be"),
             "be_uuid": (_("BE UUID"), "%-41s", "operation_be_uuid"),
@@ -5832,6 +5875,7 @@
             "operation": (_("OPERATION"), "%-25s", "operation_name"),
             "outcome": (_("OUTCOME"), "%-12s", "operation_result"),
             "reason": (_("REASON"), "%-10s", None),
+            "release_notes": (_("RELEASE NOTES"), "%-12s", None),
             "snapshot": (_("SNAPSHOT"), "%-20s", "operation_snapshot"),
             "start": (_("START"), "%-25s", "operation_start_time"),
             "time": (_("TIME"), "%-10s", None),
@@ -5843,14 +5887,17 @@
         omit_headers = False
         long_format = False
         column_format = False
+        show_notes = False
         display_limit = None    # Infinite
         time_vals = [] # list of timestamps for which we want history events
         columns = ["start", "operation", "client", "outcome"]
 
-        opts, pargs = getopt.getopt(args, "Hln:o:t:")
+        opts, pargs = getopt.getopt(args, "HNln:o:t:")
         for opt, arg in opts:
                 if opt == "-H":
                         omit_headers = True
+                elif opt == "-N":
+                        show_notes = True
                 elif opt == "-l":
                         long_format = True
                 elif opt == "-n":
@@ -5909,9 +5956,15 @@
         if time_vals and display_limit:
                 usage(_("-n and -t may not be combined"), cmd="history")
 
+        if column_format and show_notes:
+                usage(_("-o and -N may not be combined"), cmd="history")
+
+        if long_format and show_notes:
+                usage(_("-l and -N may not be combined"), cmd="history")
+
         history_fmt = None
 
-        if not long_format:
+        if not long_format and not show_notes:
                 headers = []
                 # build our format string
                 for col in columns:
@@ -5939,6 +5992,21 @@
                         error(str(e), cmd="history")
                         sys.exit(EXIT_OOPS)
 
+        if show_notes:
+                for he in gen_entries():
+                        start_time = misc.timestamp_to_time(
+                            he.operation_start_time)
+                        start_time = datetime.datetime.fromtimestamp(
+                            start_time).isoformat()
+                        if he.operation_release_notes:
+                                msg(_("%s: Release notes:") % start_time)
+                                for a in he.notes:
+                                        msg("    %s" % a)
+                        else:
+                                msg(_("%s: Release notes: None") % start_time)
+
+                return EXIT_OK
+
         for he in gen_entries():
                 # populate a dictionary containing our output
                 output = {}
@@ -5998,6 +6066,11 @@
                 else:
                         output["new_be"] = "%s" % he.operation_new_be
 
+                if he.operation_release_notes:
+                        output["release_notes"] = _("Yes")
+                else:
+                        output["release_notes"] = _("No")
+
                 outcome, reason = he.operation_result_text
                 output["outcome"] = outcome
                 output["reason"] = reason
@@ -6088,6 +6161,7 @@
         data.append((_("End Time"), hist_info["finish"]))
         data.append((_("Total Time"), hist_info["time"]))
         data.append((_("Command"), hist_info["command"]))
+        data.append((_("Release Notes"), hist_info["release_notes"]))
 
         state = he.operation_start_state
         if state:
--- a/src/modules/client/actuator.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/modules/client/actuator.py	Mon Jun 25 09:51:09 2012 -0700
@@ -25,6 +25,7 @@
 #
 
 import pkg.smf as smf
+import pkg.actions
 import os
 
 import pkg.misc
@@ -32,67 +33,19 @@
 from pkg.client.debugvalues import DebugValues
 from pkg.client.imagetypes import IMG_USER, IMG_ENTIRE
 
-class GenericActuator(object):
+
+class Actuator(object):
         """Actuators are action attributes that cause side effects
         on live images when those actions are updated, installed
         or removed.  Since no side effects are caused when the
         affected image isn't the current root image, the OS may
         need to cause the equivalent effect during boot.
-        """
-
-        actuator_attrs = set()
-
-        def __init__(self):
-                self.install = {}
-                self.removal = {}
-                self.update =  {}
-
-        def __nonzero__(self):
-                return bool(self.install or self.removal or self.update)
-
-        def scan_install(self, attrs):
-                self.__scan(self.install, attrs)
-
-        def scan_removal(self, attrs):
-                self.__scan(self.removal, attrs)
-
-        def scan_update(self, attrs):
-                self.__scan(self.update, attrs)
-
-        def __scan(self, dictionary, attrs):
-                for a in set(attrs.keys()) & self.actuator_attrs:
-                        values = attrs[a]
-
-                        if not isinstance(values, list):
-                                values = [values]
-
-                        dictionary.setdefault(a, set()).update(values)
-
-        def reboot_needed(self):
-                return False
-
-        def exec_prep(self, image):
-                pass
-
-        def exec_pre_actuators(self, image):
-                pass
-
-        def exec_post_actuators(self, image):
-                pass
-
-        def exec_fail_actuators(self, image):
-                pass
-
-        def __str__(self):
-                return "Removals: %s\nInstalls: %s\nUpdates: %s\n" % \
-                    (self.removal, self.install, self.update)
-
-
-class Actuator(GenericActuator):
-        """Solaris specific Actuator implementation..."""
+        This is Solaris specific for now. """
 
         actuator_attrs = set([
             "reboot-needed",    # have to reboot to update this file
+            "release-note",     # conditionally include this file
+                                # in release notes
             "refresh_fmri",     # refresh this service on any change
             "restart_fmri",     # restart this service on any change
             "suspend_fmri",     # suspend this service during update
@@ -104,6 +57,7 @@
                     "disable_fmri": set(),
                     "reboot-needed": set(),
                     "refresh_fmri": set(),
+                    "release-note": [(pkg.actions.generic.NSG, pkg.fmri.PkgFmri)],
                     "restart_fmri": set(),
                     "suspend_fmri": set(),
                 },
@@ -111,6 +65,7 @@
                     "disable_fmri": set(),
                     "reboot-needed": set(),
                     "refresh_fmri": set(),
+                    "release-note": [(pkg.actions.generic.NSG, pkg.fmri.PkgFmri)],
                     "restart_fmri": set(),
                     "suspend_fmri": set(),
                 },
@@ -118,13 +73,16 @@
                     "disable_fmri": set(),
                     "reboot-needed": set(),
                     "refresh_fmri": set(),
+                    "release-note": [(pkg.actions.generic.NSG, pkg.fmri.PkgFmri)],
                     "restart_fmri": set(),
                     "suspend_fmri": set(),
                 },
         }
 
         def __init__(self):
-                GenericActuator.__init__(self)
+                self.install = {}
+                self.removal = {}
+                self.update =  {}
                 self.suspend_fmris = None
                 self.tmp_suspend_fmris = None
                 self.do_nothing = True
@@ -163,12 +121,40 @@
         def __bool__(self):
                 return self.install or self.removal or self.update
 
+        def __nonzero__(self):
+                return bool(self.install or self.removal or self.update)
+
+        # scan_* functions take ActionPlan arguments (see imageplan.py)
+        def scan_install(self, ap):
+                self.__scan(self.install, ap.dst, ap.p.destination_fmri)
+
+        def scan_removal(self, ap):
+                self.__scan(self.removal, ap.src, ap.p.origin_fmri)
+
+        def scan_update(self, ap):
+                if ap.src:
+                        self.__scan(self.update, ap.src, ap.p.destination_fmri)
+                self.__scan(self.update, ap.dst, ap.p.destination_fmri)
+
+        def __scan(self, dictionary, act, fmri):
+                attrs = act.attrs
+                for a in set(attrs.keys()) & self.actuator_attrs:
+                        if a != "release-note":
+                                values = attrs[a]
+                                if not isinstance(values, list):
+                                        values = [values]
+                                dictionary.setdefault(a, set()).update(values)
+                        else:
+                                if act.name == "file": # ignore for non-files
+                                        dictionary.setdefault(a, list()).append(
+                                            (act, fmri))
+
         def get_list(self):
                 """Returns a list of actuator value pairs, suitable for printing"""
                 def check_val(dfmri):
                         # For actuators which are a single, global function that
                         # needs to get executed, simply print true.
-                        if callable(dfmri):
+                        if callable(dfmri) or isinstance(dfmri, list):
                                 return [ "true" ]
                         else:
                                 return dfmri
@@ -188,6 +174,11 @@
                         for smf in merge[fmri]
                         ]
 
+        def get_release_note_info(self):
+                """Returns a list of tuples of possible release notes"""
+                return self.update.get("release-note", []) + \
+                    self.install.get("release-note", [])
+                
         def get_services_list(self):
                 """Returns a list of services that would be restarted"""
                 return [(fmri, smf) for fmri, smf in self.get_list()
@@ -210,7 +201,7 @@
                 return bool("true" in self.update.get("reboot-needed", [])) or \
                     bool("true" in self.removal.get("reboot-needed", []))
 
-        def exec_prep(self, image):                
+        def exec_prep(self, image):
                 if not image.is_liveroot():
                         # we're doing off-line pkg ops; we need
                         # to support self-assembly milestone
--- a/src/modules/client/api.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/modules/client/api.py	Mon Jun 25 09:51:09 2012 -0700
@@ -103,8 +103,8 @@
 # things like help(pkg.client.api.PlanDescription)
 from pkg.client.plandesc import PlanDescription # pylint: disable-msg=W0611
 
-CURRENT_API_VERSION = 73
-COMPATIBLE_API_VERSIONS = frozenset([72, CURRENT_API_VERSION])
+CURRENT_API_VERSION = 74
+COMPATIBLE_API_VERSIONS = frozenset([72, 73, CURRENT_API_VERSION])
 CURRENT_P5I_VERSION = 1
 
 # Image type constants.
@@ -2393,7 +2393,8 @@
                 # by one of the previous operations, then log it as
                 # ending now.
                 if self._img.history.operation_name:
-                        self.log_operation_end()
+                        self.log_operation_end(release_notes=
+                            self._img.imageplan.pd.release_notes_name)
                 self.__executed = True
 
         def set_plan_license_status(self, pfmri, plicense, accepted=None,
@@ -4745,7 +4746,8 @@
                 # Successful; so save configuration.
                 self._img.save_config()
 
-        def log_operation_end(self, error=None, result=None):
+        def log_operation_end(self, error=None, result=None, 
+            release_notes=None):
                 """Marks the end of an operation to be recorded in image
                 history.
 
@@ -4755,7 +4757,8 @@
                 be based on the class of 'error' and 'error' will be recorded
                 for the current operation.  If 'result' and 'error' is not
                 provided, success is assumed."""
-                self._img.history.log_operation_end(error=error, result=result)
+                self._img.history.log_operation_end(error=error, result=result,
+                release_notes=release_notes)
 
         def log_operation_error(self, error):
                 """Adds an error to the list of errors to be recorded in image
--- a/src/modules/client/history.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/modules/client/history.py	Mon Jun 25 09:51:09 2012 -0700
@@ -144,7 +144,8 @@
                 for attr in ("name", "start_time", "end_time", "start_state",
                     "end_state", "username", "userid", "be", "be_exists",
                     "be_uuid", "current_be", "current_new_be", "new_be",
-                    "new_be_exists", "new_be_uuid", "result", "snapshot"):
+                    "new_be_exists", "new_be_uuid", "result", "release_notes",
+                    "snapshot"):
                         setattr(h, attr, getattr(self, attr))
                 h.errors = [copy.copy(e) for e in self.errors]
                 return h
@@ -177,12 +178,13 @@
 Operation New Boot Env. Current: %s
 Operation New Boot Env. UUID: %s
 Operation Snapshot: %s
+Operation Release Notes: %s
 Operation Errors:
 %s
 """ % (self.name, self.result, self.start_time, self.end_time,
     self.start_state, self.end_state, self.username, self.userid,
     self.be, self.current_be, self.be_uuid, self.new_be, self.current_new_be,
-    self.new_be_uuid, self.snapshot, self.errors)
+    self.new_be_uuid, self.snapshot, self.release_notes, self.errors)
 
         # All "time" values should be in UTC, using ISO 8601 as the format.
         # Name of the operation performed (e.g. install, update, etc.).
@@ -214,6 +216,8 @@
         # The uuid of the boot environment that was created as a result of the
         # operation
         new_be_uuid = None
+        # The name of the file containing the release notes, or None.
+        release_notes = None
 
         # The snapshot that was created while running this operation
         # set to None if no snapshot was taken, or destroyed after successful
@@ -278,6 +282,7 @@
         operation_snapshot = None
         operation_errors = None
         operation_result = None
+        operation_release_notes = None
 
         def __copy__(self):
                 h = History()
@@ -425,6 +430,23 @@
                             "%s-01.xml" % ops[-1]["operation"].start_time)
                 return pathname
 
+        @property
+        def notes(self):
+                """Generates the lines of release notes for this operation.
+                If no release notes are present, no output occurs."""
+
+                if not self.operation_release_notes:
+                        return
+                try:
+                        rpath = os.path.join(self.root_dir, 
+                            "notes", 
+                            self.operation_release_notes)
+                        for a in file(rpath, "r"):
+                                yield a.rstrip()
+
+                except Exception, e:
+                        raise apx.HistoryLoadException(e)
+                        
         def clear(self):
                 """Discards all information related to the current history
                 object.
@@ -490,6 +512,8 @@
                         if op.new_be_uuid:
                                 op.current_new_be = uuid_be_dic.get(
                                     op.new_be_uuid, op.new_be)
+                if node.hasAttribute("release-notes"):
+                        op.release_notes = node.getAttribute("release-notes")
 
                 def get_node_values(parent_name, child_name=None):
                         try:
@@ -611,6 +635,8 @@
                             self.operation_new_be_uuid)
                 if self.operation_snapshot:
                         op.setAttribute("snapshot", self.operation_snapshot)
+                if self.operation_release_notes:
+                        op.setAttribute("release-notes", self.operation_release_notes)
 
                 root.appendChild(op)
 
@@ -755,7 +781,7 @@
                 self.operation_be = be_name
                 self.operation_be_uuid = be_uuid
 
-        def log_operation_end(self, error=None, result=None):
+        def log_operation_end(self, error=None, result=None, release_notes=None):
                 """Marks the end of an operation to be recorded in image
                 history.
 
@@ -789,6 +815,8 @@
                 elif not result:
                         # Assume success if no error and no result.
                         result = RESULT_SUCCEEDED
+                if release_notes:
+                        self.operation_release_notes = release_notes
                 self.operation_result = result
 
         def log_operation_error(self, error):
--- a/src/modules/client/imageplan.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/modules/client/imageplan.py	Mon Jun 25 09:51:09 2012 -0700
@@ -33,6 +33,7 @@
 import os
 import simplejson as json
 import sys
+import tempfile
 import traceback
 import weakref
 
@@ -1969,6 +1970,7 @@
 
                 self.evaluate_pkg_plans()
                 self.merge_actions()
+                self.compile_release_notes()
 
                 for p in self.pd.pkg_plans:
                         cpbytes, pbytes = p.get_bytes_added()
@@ -2016,6 +2018,7 @@
 
         def __update_avail_space(self):
                 """Update amount of available space on FS"""
+
                 self.pd._cbytes_avail = misc.spaceavail(
                     self.image.write_cache_path)
 
@@ -2024,6 +2027,93 @@
                 if self.pd._cbytes_avail < 0:
                         self.pd._cbytes_avail = self.pd._bytes_avail
 
+        def __include_note(self, installed_dict, act, containing_fmri):
+                """Decide if a release note should be shown/included.  If
+                feature/pkg/self is fmri, fmri is containing package;
+                if version is then 0, this is note is displayed on initial
+                install only.  Otherwise, if version earlier than specified
+                fmri is present in code, display release note."""
+
+                for fmristr in act.attrlist("release-note"):
+                        try:
+                                pfmri = pkg.fmri.PkgFmri(fmristr, "5.11")
+                        except pkg.fmri.FmriError:
+                                continue # skip malformed fmris
+                        # any special handling here?
+                        if pfmri.pkg_name == "feature/pkg/self":
+                                if str(pfmri.version) == "0,5.11" \
+                                    and containing_fmri.pkg_name \
+                                    not in installed_dict:
+                                                return True
+                                else:
+                                        pfmri.pkg_name = \
+                                            containing_fmri.pkg_name
+                        if pfmri.pkg_name not in installed_dict:
+                                continue
+                        installed_fmri = installed_dict[pfmri.pkg_name]
+                        # if neither is successor they are equal
+                        if pfmri.is_successor(installed_fmri):
+                                return True
+                return False
+
+        def __get_note_text(self, act, pfmri):
+                """Retrieve text for release note from repo"""
+                try:
+                        pub = self.image.get_publisher(pfmri.publisher)
+                        return self.image.transport.get_content(pub, act.hash,
+                            fmri=pfmri)
+                finally:
+                        self.image.cleanup_downloads()
+
+        def compile_release_notes(self):
+                """Figure out what release notes need to be displayed"""
+                release_notes = self.pd._actuators.get_release_note_info()
+                must_display = False
+                notes = []
+
+                def do_decode(s):
+                        """convert non-ascii strings to unicode;
+                        replace non-convertable chars"""
+                        try:
+                                # this will fail if any 8 bit chars in string
+                                # this is a nop if string is ascii.
+                                s = s.encode("ascii")
+                        except ValueError:
+                                # this will encode 8 bit strings into unicode
+                                s = s.decode("utf-8", "replace")
+                        return s
+
+                if release_notes:
+                        installed_dict = ImagePlan.__fmris2dict(
+                            self.image.gen_installed_pkgs())
+                        for act, pfmri in release_notes:
+                                if self.__include_note(installed_dict, act, 
+                                    pfmri):
+                                        if act.attrs.get("must-display", 
+                                            "false") == "true":
+                                                must_display = True
+                                        for l in self.__get_note_text(
+                                            act, pfmri).splitlines():
+                                                notes.append(do_decode(l))
+                                        
+                        self.pd.release_notes = (must_display, notes)
+
+        def save_release_notes(self):
+                """Save a copy of the release notes and store the file name"""
+                if self.pd.release_notes[1]:
+                        # create a file in imgdir/notes
+                        dpath = os.path.join(self.image.imgdir, "notes")
+                        misc.makedirs(dpath)
+                        fd, path = tempfile.mkstemp(suffix=".txt",
+                            dir=dpath, prefix="release-notes-")
+                        tmpfile = os.fdopen(fd, "wb")
+                        for note in self.pd.release_notes[1]:
+                                if isinstance(note, unicode):
+                                        note = note.encode("utf-8")
+                                print >>tmpfile, note
+                        tmpfile.close()
+                        self.pd.release_notes_name = os.path.basename(path)
+
         def evaluate_pkg_plans(self):
                 """Internal helper function that does the work of converting
                 fmri changes into pkg plans."""
@@ -2693,7 +2783,7 @@
                                         fname = None
                                 attrs = re = None
 
-                        self.pd._actuators.scan_removal(ap.src.attrs)
+                        self.pd._actuators.scan_removal(ap)
                         if self.pd._need_boot_archive is None:
                                 if ap.src.attrs.get("path", "").startswith(
                                     ramdisk_prefixes):
@@ -2791,7 +2881,7 @@
                                         pp_needs_trimming.add(ap.p)
                                 nkv = index = ra = None
 
-                        self.pd._actuators.scan_install(ap.dst.attrs)
+                        self.pd._actuators.scan_install(ap)
                         if self.pd._need_boot_archive is None:
                                 if ap.dst.attrs.get("path", "").startswith(
                                     ramdisk_prefixes):
@@ -2894,9 +2984,7 @@
 
                         # scan both old and new actions
                         # repairs may result in update action w/o orig action
-                        if a[1]:
-                                self.pd._actuators.scan_update(a[1].attrs)
-                        self.pd._actuators.scan_update(a[2].attrs)
+                        self.pd._actuators.scan_update(a)
                         if self.pd._need_boot_archive is None:
                                 if a[2].attrs.get("path", "").startswith(
                                     ramdisk_prefixes):
@@ -3332,6 +3420,7 @@
                         self.pd._actuators.exec_post_actuators(self.image)
 
                 self.image._create_fast_lookups(progtrack=self.__progtrack)
+                self.save_release_notes()
 
                 # success
                 self.pd.state = plandesc.EXECUTED_OK
--- a/src/modules/client/plandesc.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/modules/client/plandesc.py	Mon Jun 25 09:51:09 2012 -0700
@@ -133,6 +133,7 @@
             "li_ppkgs": frozenset([ pkg.fmri.PkgFmri ]),
             "li_props": { li.PROP_NAME: li.LinkedImageName },
             "pkg_plans": [ pkg.client.pkgplan.PkgPlan ],
+            "release_notes": (bool, []),
             "removal_actions": [ _ActionPlan ],
             "removed_groups": { str: pkg.fmri.PkgFmri },
             "removed_users": { str: pkg.fmri.PkgFmri },
@@ -189,6 +190,8 @@
                 self.added_users = {}
                 self.removed_groups = {}
                 self.removed_users = {}
+                # release notes that are part of this operation
+                self.release_notes = (False, [])
                 # plan properties
                 self._cbytes_added = 0 # size of compressed files
                 self._bytes_added = 0  # size of files added
@@ -206,6 +209,7 @@
                 # Properties set when state >= EXECUTED_OK
                 #
                 self._salvaged = []
+                self.release_notes_name = None
 
                 #
                 # Set by imageplan.set_be_options()
@@ -514,6 +518,19 @@
                 # pylint: enable-msg=W0612
                         yield "%s -> %s" % (o_act, d_act)
 
+        def has_release_notes(self):
+                """True if there are release notes for this plan"""
+                return bool(self.release_notes[1])
+
+        def must_display_notes(self):
+                """True if the release notes must be displayed"""
+                return self.release_notes[0]
+
+        def get_release_notes(self):
+                """A generator that returns the release notes for this plan"""
+                for notes in self.release_notes[1]:
+                        yield notes
+
         def get_licenses(self, pfmri=None):
                 """A generator function that yields information about the
                 licenses related to the current plan in tuples of the form
--- a/src/modules/misc.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/modules/misc.py	Mon Jun 25 09:51:09 2012 -0700
@@ -1977,6 +1977,7 @@
 
         # we don't need to do anything for basic types
         if desc_type in json_types_immediates:
+                rv = None
                 return jd_return(name, data, desc, finish, jd_state)
 
         # decode elements nested in a dictionary
--- a/src/tests/cli/t_actuators.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/tests/cli/t_actuators.py	Mon Jun 25 09:51:09 2012 -0700
@@ -1,5 +1,5 @@
 #!/usr/bin/python
-#
+# -*- coding: utf-8
 # CDDL HEADER START
 #
 # The contents of this file are subject to the terms of the
@@ -30,7 +30,7 @@
 import pkg5unittest
 import unittest
 
-class TestPkgActuators(pkg5unittest.SingleDepotTestCase):
+class TestPkgSMFActuators(pkg5unittest.SingleDepotTestCase):
         # Only start/stop the depot once (instead of for every test)
         persistent_setup = True
 
@@ -425,5 +425,137 @@
                     "svc:/system/test_multi_svc2:default")
                 os.unlink(svcadm_output)
 
+class TestPkgReleaseNotes(pkg5unittest.SingleDepotTestCase):
+        # Only start/stop the depot once (instead of for every test)
+        persistent_setup = True
+
+        foo10 = """
+            open [email protected],5.11-0
+            add file tmp/release-note-1 mode=0644 owner=root group=bin path=/usr/share/doc/release-notes/release-note-1 release-note=feature/pkg/self@0
+            close """
+
+        foo11 = """
+            open [email protected],5.11-0
+            add file tmp/release-note-2 mode=0644 owner=root group=root path=/usr/share/doc/release-notes/release-note-2 release-note=feature/pkg/[email protected]
+            close """
+
+        foo12 = """
+            open [email protected],5.11-0
+            add file tmp/release-note-3 mode=0644 owner=root group=root path=/usr/share/doc/release-notes/release-note-3 release-note=feature/pkg/[email protected] must-display=true
+            close """
+
+        foo13 = """
+            open [email protected],5.11-0
+            add file tmp/release-note-4 mode=0644 owner=root group=root path=/usr/share/doc/release-notes/release-note-4 release-note=feature/pkg/[email protected]
+            close """
+
+        bar10 = """
+            open [email protected],5.11-0
+            add dir path=/usr mode=0755 owner=root group=root release-note=feature/pkg/self@0
+            close """
+
+        bar11 = """
+            open [email protected],5.11-0
+            close """
+
+        baz10 = """
+            open [email protected],5.11-0
+            add file tmp/release-note-5 mode=0644 owner=root group=root path=/usr/share/doc/release-notes/release-note-5 [email protected]
+            close """
+
+        hovercraft = """
+            open [email protected],5.10-0
+            add file tmp/release-note-6 mode=0644 owner=root group=root path=/usr/share/doc/release-notes/release-note-6 release-note=feature/pkg/self@0
+            close """
+
+        misc_files = {
+                "tmp/release-note-1":"bobcats are fun!",
+                "tmp/release-note-2":"wombats are fun!",
+                "tmp/release-note-3":"no animals were hurt...",
+                "tmp/release-note-4":"no vegetables were hurt...",
+                "tmp/release-note-5":"multi-line release notes\nshould work too,\nwe'll see if they do.",
+                "tmp/release-note-6":u"Eels are best smoked\nМоё судно на воздушной подушке полно угрей\nHovercraft can be smoked, too.\n",
+                }
+
+        def setUp(self):
+                pkg5unittest.SingleDepotTestCase.setUp(self)
+                self.make_misc_files(self.misc_files)
+                self.pkgsend_bulk(self.rurl, self.foo10 + self.foo11 + 
+                    self.foo12 + self.foo13 + self.bar10 + self.bar11 + self.baz10 +
+                    self.hovercraft)
+                self.image_create(self.rurl)
+
+        def test_release_note_1(self):
+                # make sure release note gets printed on original install
+                self.pkg("install -v [email protected]")
+                self.output.index("bobcats are fun!")
+                # check update case
+                self.pkg("update -v [email protected]")
+                self.output.index("wombats are fun!")
+                # check must display case
+                self.pkg("update [email protected]")
+                self.output.index("no animals")
+                # check that no output is seen w/o must-display and -v,
+                # but that user is prompted that notes are available.
+                self.pkg("update [email protected]")
+                assert self.output.find("no vegetables") == -1
+
+        def test_release_note_2(self):
+                self.pkg("uninstall '*'")
+                # check that release notes are printed with just -n
+                self.pkg("install -vn [email protected]")
+                self.output.index("bobcats are fun!")
+                # retrieve release notes with pkg history after actual install
+                self.pkg("install [email protected]")
+                # make sure we note that release notes are available
+                self.output.index("Release notes")
+                # check that we list them in the -l output
+                self.pkg("history -n 1 -l")
+                self.output.index("Release Notes")
+                # retrieve notes and look for felines
+                self.pkg("history -n 1 -N")
+                self.output.index("bobcats are fun!")
+                # check that we say yes that release notes are available
+                self.pkg("history -Hn 1 -o release_notes")
+                self.output.index("Yes")
+
+        def test_release_note_3(self):
+                # check that release notes are printed properly
+                # when needed and dependency is on other pkg
+                self.pkg("uninstall '*'")
+                self.pkg("install [email protected]")
+                self.pkg("install -v [email protected]")
+                self.output.index("multi-line release notes")
+                self.output.index("should work too,")
+                self.output.index("we'll see if they do.")
+                # should not see notes again
+                self.pkg("update -v bar")
+                assert self.output.find("Release notes") == -1
+                self.pkg("uninstall '*'")
+                # no output expected here since [email protected] isn't part of original image.
+                self.pkg("install [email protected] [email protected]")
+                assert self.output.find("multi-line release notes") == -1
+
+        def test_release_note_4(self):
+                # make sure that parseable option works properly
+                self.pkg("uninstall '*'")                
+                self.pkg("install [email protected]")
+                self.pkg("install --parsable 0 [email protected]")
+                self.output.index("multi-line release notes")
+                self.output.index("should work too,")
+                self.output.index("we'll see if they do.")
+                self.pkg("uninstall '*'")
+                # test unicode character in files
+                self.pkg("install -n [email protected]")
+                unicode(self.output, "utf-8").index(u"Моё судно на воздушной подушке полно угрей")
+                unicode(self.output, "utf-8").index(u"Eels are best smoked")
+                self.pkg("install -v [email protected]")                
+                unicode(self.output, "utf-8").index(u"Моё судно на воздушной подушке полно угрей")
+                unicode(self.output, "utf-8").index(u"Eels are best smoked")
+                self.pkg("uninstall '*'")
+                self.pkg("install --parsable 0 [email protected]")                
+                self.pkg("history -n 1 -N")
+                unicode(self.output, "utf-8").index(u"Моё судно на воздушной подушке полно угрей")
+                unicode(self.output, "utf-8").index(u"Eels are best smoked")
 if __name__ == "__main__":
         unittest.main()
--- a/src/tests/pkg5unittest.py	Mon Jun 25 07:48:23 2012 +0530
+++ b/src/tests/pkg5unittest.py	Mon Jun 25 09:51:09 2012 -0700
@@ -398,6 +398,8 @@
                         ins = " [+%d lines...]" % (len(lines) - 1)
                 else:
                         ins = ""
+                if isinstance(lines[0], unicode):
+                        lines[0] = lines[0].encode("utf-8")
                 self.debugcmd(
                     "echo '%s%s' > %s" % (lines[0], ins, path))
 
@@ -803,7 +805,7 @@
             change_facets=EmptyI, change_packages=EmptyI,
             change_mediators=EmptyI, change_variants=EmptyI,
             child_images=EmptyI, create_backup_be=False, create_new_be=False,
-            image_name=None, licenses=EmptyI, remove_packages=EmptyI,
+            image_name=None, licenses=EmptyI, remove_packages=EmptyI, release_notes=EmptyI,
             version=0):
                 """Check that the parsable output in 'output' is what is
                 expected."""