Skip to contentSkip to conversation

Add optional task signatures to buildhistory

PATCH New Paul Eggleton 1 participant 7 messages Apr 7, 2017

lore
Share & tools
lore
#0 lore raw

cover Add optional task signatures to buildhistory

If you're looking to compare task signatures between builds, that can actually be a bit difficult to do if you don't have anything printed out (e.g. from an error) with some signatures to look for. buildhistory already makes a commit (or several commits) for each build, so we can actually write the signatures into buildhistory so that we can compare them later. If available (via the tasks actually having run, or by explicitly creating them), we can even use the siginfo/sigdata files to dig down and find out exactly what caused the signature changes.

Finally, add sha256sums for each file staged by every shared state task so that we can see when a task re-executes if the file contents has changed.

(All of this functionality defaults to off, you need to add "task" to BUILDHISTORY_FEATURES in order to enable it).

There's also a small fix making it a bit easier to run the buildhistory-diff script from the buildhistory repository.

NOTE: this patch series requires some of the patches in the set recently sent to the bitbake-devel list.

The following changes since commit 901659a51cd53625a93f57a9c5865e90a07ec09d:

oeqa/runtime/utils/targetbuildproject: use parent classes defaults tmpdir (2017-04-06 10:13:34 +0100)

are available in the git repository at:

git://git.openembedded.org/openembedded-core-contrib paule/buildhistory-sigs
http://cgit.openembedded.org/openembedded-core-contrib/log/?h=paule/buildhistory-sigs

Paul Eggleton (6):
buildhistory-diff: operate from buildhistory directory classes/buildhistory: write out task signatures on every build
buildhistory-diff: add option to compare task signature list
buildhistory-diff: add option to compare actual signature differences classes/uninative: set SSTATEPOSTUNPACKFUNCS[vardepvalueexclude] properly classes/buildhistory: save output file signatures for sstate tasks

 meta/classes/buildhistory.bbclass    |  40 +++++++++++++
 meta/classes/uninative.bbclass       |   2 +-
 meta/lib/oe/buildhistory_analysis.py | 109 ++++++++++++++++++++++++++++++++++-
 meta/lib/oe/sstatesig.py             |  10 ++++
 scripts/buildhistory-diff            |  13 ++++-
 5 files changed, 170 insertions(+), 4 deletions(-)
Signature · 1 line
2.9.3
#1 lore raw

PATCH 1/6 buildhistory-diff: operate from buildhistory directory

If the cwd is named "buildhistory" and the user hasn't specified an alternative path on the command line, then assume that the current directory is the buildhistory directory. This makes it easier to run buildhistory-diff and also interact with the buildhistory git repository as you no longer have to jump into the buildhistory directory and up to the parent again when doing so.

---
 scripts/buildhistory-diff | 5 +++++
 1 file changed, 5 insertions(+)
scripts/buildhistory-diff +5 -0
index d8ca12d..e03ccc5 100755
@@ -46,6 +46,11 @@ def main():
         sys.exit(1)
 
     if not os.path.exists(options.buildhistory_dir):
+        if options.buildhistory_dir == 'buildhistory/':
+            cwd = os.getcwd()
+            if os.path.basename(cwd) == 'buildhistory':
+                options.buildhistory_dir = cwd
+    if not os.path.exists(options.buildhistory_dir):
         sys.stderr.write('Buildhistory directory "%s" does not exist\n\n' % options.buildhistory_dir)
         parser.print_help()
         sys.exit(1)
Signature · 1 line
2.9.3
#2 lore raw

PATCH 2/6 classes/buildhistory: write out task signatures on every build

In reply to Paul Eggleton

If we want to determine what changed since the last build, one angle from which to look at it is to check the signatures. However, if we don't actually have the signatures from the last build we don't have anywhere to start. Save the signatures on each build in order to give us the starting point.

NOTE: you need to set your BUILDHISTORY_FEATURES value to include "task" to enable collection of these signatures as it is is disabled by default.

---
 meta/classes/buildhistory.bbclass | 12 ++++++++++++
 meta/lib/oe/sstatesig.py          | 10 ++++++++++
 2 files changed, 22 insertions(+)
