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]>
193 lines
7.2 KiB
Python
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()
|