mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
Setting the release number was a platform change. FORGEFIRM_RELEASE sat in forgefirm-image.bb, the recipe hashes as content of meta-forgefirm, and a change to the content of a layer invalidates every acceptance result. So a version bump threw away the campaign that was meant to authorize that very release, and the number therefore had to be decided before the image the campaign ran on. Nothing said so: the release-flow page went straight from the kas configuration to the artifact and the pipeline, while the gate quietly required the recipe value, the rootfs stamp, the archive's meta-version and the tag to agree. v0.0.1 was cut on a tree whose number happened to be right; the next one would have cost a second campaign to discover the rule. The number moves to forgefirm-release.inc, which carries it and nothing else, and the manifest leaves that file out of the layer content hash exactly as it leaves out the component pin files (FORGEFIRM_MANIFEST_VERSION_SUFFIX, and the same list in scripts/manifest-from-tree.py, which computes the identity on a workstation and must agree byte for byte). release.sh reads the number from the new file. The version is metadata, not platform content, and this only makes the manifest say what it already meant: the version string was already outside the identity hash, and it was the file carrying it that defeated that. Nothing is weakened. release.sh still requires the number to equal the rootfs stamp, the .fw meta-version and the release tag, and image.health still compares the stamp on the running machine with the manifest's. Proven: the tree manifest is byte-identical across a bump from 0.0.1 to 0.0.2 (identity a64e51b8e5ecca0af683d4f0 either way, the meta-forgefirm layer hash unchanged), where before the two differed. bitbake resolves FORGEFIRM_RELEASE=0.0.1 and FORGEFIRM_VERSION_STRING=v0.0.1 for the release image through the new require, and the dev image still overrides the string with its build timestamp.
251 lines
11 KiB
Python
251 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2020-2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
#
|
|
# Build a ForgeFIRM image manifest from the source tree - the same file lists
|
|
# forgefirm-image-manifest.bbclass puts in the image, computed from the recipe
|
|
# pins with git instead of a Yocto build. For the coverage lint in CI and for
|
|
# checking a coverage map on a workstation; NOT a substitute for the image's
|
|
# manifest in the release gate (the platform section carries placeholders
|
|
# where only a build knows the answer: kernel config hash, modules dir, DTB).
|
|
#
|
|
# manifest-from-tree.py [--meta-openglow PATH] [--out manifest.json]
|
|
# [--cache DIR] [--kernel-srcrev REV]
|
|
#
|
|
# Component revisions come from the recipes in meta-forgefirm and the sibling
|
|
# meta-openglow checkout (default ../meta-openglow relative to this repo); a
|
|
# recipe's `require`d files in its own directory are read too, which is
|
|
# where the pins live (<recipe>-pin.inc). Each pinned commit is fetched
|
|
# shallowly into --cache (default .manifest-cache/, gitignored) and listed
|
|
# with `git ls-tree`; a submodule gitlink is followed through .gitmodules.
|
|
# The layer content hash mirrors forgefirm-image-manifest.bbclass: every
|
|
# file under the layer except *.md and *-pin.inc.
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(REPO, "forgetest"))
|
|
from forgetest import manifest as manifest_mod # noqa: E402
|
|
|
|
RECIPES = [
|
|
# (component, recipe path relative to the repo or meta-openglow, layer)
|
|
("forgectrl", "meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb", "forgefirm"),
|
|
("grblhal-glowforge", "meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb", "forgefirm"),
|
|
("forgefirm-app", "meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc", "forgefirm"),
|
|
("kernel-module-glowforge", "meta-glowforge-bsp/recipes-kernel/kernel-modules/kernel-module-glowforge.bb", "meta-openglow"),
|
|
("python3-gfhardware", "meta-glowforge-bsp/recipes-devtools/python/python3-gfhardware.bb", "meta-openglow"),
|
|
("python3-gfutilities", "meta-openglow-core/recipes-devtools/python/python3-gfutilities_git.bb", "meta-openglow"),
|
|
]
|
|
# Components built from files in a layer (FORGEFIRM_MANIFEST_SRC in the
|
|
# recipe): (component, directory relative to the repo, layer, recipe).
|
|
FILE_COMPONENTS = [
|
|
("ffboot", "meta-forgefirm/recipes-forgefirm/ffboot/files", "forgefirm", "ffboot.bb"),
|
|
]
|
|
CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
|
|
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
|
|
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
|
|
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
|
|
# (FORGEFIRM_MANIFEST_PIN_SUFFIX, FORGEFIRM_MANIFEST_VERSION_SUFFIX):
|
|
# documentation, the component pin files, and the release version file.
|
|
# All three are metadata; hashing them would turn a pin bump or a version
|
|
# bump into a platform change and invalidate every acceptance result.
|
|
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc", "forgefirm-release.inc")
|
|
|
|
|
|
def git(args, cwd=None, input=None):
|
|
return subprocess.run(["git"] + args, cwd=cwd, input=input, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, check=True).stdout
|
|
|
|
|
|
def recipe_text(path, seen=None):
|
|
"""The recipe's text with its `require`/`include`d files from the same
|
|
directory appended (bitbake resolves a relative name against the
|
|
including file's directory first). Only local files are followed;
|
|
anything else is left to BBPATH and skipped here."""
|
|
seen = seen if seen is not None else set()
|
|
path = os.path.abspath(path)
|
|
if path in seen:
|
|
return ""
|
|
seen.add(path)
|
|
with open(path, encoding="utf-8") as f:
|
|
text = f.read()
|
|
for m in re.finditer(r'^\s*(?:require|include)\s+(\S+)\s*$', text, re.M):
|
|
cand = os.path.join(os.path.dirname(path), m.group(1))
|
|
if os.path.isfile(cand):
|
|
text += "\n" + recipe_text(cand, seen)
|
|
return text
|
|
|
|
|
|
def parse_recipe(path):
|
|
text = recipe_text(path)
|
|
uri = re.search(r'^SRC_URI\s*\+?=\s*"([^"]+)"', text, re.M)
|
|
rev = re.search(r'^SRCREV\s*\??=\s*"([0-9a-fA-F]+)"', text, re.M)
|
|
if not uri or not rev:
|
|
raise SystemExit("cannot find SRC_URI/SRCREV in %s" % path)
|
|
first = uri.group(1).split()[0]
|
|
url = first.split(";")[0]
|
|
params = dict(p.split("=", 1) for p in first.split(";")[1:] if "=" in p)
|
|
proto = params.get("protocol", "https")
|
|
if url.startswith("git://") or url.startswith("gitsm://"):
|
|
url = proto + "://" + url.split("://", 1)[1]
|
|
return url, rev.group(1)
|
|
|
|
|
|
def fetch(url, rev, cache):
|
|
"""A bare cache repo containing rev (fetched shallowly)."""
|
|
name = hashlib.sha1(url.encode()).hexdigest()[:16]
|
|
repo = os.path.join(cache, name)
|
|
if not os.path.isdir(repo):
|
|
os.makedirs(repo)
|
|
git(["init", "-q", "--bare"], cwd=repo)
|
|
try:
|
|
git(["cat-file", "-e", rev + "^{commit}"], cwd=repo)
|
|
except subprocess.CalledProcessError:
|
|
git(["fetch", "-q", "--depth", "1", url, rev], cwd=repo)
|
|
return repo
|
|
|
|
|
|
def ls_tree(repo, rev, url, cache, prefix, files):
|
|
out = git(["ls-tree", "-r", "--full-tree", rev], cwd=repo).decode()
|
|
modules = None
|
|
for line in out.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
meta, path = line.split("\t", 1)
|
|
typ, obj = meta.split()[1], meta.split()[2]
|
|
files.append([prefix + path, obj])
|
|
if typ == "commit":
|
|
if modules is None:
|
|
modules = {}
|
|
try:
|
|
gm = git(["show", "%s:.gitmodules" % rev], cwd=repo).decode()
|
|
except subprocess.CalledProcessError:
|
|
gm = ""
|
|
cur = None
|
|
for l in gm.splitlines():
|
|
l = l.strip()
|
|
m = re.match(r'^path\s*=\s*(.+)$', l)
|
|
if m:
|
|
cur = m.group(1).strip()
|
|
m = re.match(r'^url\s*=\s*(.+)$', l)
|
|
if m and cur:
|
|
modules[cur] = m.group(1).strip()
|
|
sub_url = modules.get(path)
|
|
if sub_url:
|
|
if sub_url.startswith("../") or sub_url.startswith("./"):
|
|
base = url.rsplit("/", 1)[0]
|
|
sub_url = base + "/" + sub_url.lstrip("./")
|
|
sub_repo = fetch(sub_url, obj, cache)
|
|
ls_tree(sub_repo, obj, sub_url, cache, prefix + path + "/", files)
|
|
|
|
|
|
def dir_files(path):
|
|
"""[path, blob-id] per regular file under path, relative paths, the
|
|
same walk and hashing as forgefirm-manifest.bbclass (hash-object, so
|
|
the ids compare with tree ids)."""
|
|
paths = []
|
|
for root, dirs, fns in os.walk(path):
|
|
dirs[:] = sorted(x for x in dirs if x not in (".git", "__pycache__"))
|
|
for fn in fns:
|
|
if fn.endswith((".pyc", ".pyo")):
|
|
continue
|
|
p = os.path.join(root, fn)
|
|
if os.path.isfile(p) and not os.path.islink(p):
|
|
paths.append(os.path.relpath(p, path).replace(os.sep, "/"))
|
|
paths.sort()
|
|
if not paths:
|
|
return []
|
|
# absolute paths: hash-object --stdin-paths resolves relative ones against
|
|
# the enclosing repository's top level, not the cwd
|
|
ids = git(["hash-object", "--stdin-paths"], cwd=path,
|
|
input=(chr(10).join(os.path.join(path, p) for p in paths) + chr(10)).encode()).decode().split()
|
|
return [[p, i] for p, i in zip(paths, ids)]
|
|
|
|
|
|
def layer_content(path):
|
|
out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], cwd=path)
|
|
paths = sorted(set(p.decode("utf-8", "replace") for p in out.split(b"\0") if p))
|
|
paths = [p for p in paths
|
|
if os.path.isfile(os.path.join(path, p)) and not p.endswith(LAYER_SKIP_SUFFIXES)]
|
|
if not paths:
|
|
return None
|
|
# hash-object --stdin-paths resolves against the repository top level
|
|
prefix = git(["rev-parse", "--show-prefix"], cwd=path).decode().strip()
|
|
ids = git(["hash-object", "--stdin-paths"], cwd=path,
|
|
input=("\n".join(prefix + p for p in paths) + "\n").encode()).decode().split()
|
|
h = hashlib.sha256()
|
|
for p, i in zip(paths, ids):
|
|
h.update(p.encode("utf-8") + b"\0" + i.encode("ascii") + b"\n")
|
|
return h.hexdigest()
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--meta-openglow", default=os.path.join(os.path.dirname(REPO), "meta-openglow"))
|
|
ap.add_argument("--out", default="-")
|
|
ap.add_argument("--cache", default=os.path.join(REPO, ".manifest-cache"))
|
|
ap.add_argument("--kernel-srcrev", default=None,
|
|
help="linux-fslc SRCREV (default: read from layers/meta-freescale if present)")
|
|
args = ap.parse_args(argv)
|
|
os.makedirs(args.cache, exist_ok=True)
|
|
|
|
components = {}
|
|
for name, rel, layer in RECIPES:
|
|
base = REPO if layer == "forgefirm" else args.meta_openglow
|
|
path = os.path.join(base, rel)
|
|
url, rev = parse_recipe(path)
|
|
repo = fetch(url, rev, args.cache)
|
|
files = []
|
|
ls_tree(repo, rev, url, args.cache, "", files)
|
|
files.sort()
|
|
components[name] = {"srcrev": rev, "source": url, "files": files, "recipes": [os.path.basename(rel)]}
|
|
print("%s: %s (%d files)" % (name, rev[:12], len(files)), file=sys.stderr)
|
|
for name, rel, layer, recipe in FILE_COMPONENTS:
|
|
base = REPO if layer == "forgefirm" else args.meta_openglow
|
|
files = dir_files(os.path.join(base, rel))
|
|
components[name] = {"srcrev": None, "source": "files", "files": files, "recipes": [recipe]}
|
|
print("%s: files (%d files)" % (name, len(files)), file=sys.stderr)
|
|
|
|
ksrc = args.kernel_srcrev
|
|
if not ksrc:
|
|
for cand in ("layers/meta-freescale/recipes-kernel/linux/linux-fslc_6.12.bb",):
|
|
p = os.path.join(REPO, cand)
|
|
if os.path.exists(p):
|
|
m = re.search(r'^SRCREV\s*=\s*"([0-9a-f]+)"', open(p, encoding="utf-8").read(), re.M)
|
|
if m:
|
|
ksrc = m.group(1)
|
|
components["linux-fslc"] = {"srcrev": ksrc, "source": "git://github.com/Freescale/linux-fslc.git",
|
|
"config_sha256": None, "recipes": ["linux-fslc"],
|
|
"files": [["@config", "unknown-without-a-build"], ["@srcrev", ksrc or "unknown"]]}
|
|
|
|
layers = {}
|
|
for lname, (repo_key, sub) in CONTENT_LAYERS.items():
|
|
base = REPO if repo_key == "forgefirm" else args.meta_openglow
|
|
lpath = os.path.join(base, sub)
|
|
if os.path.isdir(lpath):
|
|
layers[lname] = {"content_sha256": layer_content(lpath)}
|
|
platform = {"machine": "glowforge", "layers": layers, "kernel_modules": [], "dtb": {}}
|
|
canonical = manifest_mod.canonical({"components": components, "platform": platform})
|
|
out = {"format": 1, "image": {"name": "tree", "version": "tree (no build)"},
|
|
"content_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
|
|
"components": components, "platform": platform}
|
|
text = json.dumps(out, sort_keys=True, indent=1) + "\n"
|
|
if args.out == "-":
|
|
sys.stdout.write(text)
|
|
else:
|
|
with open(args.out, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
print("wrote %s" % args.out, file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|