meta/classes/buildhistory.bbclass +12 -0
index 109b375..0cafad5 100644
@@ -596,6 +596,17 @@ POPULATE_SDK_POST_HOST_COMMAND[vardepvalueexclude] .= "| buildhistory_list_insta
 SDK_POSTPROCESS_COMMAND_append = " buildhistory_get_sdkinfo ; buildhistory_get_extra_sdkinfo; "
 SDK_POSTPROCESS_COMMAND[vardepvalueexclude] .= "| buildhistory_get_sdkinfo ; buildhistory_get_extra_sdkinfo; "
 
+python buildhistory_write_sigs() {
+    if not "task" in (d.getVar('BUILDHISTORY_FEATURES') or "").split():
+        return
+
+    # Create sigs file
+    if hasattr(bb.parse.siggen, 'dump_siglist'):
+        taskoutdir = os.path.join(d.getVar('BUILDHISTORY_DIR'), 'task')
+        bb.utils.mkdirhier(taskoutdir)
+        bb.parse.siggen.dump_siglist(os.path.join(taskoutdir, 'tasksigs.txt'))
+}
+
 def buildhistory_get_build_id(d):
     if d.getVar('BB_WORKERCONTEXT') != '1':
         return ""
@@ -761,6 +772,7 @@ python buildhistory_eventhandler() {
                 shutil.rmtree(olddir)
             if e.data.getVar("BUILDHISTORY_COMMIT") == "1":
                 bb.note("Writing buildhistory")
+                bb.build.exec_func("buildhistory_write_sigs", d)
                 localdata = bb.data.createCopy(e.data)
                 localdata.setVar('BUILDHISTORY_BUILD_FAILURES', str(e._failures))
                 interrupted = getattr(e, '_interrupted', 0)
meta/lib/oe/sstatesig.py +10 -0
index 13fd3bd..56b33ba 100644
@@ -211,6 +211,16 @@ class SignatureGeneratorOEBasicHash(bb.siggen.SignatureGeneratorBasicHash):
                 f.write('    "\n')
             f.write('SIGGEN_LOCKEDSIGS_TYPES_%s = "%s"' % (self.machine, " ".join(l)))
 
+    def dump_siglist(self, sigfile):
+        with open(sigfile, "w") as f:
+            tasks = []
+            for taskitem in self.taskhash:
+                (fn, task) = taskitem.rsplit(".", 1)
+                pn = self.lockedpnmap[fn]
+                tasks.append((pn, task, self.taskhash[taskitem]))
+            for (pn, task, taskhash) in sorted(tasks):
+                f.write('%s.%s %s\n' % (pn, task, taskhash))
+
     def checkhashes(self, missed, ret, sq_fn, sq_task, sq_hash, sq_hashfn, d):
         warn_msgs = []
         error_msgs = []
Signature · 1 line
2.9.3
#3 lore raw

PATCH 3/6 buildhistory-diff: add option to compare task signature list

In reply to Paul Eggleton

Having added writing out of the task signature list to buildhistory (when BUILDHISTORY_FEATURES includes "task"), we now need a way to compare the list. This just shows which tasks have been added / changed signature / removed.

---
 meta/lib/oe/buildhistory_analysis.py | 49 ++++++++++++++++++++++++++++++++++--
 meta/lib/oe/sstatesig.py             |  6 ++---
 scripts/buildhistory-diff            |  5 +++-
 3 files changed, 54 insertions(+), 6 deletions(-)
meta/lib/oe/buildhistory_analysis.py +47 -2
index 19b3bc4..449446f 100644
@@ -1,6 +1,6 @@
 # Report significant differences in the buildhistory repository since a specific revision
 #
-# Copyright (C) 2012 Intel Corporation
+# Copyright (C) 2012-2013, 2016-2017 Intel Corporation
 # Author: Paul Eggleton <[email protected]>
 #
 # Note: requires GitPython 0.3.1+
@@ -410,13 +410,58 @@ def compare_dict_blobs(path, ablob, bblob, report_all, report_ver):
     return changes
 
 
-def process_changes(repopath, revision1, revision2='HEAD', report_all=False, report_ver=False):
+def compare_siglists(a_blob, b_blob):
+    # FIXME collapse down a recipe's tasks?
+    alines = a_blob.data_stream.read().decode('utf-8').splitlines()
+    blines = b_blob.data_stream.read().decode('utf-8').splitlines()
+    keys = []
+    pnmap = {}
+    def readsigs(lines):
+        sigs = {}
+        for line in lines:
+            linesplit = line.split()
+            if len(linesplit) > 2:
+                sigs[linesplit[0]] = linesplit[2]
+                if not linesplit[0] in keys:
+                    keys.append(linesplit[0])
+                pnmap[linesplit[1]] = linesplit[0].rsplit('.', 1)[0]
+        return sigs
+    adict = readsigs(alines)
+    bdict = readsigs(blines)
+    out = []
+    changecount = 0
+    addcount = 0
+    removecount = 0
+    for key in keys:
+        siga = adict.get(key, None)
+        sigb = bdict.get(key, None)
+        if siga is not None and sigb is not None and siga != sigb:
+            out.append('%s changed from %s to %s' % (key, siga, sigb))
+            changecount += 1
+        elif siga is None:
+            out.append('%s was added' % key)
+            addcount += 1
+        elif sigb is None:
+            removecount += 1
+            out.append('%s was removed' % key)
+    out.append('Summary: %d tasks added, %d tasks removed, %d tasks modified (%.1f%%)' % (addcount, removecount, changecount, (changecount / float(len(bdict)) * 100)))
+    return '\n'.join(out)
+
+
+def process_changes(repopath, revision1, revision2='HEAD', report_all=False, report_ver=False, sigs=False):
     repo = git.Repo(repopath)
     assert repo.bare == False
     commit = repo.commit(revision1)
     diff = commit.diff(revision2)
 
     changes = []
+
+    if sigs:
+        for d in diff.iter_change_type('M'):
+            if d.a_blob.path == 'siglist.txt':
+                changes.append(compare_siglists(d.a_blob, d.b_blob))
+        return changes
+
     for d in diff.iter_change_type('M'):
         path = os.path.dirname(d.a_blob.path)
         if path.startswith('packages/'):
meta/lib/oe/sstatesig.py +3 -3
index 56b33ba..d5377db 100644
@@ -217,9 +217,9 @@ class SignatureGeneratorOEBasicHash(bb.siggen.SignatureGeneratorBasicHash):
             for taskitem in self.taskhash:
                 (fn, task) = taskitem.rsplit(".", 1)
                 pn = self.lockedpnmap[fn]
-                tasks.append((pn, task, self.taskhash[taskitem]))
-            for (pn, task, taskhash) in sorted(tasks):
-                f.write('%s.%s %s\n' % (pn, task, taskhash))
+                tasks.append((pn, task, fn, self.taskhash[taskitem]))
+            for (pn, task, fn, taskhash) in sorted(tasks):
+                f.write('%s.%s %s %s\n' % (pn, task, fn, taskhash))
 
     def checkhashes(self, missed, ret, sq_fn, sq_task, sq_hash, sq_hashfn, d):
         warn_msgs = []
scripts/buildhistory-diff +4 -1
index e03ccc5..e8e3e11 100755
@@ -33,6 +33,9 @@ def main():
     parser.add_option("-a", "--report-all",
             help = "Report all changes, not just the default significant ones",
             action="store_true", dest="report_all", default=False)
+    parser.add_option("-s", "--signatures",
+            help = "Report on signature differences instead of output",
+            action="store_true", dest="sigs", default=False)
 
     options, args = parser.parse_args(sys.argv)
 
@@ -86,7 +89,7 @@ def main():
 
     import gitdb
     try:
-        changes = oe.buildhistory_analysis.process_changes(options.buildhistory_dir, fromrev, torev, options.report_all, options.report_ver)
+        changes = oe.buildhistory_analysis.process_changes(options.buildhistory_dir, fromrev, torev, options.report_all, options.report_ver, options.sigs)
     except gitdb.exc.BadObject as e:
         if len(args) == 1:
             sys.stderr.write("Unable to find previous build revision in buildhistory repository\n\n")
Signature · 1 line
2.9.3
#4 lore raw

PATCH 4/6 buildhistory-diff: add option to compare actual signature differences

In reply to Paul Eggleton

Use the code underpinning bitbake-diffsigs to add an option to buildhistory-diff to determine and display the differences between the actual signature inputs, with a twist - we collapse identical changes across different tasks, showing only the most recent task to have that difference, meaning that there's less noise to wade through when you just want to know what changed in order to cause some rebuilding you're seeing.

---
 meta/lib/oe/buildhistory_analysis.py | 92 +++++++++++++++++++++++++++++-------
 scripts/buildhistory-diff            |  7 ++-
 2 files changed, 81 insertions(+), 18 deletions(-)
meta/lib/oe/buildhistory_analysis.py +76 -16
index 449446f..3a5b7b6 100644
@@ -13,7 +13,10 @@ import os.path
 import difflib
 import git
 import re
+import hashlib
+import collections
 import bb.utils
+import bb.tinfoil
 
 
 # How to display fields
@@ -410,7 +413,7 @@ def compare_dict_blobs(path, ablob, bblob, report_all, report_ver):
     return changes
 
 
-def compare_siglists(a_blob, b_blob):
+def compare_siglists(a_blob, b_blob, taskdiff=False):
     # FIXME collapse down a recipe's tasks?
     alines = a_blob.data_stream.read().decode('utf-8').splitlines()
     blines = b_blob.data_stream.read().decode('utf-8').splitlines()
@@ -429,26 +432,83 @@ def compare_siglists(a_blob, b_blob):
     adict = readsigs(alines)
     bdict = readsigs(blines)
     out = []
+
     changecount = 0
     addcount = 0
     removecount = 0
-    for key in keys:
-        siga = adict.get(key, None)
-        sigb = bdict.get(key, None)
-        if siga is not None and sigb is not None and siga != sigb:
-            out.append('%s changed from %s to %s' % (key, siga, sigb))
-            changecount += 1
-        elif siga is None:
-            out.append('%s was added' % key)
-            addcount += 1
-        elif sigb is None:
-            removecount += 1
-            out.append('%s was removed' % key)
+    if taskdiff:
+        with bb.tinfoil.Tinfoil() as tinfoil:
+            tinfoil.prepare(config_only=True)
+
+            changes = collections.OrderedDict()
+
+            def compare_hashfiles(pn, taskname, hash1, hash2):
+                hashes = [hash1, hash2]
+                hashfiles = bb.siggen.find_siginfo(pn, taskname, hashes, tinfoil.config_data)
+
+                if not taskname:
+                    (pn, taskname) = pn.rsplit('.', 1)
+                    pn = pnmap.get(pn, pn)
+                desc = '%s.%s' % (pn, taskname)
+
+                if len(hashfiles) == 0:
+                    out.append("Unable to find matching sigdata for %s with hashes %s or %s" % (desc, hash1, hash2))
+                elif not hash1 in hashfiles:
+                    out.append("Unable to find matching sigdata for %s with hash %s" % (desc, hash1))
+                elif not hash2 in hashfiles:
+                    out.append("Unable to find matching sigdata for %s with hash %s" % (desc, hash2))
+                else:
+                    out2 = bb.siggen.compare_sigfiles(hashfiles[hash1], hashfiles[hash2], recursecb, collapsed=True)
+                    for line in out2:
+                        m = hashlib.sha256()
+                        m.update(line.encode('utf-8'))
+                        entry = changes.get(m.hexdigest(), (line, []))
+                        if desc not in entry[1]:
+                            changes[m.hexdigest()] = (line, entry[1] + [desc])
+
+            # Define recursion callback
+            def recursecb(key, hash1, hash2):
+                compare_hashfiles(key, None, hash1, hash2)
+                return []
+
+            for key in keys:
+                siga = adict.get(key, None)
+                sigb = bdict.get(key, None)
+                if siga is not None and sigb is not None and siga != sigb:
+                    changecount += 1
+                    (pn, taskname) = key.rsplit('.', 1)
+                    compare_hashfiles(pn, taskname, siga, sigb)
+                elif siga is None:
+                    addcount += 1
+                elif sigb is None:
+                    removecount += 1
+        for key, item in changes.items():
+            line, tasks = item
+            if len(tasks) == 1:
+                desc = tasks[0]
+            elif len(tasks) == 2:
+                desc = '%s and %s' % (tasks[0], tasks[1])
+            else:
+                desc = '%s and %d others' % (tasks[-1], len(tasks)-1)
+            out.append('%s: %s' % (desc, line))
+    else:
+        for key in keys:
+            siga = adict.get(key, None)
+            sigb = bdict.get(key, None)
+            if siga is not None and sigb is not None and siga != sigb:
+                out.append('%s changed from %s to %s' % (key, siga, sigb))
+                changecount += 1
+            elif siga is None:
+                out.append('%s was added' % key)
+                addcount += 1
+            elif sigb is None:
+                out.append('%s was removed' % key)
+                removecount += 1
     out.append('Summary: %d tasks added, %d tasks removed, %d tasks modified (%.1f%%)' % (addcount, removecount, changecount, (changecount / float(len(bdict)) * 100)))
     return '\n'.join(out)
 
 
-def process_changes(repopath, revision1, revision2='HEAD', report_all=False, report_ver=False, sigs=False):
+def process_changes(repopath, revision1, revision2='HEAD', report_all=False, report_ver=False, sigs=False, sigsdiff=False):
     repo = git.Repo(repopath)
     assert repo.bare == False
     commit = repo.commit(revision1)
@@ -456,10 +516,10 @@ def process_changes(repopath, revision1, revision2='HEAD', report_all=False, rep
 
     changes = []
 
-    if sigs:
+    if sigs or sigsdiff:
         for d in diff.iter_change_type('M'):
             if d.a_blob.path == 'siglist.txt':
-                changes.append(compare_siglists(d.a_blob, d.b_blob))
+                changes.append(compare_siglists(d.a_blob, d.b_blob, taskdiff=sigsdiff))
         return changes
 
     for d in diff.iter_change_type('M'):
scripts/buildhistory-diff +5 -2
index e8e3e11..dd9745e 100755
@@ -34,8 +34,11 @@ def main():
             help = "Report all changes, not just the default significant ones",
             action="store_true", dest="report_all", default=False)
     parser.add_option("-s", "--signatures",
-            help = "Report on signature differences instead of output",
+            help = "Report list of signatures differing instead of output",
             action="store_true", dest="sigs", default=False)
+    parser.add_option("-S", "--signatures-with-diff",
+            help = "Report on actual signature differences instead of output (requires signature data to have been generated, either by running the actual tasks or using bitbake -S)",
+            action="store_true", dest="sigsdiff", default=False)
 
     options, args = parser.parse_args(sys.argv)
 
@@ -89,7 +92,7 @@ def main():
 
     import gitdb
     try:
-        changes = oe.buildhistory_analysis.process_changes(options.buildhistory_dir, fromrev, torev, options.report_all, options.report_ver, options.sigs)
+        changes = oe.buildhistory_analysis.process_changes(options.buildhistory_dir, fromrev, torev, options.report_all, options.report_ver, options.sigs, options.sigsdiff)
     except gitdb.exc.BadObject as e:
         if len(args) == 1:
             sys.stderr.write("Unable to find previous build revision in buildhistory repository\n\n")
Signature · 1 line
2.9.3
#5 lore raw

PATCH 5/6 classes/uninative: set SSTATEPOSTUNPACKFUNCS[vardepvalueexclude] properly

In reply to Paul Eggleton

Append to the value with appendVarFlag() instead of setting it outright, so that we can also append to it in other places. Accordingly, this varflag is pipe-separated (since we want to be able to exclude any string fragment, in this case including the leading space), thus put a leading pipe character to play nicely with any existing value.

---
 meta/classes/uninative.bbclass | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
meta/classes/uninative.bbclass +1 -1
index b578031..8f34483 100644
@@ -100,7 +100,7 @@ def enable_uninative(d):
         bb.debug(2, "Enabling uninative")
         d.setVar("NATIVELSBSTRING", "universal%s" % oe.utils.host_gcc_version(d))
         d.appendVar("SSTATEPOSTUNPACKFUNCS", " uninative_changeinterp")
-        d.setVarFlag("SSTATEPOSTUNPACKFUNCS", "vardepvalueexclude", " uninative_changeinterp")
+        d.appendVarFlag("SSTATEPOSTUNPACKFUNCS", "vardepvalueexclude", "| uninative_changeinterp")
         d.prependVar("PATH", "${STAGING_DIR}-uninative/${BUILD_ARCH}-linux${bindir_native}:")
 
 python uninative_changeinterp () {
Signature · 1 line
2.9.3
#6 lore raw

PATCH 6/6 classes/buildhistory: save output file signatures for sstate tasks

In reply to Paul Eggleton

Save a file per task listing sha256sums for each file staged, i.e. the output of the task. Some caveats:

1) This only covers sstate tasks since it uses SSTATEPOSTUNPACKFUNCS, however those are generally the most interesting in terms of output anyway.
2) The signature is taken before applying any relocations, so any relocated files will actually have different signatures, but that's churn that you probably won't want to see here.
3) At the moment if you run the same build twice without sstate you will very likely see changes in the output for certain tasks due to things like timestamps being present in the binary output. Fixing that is a general Linux ecosystem problem - see this page for our efforts to resolve it on our side:
https://wiki.yoctoproject.org/wiki/Reproducible_Builds

