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.
This commit is contained in:
parent
77452492ec
commit
8b87b17980
5 changed files with 397 additions and 5 deletions
|
|
@ -33,6 +33,8 @@ rmgr --version
|
|||
rmgr observe --path .
|
||||
rmgr reconcile --path .
|
||||
rmgr update-task-status --path . --task-id <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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
281
src/repo_manager/commands/rapp.py
Normal file
281
src/repo_manager/commands/rapp.py
Normal file
|
|
@ -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)
|
||||
59
tests/test_rapp_scaffold.py
Normal file
59
tests/test_rapp_scaffold.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue