build_manifests/pipeline/build_config.py
Nicholas Andrew 823de71f1d Add the shared recovery build pipeline
Jenkinsfile generalizes the OFRP-12.1 job for every base branch: one job
per branch named after it (cms-<branch> jobs target the cms test portal),
base settings from the branch build.toml (validated by
pipeline/build_config.py), the same preflight, warm rollback, manifest
sync, build, rename, checksum, Garage upload and portal registration
stages, plus portal_request_id for builds started from the portal and a
warm-base lock shared by a base's live and test jobs. README documents
the device XMLs, the pipeline and build.toml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 00:21:42 -04:00

193 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
Resolve a base's build.toml for one device and print shell assignments.
build_config.py <build.toml> <codename>
Used by the shared Jenkinsfile. build.toml is data only: unknown keys, wrong
types or unexpected placeholders stop the build instead of being ignored.
"""
from __future__ import annotations
import re
import shlex
import sys
import tomllib
BASE_KEYS = {
"project": str,
"project_name": str,
"version": str,
"release_tag": str,
"release_ver": str,
"source_root": str,
"zfs_dataset": str,
"warm_check": list,
"lunch_target": str,
"make_extra": list,
"changelog": str,
"env": dict,
}
REQUIRED_BASE = BASE_KEYS.keys() - {"warm_check", "make_extra", "changelog", "env"}
DEVICE_KEYS = {"lunch_target", "make_extra", "env", "outputs"}
OUTPUT_KEYS = {"glob": str, "checksum": str, "test_zip": bool}
CHECKSUMS = {"md5", "sha256", "none"}
CODENAME = re.compile(r"^[A-Za-z0-9_]+$")
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
TOKEN = re.compile(r"^[A-Za-z0-9_.+-]+$")
ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
EXT = re.compile(r"^[a-z0-9]+$")
PLACEHOLDER = re.compile(r"\{([^}]*)\}")
def fail(message: str) -> None:
sys.exit(f"ERROR: build.toml: {message}")
def expand(value: str, where: str, codename: str) -> str:
for name in PLACEHOLDER.findall(value):
if name != "codename":
fail(f"{where}: unknown placeholder {{{name}}} (only {{codename}} is allowed)")
return value.replace("{codename}", codename)
def check_tokens(values, where: str) -> list[str]:
if not all(isinstance(v, str) and TOKEN.match(v) for v in values):
fail(f"{where}: entries must be plain words (letters, digits, _ . + -)")
return list(values)
def check_outputs(outputs, where: str, partial: bool = False) -> dict:
if not isinstance(outputs, dict) or not outputs:
fail(f"{where}: needs at least one [outputs.<ext>] table")
for ext, spec in outputs.items():
if not EXT.match(ext):
fail(f"{where}.{ext}: extension must be lowercase letters/digits")
if not isinstance(spec, dict):
fail(f"{where}.{ext}: must be a table")
for key, value in spec.items():
if key not in OUTPUT_KEYS:
fail(f"{where}.{ext}: unknown key '{key}'")
if not isinstance(value, OUTPUT_KEYS[key]):
fail(f"{where}.{ext}.{key}: wrong type")
if "glob" not in spec and not partial:
fail(f"{where}.{ext}: 'glob' is required")
if "/" in spec.get("glob", ""):
fail(f"{where}.{ext}.glob: must be a file name pattern, not a path")
if spec.get("checksum", "none") not in CHECKSUMS:
fail(f"{where}.{ext}.checksum: one of {', '.join(sorted(CHECKSUMS))}")
return outputs
def resolve(config: dict, codename: str) -> dict[str, str]:
unknown = config.keys() - {"base", "outputs", "devices"}
if unknown:
fail(f"unknown section(s): {', '.join(sorted(unknown))}")
base = config.get("base")
if not isinstance(base, dict):
fail("[base] section is missing")
for key, value in base.items():
if key not in BASE_KEYS:
fail(f"[base]: unknown key '{key}'")
if not isinstance(value, BASE_KEYS[key]):
fail(f"[base].{key}: wrong type")
missing = REQUIRED_BASE - base.keys()
if missing:
fail(f"[base]: missing {', '.join(sorted(missing))}")
if not SLUG.match(base["project"]):
fail("[base].project: lowercase portal project slug expected")
if not base["source_root"].startswith("/"):
fail("[base].source_root: must be an absolute path")
for key in ("release_tag", "release_ver", "version", "zfs_dataset"):
if not re.match(r"^[A-Za-z0-9_.+/-]+$", base[key]):
fail(f"[base].{key}: unexpected characters")
outputs = check_outputs(config.get("outputs"), "[outputs]")
lunch = base["lunch_target"]
make_extra = base.get("make_extra", [])
env = dict(base.get("env", {}))
devices = config.get("devices", {})
if not isinstance(devices, dict):
fail("[devices] must be a table of [devices.<codename>]")
override = devices.get(codename, {})
for key in override:
if key not in DEVICE_KEYS:
fail(f"[devices.{codename}]: unknown key '{key}'")
lunch = override.get("lunch_target", lunch)
make_extra = override.get("make_extra", make_extra)
env.update(override.get("env", {}))
if "outputs" in override:
merged = {ext: dict(spec) for ext, spec in outputs.items()}
for ext, spec in check_outputs(override["outputs"], f"[devices.{codename}.outputs]", partial=True).items():
merged.setdefault(ext, {}).update(spec)
outputs = check_outputs(merged, f"[devices.{codename}] outputs")
for name, value in env.items():
if not ENV_NAME.match(name) or not isinstance(value, str):
fail(f"env.{name}: names must be UPPER_CASE and values strings")
warm_check = base.get("warm_check", [])
for path in warm_check:
if (
not isinstance(path, str)
or path.startswith("/")
or ".." in path.split("/")
or not re.match(r"^[A-Za-z0-9_./-]+$", path)
):
fail(f"[base].warm_check: '{path}' must be a relative path inside the tree")
result = {
"PROJECT": base["project"],
"PROJECT_NAME": base["project_name"],
"VERSION": base["version"],
"RELEASE_TAG": base["release_tag"],
"RELEASE_VER": base["release_ver"],
"SOURCE_ROOT": base["source_root"].rstrip("/"),
"OUT_DIR": base["source_root"].rstrip("/") + "/out",
"ZFS_DATASET": base["zfs_dataset"],
"WARM_SNAPSHOT": base["zfs_dataset"] + "@warm",
"WARM_CHECK": " ".join(warm_check),
"LUNCH_TARGET": expand(lunch, "lunch_target", codename),
"MAKE_EXTRA": " ".join(check_tokens(make_extra, "make_extra")),
"CHANGELOG_DEFAULT": base.get("changelog", f"Automated {base['project_name']} testing build."),
"BUILD_ENV_NAMES": " ".join(sorted(env)),
"OUTPUT_EXTS": " ".join(sorted(outputs)),
}
if not TOKEN.match(result["LUNCH_TARGET"]):
fail("lunch_target: unexpected characters")
for name, value in env.items():
result[name] = expand(value, f"env.{name}", codename)
for ext, spec in outputs.items():
key = ext.upper()
result[f"OUTPUT_{key}_GLOB"] = expand(spec["glob"], f"outputs.{ext}.glob", codename)
result[f"OUTPUT_{key}_CHECKSUM"] = spec.get("checksum", "none")
result[f"OUTPUT_{key}_TEST_ZIP"] = "1" if spec.get("test_zip") else ""
return result
def main() -> None:
if len(sys.argv) != 3:
sys.exit(__doc__)
path, codename = sys.argv[1], sys.argv[2]
if not CODENAME.match(codename):
fail(f"bad codename '{codename}'")
try:
with open(path, "rb") as handle:
config = tomllib.load(handle)
except FileNotFoundError:
fail(f"{path} not found (every base branch needs a build.toml)")
except tomllib.TOMLDecodeError as error:
fail(f"invalid TOML: {error}")
for name, value in resolve(config, codename).items():
print(f"{name}={shlex.quote(value)}")
if __name__ == "__main__":
main()