NOTE: you need to set your BUILDHISTORY_FEATURES value to include "task" to enable collection of these signatures as it is is disabled by default.

---
 meta/classes/buildhistory.bbclass | 28 ++++++++++++++++++++++++++++
 1 file changed, 28 insertions(+)
meta/classes/buildhistory.bbclass +28 -0
index 0cafad5..748091d 100644
@@ -47,6 +47,11 @@ sstate_install[vardepsexclude] += "buildhistory_emit_pkghistory"
 # then the value added to SSTATEPOSTINSTFUNCS:
 SSTATEPOSTINSTFUNCS[vardepvalueexclude] .= "| buildhistory_emit_pkghistory"
 
+# Similarly for our function that gets the output signatures
+SSTATEPOSTUNPACKFUNCS_append = " buildhistory_emit_outputsigs"
+sstate_installpkgdir[vardepsexclude] += "buildhistory_emit_outputsigs"
+SSTATEPOSTUNPACKFUNCS[vardepvalueexclude] .= "| buildhistory_emit_outputsigs"
+
 # All items excepts those listed here will be removed from a recipe's
 # build history directory by buildhistory_emit_pkghistory(). This is
 # necessary because some of these items (package directories, files that
@@ -292,6 +297,29 @@ python buildhistory_emit_pkghistory() {
     bb.build.exec_func("buildhistory_list_pkg_files", d)
 }
 
+python buildhistory_emit_outputsigs() {
+    if not "task" in (d.getVar('BUILDHISTORY_FEATURES') or "").split():
+        return
+
+    taskoutdir = os.path.join(d.getVar('BUILDHISTORY_DIR'), 'task', 'output')
+    bb.utils.mkdirhier(taskoutdir)
+    currenttask = d.getVar('BB_CURRENTTASK')
+    pn = d.getVar('PN')
+    taskfile = os.path.join(taskoutdir, '%s.%s' % (pn, currenttask))
+
+    cwd = os.getcwd()
+    filesigs = {}
+    for root, _, files in os.walk(cwd):
+        for fname in files:
+            if fname == 'fixmepath':
+                continue
+            fullpath = os.path.join(root, fname)
+            filesigs[os.path.relpath(fullpath, cwd)] = bb.utils.sha256_file(fullpath)
+    with open(taskfile, 'w') as f:
+        for fpath, fsig in sorted(filesigs.items(), key=lambda item: item[0]):
+            f.write('%s %s\n' % (fpath, fsig))
+}
+
 
 def write_recipehistory(rcpinfo, d):
     bb.debug(2, "Writing recipe history")
Signature · 1 line
2.9.3