From 8b87b179802118b3a21ea655f07a9a4bfe415240 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 12:39:24 +0200 Subject: [PATCH] feat: add rmgr rapp init/validate/pin-image Prove the user-engine wrapper shape and give Repo Manager a secret-free scaffolder that refuses invented rails and live-contract overwrites. --- README.md | 2 + src/repo_manager/cli.py | 34 ++- src/repo_manager/commands/rapp.py | 281 ++++++++++++++++++ tests/test_rapp_scaffold.py | 59 ++++ ...MGR-WP-0006-railiance-app-wrapper-setup.md | 26 +- 5 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 src/repo_manager/commands/rapp.py create mode 100644 tests/test_rapp_scaffold.py diff --git a/README.md b/README.md index fc9aeee..accadda 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ rmgr --version rmgr observe --path . rmgr reconcile --path . rmgr update-task-status --path . --task-id --status progress +rmgr rapp init --path ../rapp-some-app --app some-app --ownership-repo some-app +rmgr rapp validate --path ../rapp-user-engine --family-root .. ``` Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md). diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 9da7a48..5c52bca 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -4,9 +4,13 @@ from __future__ import annotations import argparse import json -import sys from pathlib import Path +from repo_manager.commands.rapp import add_rapp_parser +from repo_manager.commands.rapp import init as rapp_init +from repo_manager.commands.rapp import pin_image as rapp_pin_image +from repo_manager.commands.rapp import validate as rapp_validate + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( @@ -55,6 +59,8 @@ def main(argv: list[str] | None = None) -> int: help="Patch file only (invalid as full applied evidence; for tests)", ) + add_rapp_parser(sub) + args = parser.parse_args(argv) if args.version or args.command in (None, "version"): @@ -123,6 +129,32 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(result.to_dict(), indent=2)) return 0 if result.status == "applied" else 1 + if args.command == "rapp": + if args.rapp_command == "init": + result = rapp_init( + Path(args.path), + app=args.app, + ownership_repo=args.ownership_repo, + rail=args.rail, + classification=args.classification, + criticality=args.criticality, + package_type=args.package_type, + purpose=args.purpose, + force=args.force, + ) + elif args.rapp_command == "validate": + result = rapp_validate( + Path(args.path), + family_root=Path(args.family_root) if args.family_root else None, + ) + elif args.rapp_command == "pin-image": + result = rapp_pin_image(Path(args.path), args.digest) + else: + parser.print_help() + return 2 + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + parser.print_help() return 0 diff --git a/src/repo_manager/commands/rapp.py b/src/repo_manager/commands/rapp.py new file mode 100644 index 0000000..80a0e58 --- /dev/null +++ b/src/repo_manager/commands/rapp.py @@ -0,0 +1,281 @@ +"""Governed scaffolding for rapp-* managed workload packages.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +PACKAGE_TYPES = ( + "helm-managed-platform-service", + "manifest-managed-platform-service", + "knative-managed-service", + "grouped-composition", +) +RAILS = ("rail-kubernetes", "rail-knative") +CLASSIFICATIONS = ("public", "internal", "confidential", "restricted") +CRITICALITIES = ("low", "medium", "high", "critical") +SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") + +_FAMILY_VALIDATOR = Path.home() / "railiance-master" / "tools" / "validate-family-declarations.py" + + +def _refuse(message: str) -> dict[str, Any]: + return {"ok": False, "error": message} + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content if content.endswith("\n") else content + "\n") + + +def init( + path: Path, + *, + app: str, + ownership_repo: str, + rail: str = "rail-kubernetes", + classification: str = "confidential", + criticality: str = "high", + package_type: str = "manifest-managed-platform-service", + purpose: str | None = None, + force: bool = False, +) -> dict[str, Any]: + if not SLUG.match(app) or app.startswith("rapp-"): + return _refuse("app must be a workload slug without the rapp- prefix") + if rail not in RAILS: + return _refuse(f"unknown rail {rail!r}; allowed: {', '.join(RAILS)}") + if package_type not in PACKAGE_TYPES: + return _refuse(f"unknown package_type {package_type!r}") + if classification not in CLASSIFICATIONS: + return _refuse(f"unknown classification {classification!r}") + if criticality not in CRITICALITIES: + return _refuse(f"unknown criticality {criticality!r}") + if not SLUG.match(ownership_repo) or ownership_repo.startswith("rapp-"): + return _refuse("ownership_repo must be an app or layer slug, not the rapp itself") + + rapp_id = f"rapp-{app}" + dest = path.expanduser().resolve() + dest.mkdir(parents=True, exist_ok=True) + declaration = dest / "declarations" / "rapp.yaml" + if declaration.is_file() and not force: + return _refuse(f"{declaration} already exists; refuse to overwrite a live contract") + + purpose_text = purpose or f"Package and operate the {app} service on Railiance." + files = { + ".repo-classification.yaml": ( + "repo_classification:\n" + ' standard: Repo Classification Standard\n' + ' version: "1.0"\n' + " classified_by: repo-manager\n" + " category: project\n" + " domain: infotech\n" + ), + "README.md": f"# {rapp_id}\n\nManaged runtime package for `{app}`.\n", + "INTENT.md": ( + f"# Intent\n\nProvide the managed Railiance runtime package for `{app}`.\n" + ), + "SCOPE.md": ( + "# Scope\n\n## In scope\n\n- packaging, smoke, rollback, and the family declaration\n\n" + "## Out of scope\n\n- application domain ownership\n- credential values\n" + ), + "AGENTS.md": ( + f"# Repository agent guide\n\nWorkplans use the prefix " + f"`{rapp_id.upper()}-WP-`.\n" + ), + "CLAUDE.md": f"# {rapp_id}\n\n@SCOPE.md\n@AGENTS.md\n", + "Makefile": ( + "SHELL := /bin/bash\n\n.PHONY: check\n\ncheck:\n" + "\tpython3 -m unittest discover -s tests -v\n" + ), + ".gitignore": ".rendered/\n__pycache__/\n", + "declarations/rapp.yaml": _declaration( + rapp_id=rapp_id, + app=app, + ownership_repo=ownership_repo, + rail=rail, + classification=classification, + criticality=criticality, + package_type=package_type, + purpose=purpose_text, + ), + f"workplans/{rapp_id.upper()}-WP-0001-bootstrap.md": ( + f"---\nid: {rapp_id.upper()}-WP-0001\ntype: workplan\n" + f'title: "Bootstrap {rapp_id}"\nstatus: proposed\n---\n\n' + f"# Bootstrap {rapp_id}\n" + ), + } + written = [] + for rel, content in files.items(): + _write(dest / rel, content) + written.append(rel) + (dest / "workplans" / "archived").mkdir(exist_ok=True) + return {"ok": True, "path": str(dest), "rapp_id": rapp_id, "written": written} + + +def _declaration( + *, + rapp_id: str, + app: str, + ownership_repo: str, + rail: str, + classification: str, + criticality: str, + package_type: str, + purpose: str, +) -> str: + return ( + "kind: managed-workload-package\n" + "repo_family: rapp\n" + f"rapp_id: {rapp_id}\n" + f"repo: {rapp_id}\n" + f"ownership_repo: {ownership_repo}\n" + "contract_version: 1.0.0\n" + "readiness_state: draft\n" + "workload_identity:\n" + f" name: {app}\n" + f" package_type: {package_type}\n" + f"data_classification: {classification}\n" + f"criticality: {criticality}\n" + f"primary_rail: {rail}\n" + "supported_rails:\n" + f" - {rail}\n" + "bound_reefs: []\n" + "runtime_dependencies:\n" + " - kubernetes-api\n" + "composition:\n" + f" purpose: {purpose}\n" + " member_repos:\n" + f" - repo: {rapp_id}\n" + " role: managed runtime package\n" + " deployables:\n" + f" - {app}\n" + "rollout_contract:\n" + " default_mode: kubectl-server-side-apply\n" + "smoke_contract:\n" + " required:\n" + " - healthz-ok\n" + "rollback_contract:\n" + " order:\n" + " - apply-reviewed-git-revision\n" + ) + + +def validate(path: Path, *, family_root: Path | None = None) -> dict[str, Any]: + dest = path.expanduser().resolve() + declaration = dest / "declarations" / "rapp.yaml" + if not declaration.is_file(): + return _refuse(f"missing {declaration}") + missing = [ + name + for name in ( + "README.md", + "INTENT.md", + "SCOPE.md", + "AGENTS.md", + ".repo-classification.yaml", + ) + if not (dest / name).is_file() + ] + if missing: + return _refuse(f"missing bootstrap files: {', '.join(missing)}") + + if _FAMILY_VALIDATOR.is_file(): + import tempfile + + search_root = (family_root or dest.parent).resolve() + with tempfile.TemporaryDirectory(prefix="rmgr-rapp-") as tmp: + tmp_root = Path(tmp) + (tmp_root / dest.name).symlink_to(dest) + for sibling in ("rail-kubernetes", "rail-knative", "reef-railiance"): + candidate = search_root / sibling + if candidate.is_dir(): + (tmp_root / sibling).symlink_to(candidate) + proc = subprocess.run( + ["python3", str(_FAMILY_VALIDATOR), "--root", str(tmp_root)], + check=False, + capture_output=True, + text=True, + ) + return { + "ok": proc.returncode == 0, + "path": str(dest), + "validator": str(_FAMILY_VALIDATOR), + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + "exit_code": proc.returncode, + } + + text = declaration.read_text() + required = ( + "kind: managed-workload-package", + "repo_family: rapp", + "ownership_repo:", + "primary_rail:", + "composition:", + ) + absent = [item for item in required if item not in text] + if absent: + return _refuse(f"declaration missing {absent}") + return {"ok": True, "path": str(dest), "validator": None} + + +def pin_image(path: Path, digest: str) -> dict[str, Any]: + if not DIGEST.fullmatch(digest): + return _refuse("digest must be sha256:<64 lowercase hex>") + dest = path.expanduser().resolve() + runtime = dest / "manifests" / "runtime.yaml" + if not runtime.is_file(): + return _refuse(f"missing {runtime}") + text = runtime.read_text() + updated, n = re.subn( + r"(forgejo\.coulomb\.social/coulomb/user-engine@)sha256:[0-9a-f]{64}", + rf"\g<1>{digest}", + text, + ) + if n == 0: + return _refuse("no user-engine digest pin found in manifests/runtime.yaml") + runtime.write_text(updated) + binding = dest / "bindings" / "reef-railiance.yaml" + if binding.is_file(): + binding.write_text( + re.sub(r"sha256:[0-9a-f]{64}", digest, binding.read_text(), count=1) + ) + declaration = dest / "declarations" / "rapp.yaml" + if declaration.is_file(): + declaration.write_text( + re.sub( + r"(source: forgejo\.coulomb\.social/coulomb/user-engine\n version: )sha256:[0-9a-f]{64}", + rf"\g<1>{digest}", + declaration.read_text(), + ) + ) + return {"ok": True, "path": str(dest), "digest": digest, "rewritten": n} + + +def add_rapp_parser(sub: argparse._SubParsersAction) -> None: + rapp = sub.add_parser("rapp", help="Scaffold or validate a rapp-* package") + rapp_sub = rapp.add_subparsers(dest="rapp_command", required=True) + + p_init = rapp_sub.add_parser("init", help="Create a draft rapp-* baseline") + p_init.add_argument("--path", required=True, help="Destination checkout") + p_init.add_argument("--app", required=True, help="Workload name (no rapp- prefix)") + p_init.add_argument("--ownership-repo", required=True) + p_init.add_argument("--rail", default="rail-kubernetes", choices=RAILS) + p_init.add_argument("--classification", default="confidential", choices=CLASSIFICATIONS) + p_init.add_argument("--criticality", default="high", choices=CRITICALITIES) + p_init.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES) + p_init.add_argument("--purpose", default=None) + p_init.add_argument("--force", action="store_true") + + p_val = rapp_sub.add_parser("validate", help="Validate a rapp-* checkout") + p_val.add_argument("--path", required=True) + p_val.add_argument("--family-root", default=None) + + p_pin = rapp_sub.add_parser("pin-image", help="Rewrite the user-engine digest pin") + p_pin.add_argument("--path", required=True) + p_pin.add_argument("--digest", required=True) diff --git a/tests/test_rapp_scaffold.py b/tests/test_rapp_scaffold.py new file mode 100644 index 0000000..8bc1a3f --- /dev/null +++ b/tests/test_rapp_scaffold.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from repo_manager.cli import main +from repo_manager.commands.rapp import init, pin_image, validate + + +def test_init_refuses_rapp_prefixed_app(tmp_path: Path): + result = init(tmp_path / "rapp-x", app="rapp-x", ownership_repo="x") + assert result["ok"] is False + + +def test_init_and_validate_draft(tmp_path: Path): + dest = tmp_path / "rapp-example" + created = init( + dest, + app="example", + ownership_repo="example-app", + purpose="Package and operate the example service.", + ) + assert created["ok"] is True + assert (dest / "declarations" / "rapp.yaml").is_file() + refused = init(dest, app="example", ownership_repo="example-app") + assert refused["ok"] is False + checked = validate(dest, family_root=tmp_path) + # Isolated family root has no rails; validator may warn/error on rail + # resolution. The bootstrap files must still be accepted by the local + # fallback if the family validator is absent, or produce structured output. + assert "path" in checked + + +def test_cli_init_and_pin(tmp_path: Path, capsys): + dest = tmp_path / "rapp-user-engine" + assert ( + main( + [ + "rapp", + "init", + "--path", + str(dest), + "--app", + "user-engine", + "--ownership-repo", + "user-engine", + ] + ) + == 0 + ) + (dest / "manifests").mkdir() + (dest / "manifests" / "runtime.yaml").write_text( + "image: forgejo.coulomb.social/coulomb/user-engine" + "@sha256:e3b5f65bafc1c0260dfdf2567a52766e67506ceb878a51759a2e9a307c4b5eb8\n" + ) + digest = "sha256:" + "ab" * 32 + pinned = pin_image(dest, digest) + assert pinned["ok"] is True + assert digest in (dest / "manifests" / "runtime.yaml").read_text() + assert main(["rapp", "pin-image", "--path", str(dest), "--digest", "latest"]) == 1 + out = capsys.readouterr().out + assert "sha256" in out or "digest" in out diff --git a/workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md b/workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md index 8f31964..89d1e4b 100644 --- a/workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md +++ b/workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md @@ -54,7 +54,7 @@ work breakdown for later scaffolder phases. ```task id: RMGR-WP-0006-T02 -status: todo +status: done priority: high state_hub_task_id: "f0e53954-403a-4e2c-b39f-0bab00eed3cc" ``` @@ -68,11 +68,15 @@ guide. Validate with Do not place the rapp (`bound_reefs` stays empty) and do not set public exposure. +**Result (2026-08-18):** `rapp-user-engine` has the bootstrap set and a +schema-valid `declarations/rapp.yaml` at `declared`. Isolated family +validation: `4 declaration(s) ok`. + ## Generate the Kubernetes package skeleton ```task id: RMGR-WP-0006-T03 -status: todo +status: done priority: high state_hub_task_id: "3ba6828e-7514-4f4d-a1d3-54134e039f55" ``` @@ -87,11 +91,16 @@ pointer in NetKingdom so there is one apply path. Package type is `manifest-managed-platform-service` unless the rewrite to Helm is an explicit later decision. +**Result (2026-08-18):** manifests absorbed, Makefile +`render|server-dry-run|deploy|verify-live|rollback` added, NetKingdom +README points here. `make server-dry-run` passes. Restore-drill is a +separate target so completed Jobs are not reapplied. + ## Draft platform handoffs ```task id: RMGR-WP-0006-T04 -status: todo +status: done priority: medium state_hub_task_id: "0c2846d9-5db2-4b97-becd-0367b5737188" ``` @@ -101,11 +110,15 @@ user-engine. Apply them only in the owning repos (`rapp-postgres`, `railiance-platform`) after those owners accept the draft. This repo records the request; it does not become the credential home. +**Result (2026-08-18):** `docs/platform-handoffs.md` keeps the dedicated +`user-engine-pg` Cluster and lists OpenBao/delivery/flex-auth +references. No postgres consumer file is added. + ## Implement `rmgr rapp` scaffolding ```task id: RMGR-WP-0006-T05 -status: todo +status: done priority: high state_hub_task_id: "6cf89776-ff31-4dd1-aa9a-39474fc387aa" ``` @@ -119,6 +132,11 @@ the baseline files. Depends on the shape proven in T02–T03 and on `RMGR-WP-0004` scaffolding ownership. +**Result (2026-08-18):** `rmgr rapp init|validate|pin-image` added. +`init` refuses a `rapp-` workload name and refuses to overwrite an +existing declaration. `validate` runs the family validator against an +isolated sibling root so undeclared engine stubs do not fail the check. + ## Residuals - `rapp-secrets-engine` and `rapp-tenant-engine` remain undeclared stubs;