distribution: establish the OS release contract
This commit is contained in:
parent
287acdffd9
commit
d1312cbb06
5 changed files with 291 additions and 0 deletions
106
tools/validate-distribution.py
Normal file
106
tools/validate-distribution.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate the SouveraineOS distribution contract without network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PACKAGE_STATES = {"managed", "blocked", "planned"}
|
||||
PROFILE_STATES = {"ready", "blocked", "planned"}
|
||||
TARGET_STATES = {"supported", "bringup", "planned"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--ready", metavar="PROFILE", help="require one profile to be installer-ready")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with args.manifest.open("rb") as source:
|
||||
data = tomllib.load(source)
|
||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"invalid manifest: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
errors: list[str] = []
|
||||
def error(message: str) -> None:
|
||||
errors.append(message)
|
||||
|
||||
if data.get("schema") != 1:
|
||||
error("schema must be 1")
|
||||
if data.get("archive", {}).get("publisher_rule") != "additive":
|
||||
error("archive publisher_rule must be additive")
|
||||
|
||||
producers = data.get("producers", {})
|
||||
packages = data.get("packages", {})
|
||||
for name, package in packages.items():
|
||||
if package.get("state") not in PACKAGE_STATES:
|
||||
error(f"package {name}: invalid state {package.get('state')!r}")
|
||||
if package.get("producer") not in producers and package.get("state") != "planned":
|
||||
error(f"package {name}: unknown producer {package.get('producer')!r}")
|
||||
if not package.get("architectures"):
|
||||
error(f"package {name}: no architecture declared")
|
||||
|
||||
profiles = data.get("profiles", {})
|
||||
for profile_name, profile in profiles.items():
|
||||
status = profile.get("status")
|
||||
architectures = set(profile.get("architectures", []))
|
||||
if status not in PROFILE_STATES:
|
||||
error(f"profile {profile_name}: invalid status {status!r}")
|
||||
if not architectures:
|
||||
error(f"profile {profile_name}: no architecture declared")
|
||||
for name in profile.get("packages", []):
|
||||
package = packages.get(name)
|
||||
if package is None:
|
||||
error(f"profile {profile_name}: unknown package {name}")
|
||||
continue
|
||||
if not architectures.intersection(package.get("architectures", [])):
|
||||
error(f"profile {profile_name}: {name} has no matching architecture")
|
||||
if status == "ready" and package.get("state") != "managed":
|
||||
error(f"profile {profile_name}: ready profile includes {name} ({package.get('state')})")
|
||||
for name in profile.get("host_tools", []):
|
||||
package = packages.get(name)
|
||||
if package is None:
|
||||
error(f"profile {profile_name}: unknown host tool {name}")
|
||||
elif status == "ready" and package.get("state") != "managed":
|
||||
error(f"profile {profile_name}: ready profile includes host tool {name} ({package.get('state')})")
|
||||
|
||||
targets = data.get("targets", {})
|
||||
for target_name, target in targets.items():
|
||||
profile = profiles.get(target.get("profile"))
|
||||
if target.get("state") not in TARGET_STATES:
|
||||
error(f"target {target_name}: invalid state {target.get('state')!r}")
|
||||
if profile is None:
|
||||
error(f"target {target_name}: unknown profile {target.get('profile')!r}")
|
||||
continue
|
||||
if target.get("architecture") not in profile.get("architectures", []):
|
||||
error(f"target {target_name}: architecture does not match its profile")
|
||||
if target.get("state") == "supported" and profile.get("status") == "planned":
|
||||
error(f"target {target_name}: supported target cannot use a planned profile")
|
||||
|
||||
if errors:
|
||||
print("distribution manifest is invalid:", file=sys.stderr)
|
||||
print("\n".join(f" - {item}" for item in errors), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
blocked = [name for name, profile in profiles.items() if profile.get("status") != "ready"]
|
||||
print(f"distribution manifest valid: {len(targets)} targets, {len(packages)} packages, {len(profiles)} profiles")
|
||||
if blocked:
|
||||
print("profiles not ready: " + ", ".join(blocked))
|
||||
if args.ready:
|
||||
profile = profiles.get(args.ready)
|
||||
if profile is None:
|
||||
print(f"unknown profile: {args.ready}", file=sys.stderr)
|
||||
return 2
|
||||
if profile.get("status") != "ready":
|
||||
print(f"profile {args.ready} is not ready for a public installer", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue