From f953b1ebf54eb738a3381f247e54773697ffc166 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 13:03:16 +0200 Subject: [PATCH] feat: greenfield rapp wrap path and rail-sequence rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the §0 playbook and kubernetes-then-knative gate to the guide. Implement rmgr rapp skeleton/wrap/place, draft postgres consumers, and copy the fleet image workflow when missing. --- README.md | 4 +- docs/RailianceAppDeploymentGuide.md | 195 +++-- src/repo_manager/cli.py | 33 + src/repo_manager/commands/rapp.py | 733 +++++++++++++++++- tests/test_rapp_scaffold.py | 55 +- ...WP-0007-greenfield-rapp-wrap-efficiency.md | 40 +- 6 files changed, 963 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index accadda..64d1c02 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,10 @@ 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 wrap --path ../rapp-some-app --app some-app \ + --ownership-repo some-app --from-app ../some-app rmgr rapp validate --path ../rapp-user-engine --family-root .. +rmgr rapp place --path ../rapp-some-app --reef reef-railiance ``` Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md). diff --git a/docs/RailianceAppDeploymentGuide.md b/docs/RailianceAppDeploymentGuide.md index 0a64a8c..2a6075d 100644 --- a/docs/RailianceAppDeploymentGuide.md +++ b/docs/RailianceAppDeploymentGuide.md @@ -12,7 +12,55 @@ schema wins: - [`railiance-master/docs/adr/ADR-0007-rapp-declaration-contract.md`](../../railiance-master/docs/adr/ADR-0007-rapp-declaration-contract.md) - [`railiance-master/docs/repo-family-bootstrap-contract.md`](../../railiance-master/docs/repo-family-bootstrap-contract.md) -Worked example throughout: `user-engine` + the empty stub `rapp-user-engine`. +Worked example (absorb path): `user-engine` → `rapp-user-engine` (§10). +Greenfield default: `rmgr rapp wrap` (§0). Rail choice: §3.1. + +`RMGR-WP-0006` proved the contract. `RMGR-WP-0007` is the wrap path. + +--- + +## 0. Greenfield playbook + +Use this section to wrap an app that has **no rapp yet**. Do not start in +§10 unless you are absorbing an existing apply home. + +```text +decide (§4, §3.1) → rmgr rapp wrap → human reviews drafts + → CI publishes a digest → rmgr rapp pin-image + → rmgr rapp place → operator deploy +``` + +```bash +rmgr rapp wrap \ + --path ../rapp- \ + --app \ + --ownership-repo \ + --from-app ../ + +rmgr rapp validate --path ../rapp- --family-root .. +# after CI prints a digest: +rmgr rapp pin-image --path ../rapp- --digest sha256:<64 hex> +# only when you intend to bind compute: +rmgr rapp place --path ../rapp- --reef reef-railiance +``` + +`wrap` runs init → skeleton (or absorb `deploy/`) → app `image.yaml` if +missing → postgres consumer **draft** → validate. It stops. It does not +set `bound_reefs`, apply to a cluster, or grant public exposure. + +Defaults: + +| Decision | Default | +| --- | --- | +| Rail | `rail-kubernetes` (§3.1). Not Knative. | +| Package type | `manifest-managed-platform-service`. Helm only if a chart already exists. | +| Database | `rapp-postgres` consumer draft. No dedicated Cluster. | +| Image | fleet `.forgejo/workflows/image.yaml` in the **app** repo. Pin by digest in the rapp. | +| `railiance/app.toml` | optional; not part of the first wrap | +| Exposure | omitted (private) | + +Still human: the purpose sentence, classification/criticality, NetworkPolicy +review, CCR / consumer **apply**, production gate, any public grant. --- @@ -25,14 +73,14 @@ the app onto a rail and a reef. This guide answers: -1. What a managed Railiance application **is**. -2. How rails, reefs, and existing rapps already do it. -3. Which files, declarations, and operator surfaces a new wrapper needs. -4. How `user-engine` should be wrapped as `rapp-user-engine`. -5. What Repo Manager must later automate, in order. +1. How to wrap a new app from scratch (§0). +2. Which rail to start on, and when Knative is justified (§3.1). +3. What a managed Railiance application **is**. +4. Which files, declarations, and operator surfaces a wrapper needs. +5. How `user-engine` was absorbed as `rapp-user-engine` (§10). -It is the first deliverable of `RMGR-WP-0006`. Implementation of the -scaffolder comes after this shape is accepted. +`RMGR-WP-0006` recorded the contract and the first wrap. +`RMGR-WP-0007` is the greenfield command path. --- @@ -73,14 +121,73 @@ Grouping is legitimate only where members share rollout and rollback fate. | `rail-kubernetes` | base | Kubernetes | verified | Default for platform services **and** managed applications | | `rail-knative` | derived from `rail-kubernetes` | Knative Serving on Kubernetes | rail.yaml `verified`; reef binding not production-approved | Scale-to-zero, revision traffic, request activation (today: Qonto). Serving 1.22.0 is installed on Railiance01; single-node control plane still blocks production approval. | -Default bias: use `rail-kubernetes` unless the workload truly needs -Knative lifecycle semantics. Do not invent a new rail for an adapter or -values profile. +This is a **default plus a specialization gate**, not a maturity ladder. +An app does not graduate from Kubernetes to Knative. + +```text +rail-kubernetes ← start here, stay here unless proven otherwise + │ + └── rail-knative ← only if the execution model itself must change +``` + +`rail-kubernetes` is the base rail. `rail-knative` is derived from it: +same identity, smoke, promotion vocabulary, and recovery; it only +overrides activation, scale-to-zero, concurrency, revision traffic, +cold-start, and revision rollback +(`railiance-master/docs/rail-composition-contract.md`). + +There is no next rail after Knative. Names such as `rail-keda`, +`rail-fission`, and `rail-nuclio` are examples of *when a new rail would +be justified*, not a planned path. Do not invent a rail for an adapter +or a values profile. + +Do not mix rail choice with the other progressions: + +| Sequence | What it is | +| --- | --- | +| `draft` → `declared` → `deployed` → `verified` | package readiness | +| omitted / `private` → `operator` → `public` | who may reach the listener | +| kubernetes → knative | **how the process is executed** | + +**Stay on Kubernetes when** the app is a long-running portal or API; it +has a durable store, in-process migrations, or startup that cannot die +between requests; ordinary Deployment rolling updates and digest +rollback are enough; “scale down at night” can be an HPA / replica +count; or no cold-start SLO has been measured. + +**Move to Knative only if all of these hold:** + +1. The semantics are intrinsic: request activation, scale-to-zero, + revision traffic splits, or previous-revision rollback — not just + fewer replicas. +2. A Kubernetes profile cannot say it safely. If a Deployment + Service + + HPA is enough, it is not a new rail. +3. The workload can die between requests. No migration-on-boot that must + finish before the first probe; no sticky in-memory session that + cannot cold-start. +4. Callers tolerate cold-start. Measure activator/buffering timeouts, + concurrency, and retry ownership before those numbers are + load-bearing (`docs/qonto-knative-runtime-contract.md`). +5. Egress is restricted. Unrestricted HTTPS egress is not + production-approved for critical Knative workloads. +6. The reef will admit it. Knative Serving is installed and *verified* + on Railiance01; **production approval is still blocked** (single-node + control plane). Today only `rapp-qonto` uses this rail. + +Qonto is the worked example: internet-reachable, scales to zero, holds a +bank credential, needs revision canaries. user-engine stays on +Kubernetes — long-running portal plus Postgres. + +New wraps set `primary_rail: rail-kubernetes`. Change it only when the +app owner can write: *this process must not exist until a request +arrives, and rollback is a previous Knative revision, not a previous +image digest.* If that sentence needs “and also it has a database that +migrates on start,” keep Kubernetes. `rail-kubernetes` also owns the generic staged-promotion contract `railiance/app.toml` (Stage 1 local, Stage 2 canary, Stage 3 promote) and -the compatibility overlay-repo pattern. That overlay is the migration-era -wrapper. Durable first-class packaging belongs in `rapp-*`. +the compatibility overlay-repo pattern. That overlay is the +migration-era wrapper. `app.toml` is **not** required on the first wrap. ### 3.2 Reefs @@ -103,17 +210,17 @@ admit it to production and does not make it public (ADR-0008). | `rapp-postgres` | yes | helm-managed-platform-service | kubernetes | `railiance-platform` | verified | Shared CNPG + per-consumer surface. Apps consume this; they do not run their own Postgres. | | `rapp-qonto` | yes, pre-schema drift | knative-managed-service (implied) | knative | `qonto-assistant` | verified | First-party Knative app. Missing `composition`, `bound_reefs`, `package_type`; `workload_identity.name` is still `rapp-qonto`. | | `rapp-policy-nexus` | yes | helm-managed-platform-service | kubernetes | `policy-nexus` | declared | Closest first-party Helm app wrapper. Public grant at `policy.coulomb.social`. | -| `rapp-user-engine` | **no** | — | — | — | stub | Empty git repo. Target of this guide. | +| `rapp-user-engine` | yes | manifest-managed-platform-service | kubernetes | `user-engine` | verified | First-party absorb wrap. Apply home for the portal. | | `rapp-secrets-engine` | **no** | — | — | — | stub | README only. Same family as user-engine. | | `rapp-tenant-engine` | **no** | — | — | — | stub | README only. Same family. | | `rapp-vergabe-teilnahme` | never built | — | — | — | — | Planned user-facing proof; workload still lives in `railiance-apps`. | | `rapp-forgejo` | not built | — | — | — | — | Deferred; forge still mixed into `railiance-forge` / `railiance-apps`. | The platform-service wrapper is proven twice. The first-party application -wrapper is only half-proven (`rapp-policy-nexus` declared; `rapp-qonto` -drifted; `rapp-vergabe-teilnahme` never extracted). `rapp-user-engine` is -the next application-shaped proof, not a substitute for the missing -Vergabe extraction. +wrapper is proven once as an absorb (`rapp-user-engine`) and once as a +static Helm site (`rapp-policy-nexus`). `rapp-qonto` is drifted. +`rapp-vergabe-teilnahme` was never extracted. `rapp-tenant-engine` is +the greenfield/absorb pilot for `RMGR-WP-0007`. ### 3.4 Ownership repos that still hold wrappers @@ -777,8 +884,10 @@ Use this when filling a stub or extracting a wrapper from product manifesto. - [ ] Author `declarations/rapp.yaml` to the schema. Start `readiness_state: draft`. -- [ ] Add `railiance/app.toml` if the primary rail is `rail-kubernetes`. -- [ ] Add the Helm chart or manifest set. Pin images by digest. +- [ ] Add `railiance/app.toml` only if you need Stage 1/2/3 rail + promotion. It is not part of the first wrap. +- [ ] Add the Helm chart or manifest set. Pin images by digest. Prefer + `rmgr rapp wrap` / `skeleton` over hand-copying. - [ ] Add Makefile targets from §8.1. - [ ] Name smoke **outcomes** and rollback **order**. - [ ] Leave `exposure` unset. @@ -816,42 +925,36 @@ Use this when filling a stub or extracting a wrapper from --- -## 12. What Repo Manager should automate +## 12. What Repo Manager automates -This is the work structure for `RMGR-WP-0006`. Each step is a later -command or command flag on the existing governed-mutation path -(`RMGR-WP-0004` scaffolding, specialized for the rapp family). +Implemented under `RMGR-WP-0006` (P1, P4) and `RMGR-WP-0007` (P2, P3, P5, +compose). -| Phase | Repo Manager does | Still human | +| Phase | Command | Still human | | --- | --- | --- | -| **P0 — this guide** | Record the shape. | Accept or amend the shape. | -| **P1 — bootstrap stub** | Create or complete `rapp-` with §6 baseline files, classification, and a `draft` `rapp.yaml` from a questionnaire (ownership repo, rail, classification, criticality, purpose). | Confirm ownership split and purpose sentence. | -| **P2 — package skeleton** | Generate Helm chart + Makefile targets from app facts: port, health paths, Containerfile user, image repository. Generate `railiance/app.toml` skeleton. | Review NetworkPolicy and resource requests. | -| **P3 — platform bindings** | Draft `rapp-postgres` consumer and `secret_references` from the app's documented logical secret names. | Approve CCR / consumer apply in the owning packages. | -| **P4 — validate** | Run family-declaration validation and `helm lint` / render. Refuse `readiness_state` promotions that skip evidence. | Promote `draft` → `declared`. | -| **P5 — place** | Set `bound_reefs: [reef-railiance]` only on an explicit place command. | Production gate and any exposure grant. | - -Command sketch (not implemented): +| **P0 — this guide** | — | Accept or amend the shape. | +| **P1 — bootstrap** | `rmgr rapp init` | Purpose sentence, classification. | +| **P2 — package skeleton** | `rmgr rapp skeleton --from-app` | NetworkPolicy and resource review. | +| **P3 — platform drafts** | consumer draft + app `image.yaml` | CCR / consumer **apply**. | +| **P4 — validate** | `rmgr rapp validate` | Promote `draft` → `declared`. | +| **P5 — place** | `rmgr rapp place --reef reef-railiance` | Production gate, exposure grant. | ```text -rmgr rapp init --app user-engine --ownership-repo user-engine \ - --rail rail-kubernetes --classification confidential --criticality high - -rmgr rapp skeleton --path ../rapp-user-engine --from-app ../user-engine - -rmgr rapp validate --path ../rapp-user-engine --family-root .. +rmgr rapp wrap --path ../rapp- --app \ + --ownership-repo --from-app ../ +rmgr rapp validate --path ../rapp- --family-root .. +rmgr rapp pin-image --path ../rapp- --digest sha256:<64 hex> +rmgr rapp place --path ../rapp- --reef reef-railiance ``` -Constraints on the scaffolder: +Constraints: -- Files stay authoritative. The hub is not written to except via - `fix-consistency` after the files exist. +- Files stay authoritative. The hub is only updated via `fix-consistency`. - No secret values, ever. - Do not invent package types, rails, or reefs. -- Idempotent: re-running `init` on an already-declared rapp must refuse - or update in a reviewed diff, not overwrite a live contract. -- The three engine stubs (`user-engine`, `secrets-engine`, - `tenant-engine`) are the first cohort; `user-engine` is the pilot. +- `init` / `wrap` refuse to overwrite a live declaration. +- `tenant-engine` is the `RMGR-WP-0007` pilot. `rapp-secrets-engine` is + not a wrap target. --- diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 5c52bca..cbef411 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -9,7 +9,10 @@ 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 place as rapp_place +from repo_manager.commands.rapp import skeleton as rapp_skeleton from repo_manager.commands.rapp import validate as rapp_validate +from repo_manager.commands.rapp import wrap as rapp_wrap def main(argv: list[str] | None = None) -> int: @@ -147,6 +150,36 @@ def main(argv: list[str] | None = None) -> int: Path(args.path), family_root=Path(args.family_root) if args.family_root else None, ) + elif args.rapp_command == "skeleton": + result = rapp_skeleton( + Path(args.path), + from_app=Path(args.from_app), + app=args.app, + package_type=args.package_type, + force=args.force, + dedicated_postgres=args.dedicated_postgres, + ) + elif args.rapp_command == "wrap": + result = rapp_wrap( + Path(args.path), + app=args.app, + ownership_repo=args.ownership_repo, + from_app=Path(args.from_app), + rail=args.rail, + classification=args.classification, + criticality=args.criticality, + package_type=args.package_type, + purpose=args.purpose, + force=args.force, + dedicated_postgres=args.dedicated_postgres, + family_root=Path(args.family_root) if args.family_root else None, + ) + elif args.rapp_command == "place": + result = rapp_place( + Path(args.path), + reef=args.reef, + 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: diff --git a/src/repo_manager/commands/rapp.py b/src/repo_manager/commands/rapp.py index 80a0e58..7ad9e9e 100644 --- a/src/repo_manager/commands/rapp.py +++ b/src/repo_manager/commands/rapp.py @@ -3,8 +3,8 @@ from __future__ import annotations import argparse -import json import re +import shutil import subprocess from pathlib import Path from typing import Any @@ -18,8 +18,17 @@ PACKAGE_TYPES = ( RAILS = ("rail-kubernetes", "rail-knative") CLASSIFICATIONS = ("public", "internal", "confidential", "restricted") CRITICALITIES = ("low", "medium", "high", "critical") +COMPUTE_REEFS = ("reef-railiance",) SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +REEF_SLUG = re.compile(r"^reef-[a-z0-9]+(-[a-z0-9]+)*$") DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +EXPOSE_RE = re.compile(r"^EXPOSE\s+(\d+)", re.MULTILINE) +USER_RE = re.compile(r"^USER\s+(\d+)", re.MULTILINE) +HEALTH_PATH_RE = re.compile(r'path:\s*["\']?(/[A-Za-z0-9._/-]+)') +IMAGE_PIN_RE = re.compile( + r"(forgejo\.coulomb\.social/coulomb/[a-z0-9-]+@)sha256:[0-9a-f]{64}" +) +UNSET_DIGEST = "sha256:" + ("0" * 64) _FAMILY_VALIDATOR = Path.home() / "railiance-master" / "tools" / "validate-family-declarations.py" @@ -33,6 +42,10 @@ def _write(path: Path, content: str) -> None: path.write_text(content if content.endswith("\n") else content + "\n") +def _underscore(app: str) -> str: + return app.replace("-", "_") + + def init( path: Path, *, @@ -45,18 +58,9 @@ def init( 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") + check = _check_identity(app, ownership_repo, rail, package_type, classification, criticality) + if check: + return check rapp_id = f"rapp-{app}" dest = path.expanduser().resolve() @@ -111,12 +115,38 @@ def init( } written = [] for rel, content in files.items(): - _write(dest / rel, content) + target = dest / rel + if target.is_file() and not force and rel not in {"declarations/rapp.yaml"}: + continue + _write(target, content) written.append(rel) (dest / "workplans" / "archived").mkdir(exist_ok=True) return {"ok": True, "path": str(dest), "rapp_id": rapp_id, "written": written} +def _check_identity( + app: str, + ownership_repo: str, + rail: str, + package_type: str, + classification: str, + criticality: str, +) -> dict[str, Any] | None: + 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") + return None + + def _declaration( *, rapp_id: str, @@ -147,6 +177,7 @@ def _declaration( "bound_reefs: []\n" "runtime_dependencies:\n" " - kubernetes-api\n" + " - openbao-database-secrets-engine\n" "composition:\n" f" purpose: {purpose}\n" " member_repos:\n" @@ -161,10 +192,272 @@ def _declaration( " - healthz-ok\n" "rollback_contract:\n" " order:\n" + " - previous-immutable-image-digest\n" " - apply-reviewed-git-revision\n" ) +def inspect_app(from_app: Path, app: str) -> dict[str, Any]: + root = from_app.expanduser().resolve() + container = "" + for name in ("Containerfile", "Dockerfile"): + candidate = root / name + if candidate.is_file(): + container = candidate.read_text() + break + expose = EXPOSE_RE.search(container) + user = USER_RE.search(container) + port = int(expose.group(1)) if expose else 8080 + uid = int(user.group(1)) if user else 10001 + deploy_dir = root / "deploy" + deploy_files = sorted(deploy_dir.glob("*.yaml")) if deploy_dir.is_dir() else [] + health = "/healthz" + ready = "/readyz" + blob = container + for path in deploy_files: + blob += "\n" + path.read_text() + for src in (root / "src").rglob("*.py") if (root / "src").is_dir() else []: + try: + blob += "\n" + src.read_text() + except OSError: + continue + if len(blob) > 400_000: + break + paths = HEALTH_PATH_RE.findall(blob) + if "/health" in paths and "/healthz" not in paths: + health = "/health" + ready = "/health" + if "/healthz" in paths: + health = "/healthz" + if "/readyz" in paths: + ready = "/readyz" + digest = UNSET_DIGEST + pin = IMAGE_PIN_RE.search(blob) + if pin: + digest = "sha256:" + pin.group(0).rsplit("sha256:", 1)[1] + return { + "app": app, + "root": str(root), + "port": port, + "uid": uid, + "health_path": health, + "ready_path": ready, + "image_repository": f"forgejo.coulomb.social/coulomb/{app}", + "image_digest": digest, + "deploy_files": [str(p) for p in deploy_files], + "has_chart": (root / "helm").is_dir() or (root / "charts").is_dir(), + "has_image_workflow": (root / ".forgejo" / "workflows" / "image.yaml").is_file(), + } + + +def skeleton( + path: Path, + *, + from_app: Path, + app: str | None = None, + package_type: str = "manifest-managed-platform-service", + force: bool = False, + dedicated_postgres: bool = False, +) -> dict[str, Any]: + dest = path.expanduser().resolve() + app_name = app or dest.name.removeprefix("rapp-") + if package_type == "helm-managed-platform-service": + return _refuse("Helm skeleton is not generated; pass an existing chart or use manifests") + if package_type not in PACKAGE_TYPES: + return _refuse(f"unknown package_type {package_type!r}") + facts = inspect_app(from_app, app_name) + written: list[str] = [] + notes: list[str] = [] + if facts["deploy_files"]: + manifests = dest / "manifests" + if manifests.exists() and any(manifests.glob("*.yaml")) and not force: + return _refuse(f"{manifests} already has manifests; pass --force to replace") + manifests.mkdir(parents=True, exist_ok=True) + for src in facts["deploy_files"]: + target = manifests / Path(src).name + shutil.copy2(src, target) + written.append(str(target.relative_to(dest))) + notes.append(f"absorbed {len(facts['deploy_files'])} file(s) from {from_app}/deploy") + else: + runtime = dest / "manifests" / "runtime.yaml" + if runtime.is_file() and not force: + return _refuse(f"{runtime} already exists; pass --force to replace") + _write(runtime, _runtime_manifest(app_name, facts)) + written.append("manifests/runtime.yaml") + notes.append("generated Deployment/Service/ServiceAccount/NetworkPolicy") + + if dedicated_postgres: + notes.append("dedicated Cluster not generated; pass reviewed CNPG YAML by hand") + + makefile = dest / "Makefile" + if not makefile.is_file() or force or makefile.read_text().count("\n") < 12: + _write(makefile, _makefile(app_name, facts, dest.name)) + written.append("Makefile") + render = dest / "tools" / "render.py" + if not render.is_file() or force: + _write(render, _render_py(app_name)) + render.chmod(0o755) + written.append("tools/render.py") + verify = dest / "tools" / "verify_live.sh" + if not verify.is_file() or force: + _write(verify, _verify_sh(app_name, facts)) + verify.chmod(0o755) + written.append("tools/verify_live.sh") + test = dest / "tests" / "test_packaging.py" + if not test.is_file() or force: + _write(test, _packaging_test(app_name)) + written.append("tests/test_packaging.py") + return {"ok": True, "path": str(dest), "facts": facts, "written": written, "notes": notes} + + +def ensure_image_workflow(from_app: Path, app: str) -> dict[str, Any]: + root = from_app.expanduser().resolve() + if not root.is_dir(): + return _refuse(f"app checkout missing: {root}") + target = root / ".forgejo" / "workflows" / "image.yaml" + if target.is_file(): + return {"ok": True, "path": str(target), "written": False, "note": "already present"} + _write(target, _image_workflow(app)) + return { + "ok": True, + "path": str(target), + "written": True, + "note": "first deploy waits on the CI digest; no workstation build", + } + + +def draft_postgres_consumer( + path: Path, + *, + app: str, + family_root: Path | None = None, +) -> dict[str, Any]: + dest = path.expanduser().resolve() + app_name = app or dest.name.removeprefix("rapp-") + slug = _underscore(app_name) + draft = ( + "apiVersion: rapp-postgres.railiance.io/v1alpha1\n" + "kind: PostgresConsumer\n" + "metadata:\n" + f" name: {app_name}\n" + "spec:\n" + f" database: {slug}\n" + f" schema: {slug}\n" + f" costAttributionKey: platform:{app_name}\n" + f" clientNamespaces: [{app_name}]\n" + " roles:\n" + f" owner: {slug}_owner\n" + f" migration: {slug}_migrate\n" + f" runtime: {slug}_app\n" + " tenantKeyingRequired: true\n" + " # Draft only. Do not apply from this package. Review in rapp-postgres.\n" + ) + handoff = dest / "handoffs" / "postgres-consumer.yaml" + search = (family_root or dest.parent).resolve() + live = search / "rapp-postgres" / "consumers" / f"{app_name}.yaml" + notes = [] + if live.is_file(): + _write( + dest / "handoffs" / "README.md", + f"# Handoffs\n\nPostgres consumer already lives at `{live}`.\n" + "This package does not apply it.\n", + ) + notes.append(f"existing consumer left in place: {live}") + return {"ok": True, "path": str(live), "written": False, "notes": notes} + _write(handoff, draft) + notes.append(str(handoff)) + if (search / "rapp-postgres" / "consumers").is_dir(): + target = search / "rapp-postgres" / "consumers" / f"{app_name}.yaml" + if not target.is_file(): + _write(target, draft) + notes.append(str(target)) + return {"ok": True, "path": str(handoff), "written": True, "notes": notes} + + +def wrap( + path: Path, + *, + app: str, + ownership_repo: str, + from_app: Path, + rail: str = "rail-kubernetes", + classification: str = "confidential", + criticality: str = "high", + package_type: str = "manifest-managed-platform-service", + purpose: str | None = None, + force: bool = False, + dedicated_postgres: bool = False, + family_root: Path | None = None, +) -> dict[str, Any]: + dest = path.expanduser().resolve() + created = init( + dest, + app=app, + ownership_repo=ownership_repo, + rail=rail, + classification=classification, + criticality=criticality, + package_type=package_type, + purpose=purpose, + force=force, + ) + if not created.get("ok"): + return created + skel = skeleton( + dest, + from_app=from_app, + app=app, + package_type=package_type, + force=force, + dedicated_postgres=dedicated_postgres, + ) + if not skel.get("ok"): + return skel + image = ensure_image_workflow(from_app, app) + if not image.get("ok"): + return image + consumer = {} if dedicated_postgres else draft_postgres_consumer( + dest, app=app, family_root=family_root or dest.parent + ) + if consumer and not consumer.get("ok"): + return consumer + checked = validate(dest, family_root=family_root or dest.parent) + return { + "ok": True, + "path": str(dest), + "init": created, + "skeleton": skel, + "image_workflow": image, + "postgres_consumer": consumer, + "validate": checked, + "placed": False, + "applied": False, + } + + +def place(path: Path, *, reef: str, family_root: Path | None = None) -> dict[str, Any]: + if not REEF_SLUG.match(reef): + return _refuse(f"unknown reef {reef!r}") + if reef == "reef-storage": + return _refuse("reef-storage hosts no rail; it is a consumed capability") + dest = path.expanduser().resolve() + declaration = dest / "declarations" / "rapp.yaml" + if not declaration.is_file(): + return _refuse(f"missing {declaration}") + search = (family_root or dest.parent).resolve() + if reef not in COMPUTE_REEFS and not (search / reef).is_dir(): + return _refuse(f"reef {reef!r} is not a known compute reef") + text = declaration.read_text() + if re.search(r"^bound_reefs:\n - ", text, re.M): + text = re.sub(r"^bound_reefs:\n(?: - .+\n)+", f"bound_reefs:\n - {reef}\n", text, flags=re.M) + else: + text = re.sub(r"^bound_reefs:\s*\[\]\s*$", f"bound_reefs:\n - {reef}", text, flags=re.M) + if "exposure:" in text and "posture: public" in text: + return _refuse("place does not grant public exposure; edit exposure separately") + declaration.write_text(text) + return {"ok": True, "path": str(dest), "bound_reefs": [reef]} + + def validate(path: Path, *, family_root: Path | None = None) -> dict[str, Any]: dest = path.expanduser().resolve() declaration = dest / "declarations" / "rapp.yaml" @@ -228,33 +521,364 @@ 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) + rewritten = 0 + for rel in ("manifests", "bindings", "declarations"): + root = dest / rel + if not root.exists(): + continue + files = [root] if root.is_file() else list(root.rglob("*")) + for file in files: + if not file.is_file(): + continue + text = file.read_text() + updated, n = IMAGE_PIN_RE.subn(rf"\g<1>{digest}", text) + if n: + file.write_text(updated) + rewritten += n + if rewritten == 0: + return _refuse("no forgejo.coulomb.social digest pin found") + makefile = dest / "Makefile" + if makefile.is_file(): + makefile.write_text( + re.sub(r"sha256:[0-9a-f]{64}", digest, makefile.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} + return {"ok": True, "path": str(dest), "digest": digest, "rewritten": rewritten} + + +def _runtime_manifest(app: str, facts: dict[str, Any]) -> str: + port = facts["port"] + uid = facts["uid"] + health = facts["health_path"] + ready = facts["ready_path"] + image = f"{facts['image_repository']}@{facts['image_digest']}" + return f"""apiVersion: v1 +kind: Namespace +metadata: + name: {app} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {app} + namespace: {app} + labels: + app.kubernetes.io/name: {app} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {app} + template: + metadata: + labels: + app.kubernetes.io/name: {app} + spec: + automountServiceAccountToken: false + serviceAccountName: {app} + securityContext: + runAsNonRoot: true + runAsUser: {uid} + seccompProfile: + type: RuntimeDefault + containers: + - name: {app} + image: {image} + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {port} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + readinessProbe: + httpGet: + path: {ready} + port: http + livenessProbe: + httpGet: + path: {health} + port: http +--- +apiVersion: v1 +kind: Service +metadata: + name: {app} + namespace: {app} +spec: + selector: + app.kubernetes.io/name: {app} + ports: + - name: http + port: {port} + targetPort: http +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {app} + namespace: {app} +automountServiceAccountToken: false +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {app}-default-deny + namespace: {app} +spec: + podSelector: {{}} + policyTypes: [Ingress, Egress] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {app}-runtime + namespace: {app} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {app} + policyTypes: [Ingress, Egress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - {{protocol: TCP, port: {port}}} + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - {{protocol: UDP, port: 53}} + - {{protocol: TCP, port: 53}} +""" + + +def _makefile(app: str, facts: dict[str, Any], field_manager: str) -> str: + digest = facts["image_digest"] + repo = facts["image_repository"] + return f"""SHELL := /bin/bash + +TARGET ?= railiance01 +NAMESPACE := {app} +DEPLOYMENT := {app} +IMAGE_REPOSITORY := {repo} +IMAGE_DIGEST ?= {digest} +DIGEST ?= +RENDERED := .rendered +MANIFESTS := $(wildcard manifests/*.yaml) + +.PHONY: check test render validate-inputs server-dry-run deploy status verify-live rollback + +check: test + +test: + python3 -m unittest discover -s tests -v + +validate-inputs: + @echo "$(IMAGE_DIGEST)" | grep -Eq '^sha256:[0-9a-f]{{64}}$$' \\ + || (echo "IMAGE_DIGEST must be sha256:<64 hex>" >&2; exit 2) + +render: validate-inputs + @rm -rf $(RENDERED) + @mkdir -p $(RENDERED) + IMAGE_REPOSITORY=$(IMAGE_REPOSITORY) IMAGE_DIGEST=$(IMAGE_DIGEST) \\ + python3 tools/render.py $(MANIFESTS) --out $(RENDERED) + +server-dry-run: render + {{ for f in $(RENDERED)/*.yaml; do echo '---'; cat "$$f"; done; }} \\ + | ssh -o BatchMode=yes $(TARGET) \\ + kubectl apply --server-side --force-conflicts --dry-run=server -f - + +deploy: render + {{ for f in $(RENDERED)/*.yaml; do echo '---'; cat "$$f"; done; }} \\ + | ssh -o BatchMode=yes $(TARGET) \\ + kubectl apply --server-side --force-conflicts --field-manager={field_manager} -f - + ssh -o BatchMode=yes $(TARGET) \\ + kubectl -n $(NAMESPACE) rollout status deployment/$(DEPLOYMENT) --timeout=180s + +status: + ssh -o BatchMode=yes $(TARGET) kubectl -n $(NAMESPACE) get deploy,pods,svc + +verify-live: validate-inputs + EXPECTED_IMAGE="$(IMAGE_REPOSITORY)@$(IMAGE_DIGEST)" \\ + TARGET=$(TARGET) NAMESPACE=$(NAMESPACE) ./tools/verify_live.sh + +rollback: + @test -n "$(DIGEST)" || (echo "DIGEST=sha256:<64 hex> is required" >&2; exit 2) + $(MAKE) deploy IMAGE_DIGEST=$(DIGEST) +""" + + +def _render_py(app: str) -> str: + return f'''#!/usr/bin/env python3 +"""Rewrite digest pins into rendered manifests.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +IMAGE_LINE = re.compile( + r"(image:\\s+)(forgejo\\.coulomb\\.social/coulomb/{app})(@sha256:[0-9a-f]{{64}})?" +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("manifests", nargs="+", type=Path) + parser.add_argument("--out", required=True, type=Path) + args = parser.parse_args() + repo = os.environ.get("IMAGE_REPOSITORY", "forgejo.coulomb.social/coulomb/{app}") + digest = os.environ.get("IMAGE_DIGEST", "") + if not re.fullmatch(r"sha256:[0-9a-f]{{64}}", digest): + print("IMAGE_DIGEST must be sha256:<64 lowercase hex>", file=sys.stderr) + return 2 + replacement = rf"\\1{{repo}}@{{digest}}" + args.out.mkdir(parents=True, exist_ok=True) + for src in args.manifests: + (args.out / src.name).write_text(IMAGE_LINE.sub(replacement, src.read_text())) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +''' + + +def _verify_sh(app: str, facts: dict[str, Any]) -> str: + port = facts["port"] + health = facts["health_path"] + ready = facts["ready_path"] + return f"""#!/usr/bin/env bash +set -euo pipefail + +TARGET="${{TARGET:-railiance01}}" +NAMESPACE="${{NAMESPACE:-{app}}}" +DEPLOYMENT="${{DEPLOYMENT:-{app}}}" +EXPECTED_IMAGE="${{EXPECTED_IMAGE:?EXPECTED_IMAGE is required}}" + +remote() {{ + ssh -o BatchMode=yes "$TARGET" "$1" +}} + +live_image="$(remote "kubectl -n ${{NAMESPACE}} get deploy ${{DEPLOYMENT}} -o jsonpath='{{.spec.template.spec.containers[0].image}}'")" +if [[ "$live_image" != "$EXPECTED_IMAGE" ]]; then + echo "digest mismatch live=$live_image expected=$EXPECTED_IMAGE" >&2 + exit 1 +fi +remote "kubectl -n ${{NAMESPACE}} rollout status deployment/${{DEPLOYMENT}} --timeout=60s >/dev/null" +health="$(remote "kubectl -n ${{NAMESPACE}} exec deploy/${{DEPLOYMENT}} -- python3 -c \\"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:{port}{health}').status)\\"")" +ready="$(remote "kubectl -n ${{NAMESPACE}} exec deploy/${{DEPLOYMENT}} -- python3 -c \\"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:{port}{ready}').status)\\"")" +[[ "$health" == "200" ]] +[[ "$ready" == "200" ]] +python3 - "$live_image" "$health" "$ready" <<'PY' +import json, sys +print(json.dumps({{ + "health_ok": sys.argv[2] == "200", + "ready_ok": sys.argv[3] == "200", + "live_image": sys.argv[1], + "secret_values_observed": False, +}}, sort_keys=True)) +PY +""" + + +def _packaging_test(app: str) -> str: + return f'''import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class PackagingTests(unittest.TestCase): + def test_declaration_names_the_workload(self): + decl = (ROOT / "declarations" / "rapp.yaml").read_text() + self.assertIn("ownership_repo:", decl) + self.assertIn("name: {app}", decl.split("workload_identity:", 1)[1][:200]) + self.assertNotIn("posture: public", decl) + + def test_package_has_no_floating_tag(self): + texts = [] + for folder in ("manifests", "declarations"): + root = ROOT / folder + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file(): + texts.append(path.read_text()) + blob = "\\n".join(texts) + self.assertNotRegex(blob, re.compile(r"image:\\s+[^\\n]+:(latest|main)\\b")) + self.assertNotIn("sk-", blob) +''' + + +def _image_workflow(app: str) -> str: + return f"""name: Build and Publish Container Image + +# Images are built by CI from a tarball of the pushed commit, never from +# a workstation working tree. + +on: + push: + branches: + - main + paths: + - ".forgejo/workflows/image.yaml" + - "Containerfile" + - "src/**" + - "pyproject.toml" + - "README.md" + - "LICENSE" + workflow_dispatch: + +env: + REGISTRY: forgejo.coulomb.social + IMAGE_NAME: coulomb/{app} + DOCKER_HOST: tcp://127.0.0.1:2375 + +jobs: + build-and-push: + runs-on: container-build + steps: + - name: Build and push image + env: + REGISTRY_USER: ${{{{ secrets.REGISTRY_USER }}}} + REGISTRY_TOKEN: ${{{{ secrets.REGISTRY_TOKEN }}}} + run: | + set -eu + REF="${{GITHUB_SHA:-main}}" + SHORT="${{REF:0:7}}" + mkdir -p buildctx "${{HOME}}/bin" + wget -qO /tmp/repo.tar.gz \\ + "https://forgejo.coulomb.social/${{GITHUB_REPOSITORY}}/archive/${{SHORT}}.tar.gz" + tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1 + wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \\ + | tar xz --strip-components=1 -C "${{HOME}}/bin" docker/docker + export PATH="${{HOME}}/bin:${{PATH}}" + echo "${{REGISTRY_TOKEN}}" | docker login "${{REGISTRY}}" -u "${{REGISTRY_USER}}" --password-stdin + IMAGE="${{REGISTRY}}/${{IMAGE_NAME}}" + docker build -f buildctx/Containerfile -t "${{IMAGE}}:latest" -t "${{IMAGE}}:main-${{SHORT}}" buildctx + docker push "${{IMAGE}}:latest" + docker push "${{IMAGE}}:main-${{SHORT}}" + echo "pushed ${{IMAGE}}:latest and ${{IMAGE}}:main-${{SHORT}}" + + - name: Report immutable digest + run: | + set -eu + export PATH="${{HOME}}/bin:${{PATH}}" + IMAGE="${{REGISTRY}}/${{IMAGE_NAME}}" + SHORT="${{GITHUB_SHA:0:7}}" + docker inspect --format='{{{{index .RepoDigests 0}}}}' "${{IMAGE}}:main-${{SHORT}}" +""" def add_rapp_parser(sub: argparse._SubParsersAction) -> None: @@ -272,10 +896,37 @@ def add_rapp_parser(sub: argparse._SubParsersAction) -> None: p_init.add_argument("--purpose", default=None) p_init.add_argument("--force", action="store_true") + p_skel = rapp_sub.add_parser("skeleton", help="Generate or absorb runtime manifests") + p_skel.add_argument("--path", required=True) + p_skel.add_argument("--from-app", required=True) + p_skel.add_argument("--app", default=None) + p_skel.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES) + p_skel.add_argument("--force", action="store_true") + p_skel.add_argument("--dedicated-postgres", action="store_true") + + p_wrap = rapp_sub.add_parser("wrap", help="init + skeleton + image + consumer + validate") + p_wrap.add_argument("--path", required=True) + p_wrap.add_argument("--app", required=True) + p_wrap.add_argument("--ownership-repo", required=True) + p_wrap.add_argument("--from-app", required=True) + p_wrap.add_argument("--rail", default="rail-kubernetes", choices=RAILS) + p_wrap.add_argument("--classification", default="confidential", choices=CLASSIFICATIONS) + p_wrap.add_argument("--criticality", default="high", choices=CRITICALITIES) + p_wrap.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES) + p_wrap.add_argument("--purpose", default=None) + p_wrap.add_argument("--family-root", default=None) + p_wrap.add_argument("--force", action="store_true") + p_wrap.add_argument("--dedicated-postgres", action="store_true") + + p_place = rapp_sub.add_parser("place", help="Set bound_reefs only") + p_place.add_argument("--path", required=True) + p_place.add_argument("--reef", default="reef-railiance") + p_place.add_argument("--family-root", default=None) + 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 = rapp_sub.add_parser("pin-image", help="Rewrite digest pins") 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 index 8bc1a3f..084d990 100644 --- a/tests/test_rapp_scaffold.py +++ b/tests/test_rapp_scaffold.py @@ -1,7 +1,7 @@ from pathlib import Path from repo_manager.cli import main -from repo_manager.commands.rapp import init, pin_image, validate +from repo_manager.commands.rapp import init, pin_image, place, skeleton, validate, wrap def test_init_refuses_rapp_prefixed_app(tmp_path: Path): @@ -57,3 +57,56 @@ def test_cli_init_and_pin(tmp_path: Path, capsys): assert main(["rapp", "pin-image", "--path", str(dest), "--digest", "latest"]) == 1 out = capsys.readouterr().out assert "sha256" in out or "digest" in out + + +def test_skeleton_from_containerfile(tmp_path: Path): + app = tmp_path / "demo" + app.mkdir() + (app / "Containerfile").write_text( + "FROM python:3.12-slim\nUSER 10001\nEXPOSE 8090\n" + ) + dest = tmp_path / "rapp-demo" + init(dest, app="demo", ownership_repo="demo") + built = skeleton(dest, from_app=app, app="demo") + assert built["ok"] is True + runtime = (dest / "manifests" / "runtime.yaml").read_text() + assert "containerPort: 8090" in runtime + assert "runAsUser: 10001" in runtime + refused = skeleton(dest, from_app=app, app="demo") + assert refused["ok"] is False + + +def test_place_refuses_storage_and_sets_compute(tmp_path: Path): + dest = tmp_path / "rapp-demo" + init(dest, app="demo", ownership_repo="demo") + assert place(dest, reef="reef-storage")["ok"] is False + assert place(dest, reef="not-a-reef")["ok"] is False + placed = place(dest, reef="reef-railiance") + assert placed["ok"] is True + assert "- reef-railiance" in (dest / "declarations" / "rapp.yaml").read_text() + + +def test_wrap_absorbs_deploy_and_skips_existing_consumer(tmp_path: Path): + app = tmp_path / "demo" + (app / "deploy").mkdir(parents=True) + (app / "Containerfile").write_text("USER 10001\nEXPOSE 8080\n") + (app / "deploy" / "demo.yaml").write_text( + "apiVersion: apps/v1\nkind: Deployment\n" + "metadata: {name: demo}\n" + ) + postgres = tmp_path / "rapp-postgres" / "consumers" + postgres.mkdir(parents=True) + (postgres / "demo.yaml").write_text("kind: PostgresConsumer\n") + dest = tmp_path / "rapp-demo" + result = wrap( + dest, + app="demo", + ownership_repo="demo", + from_app=app, + family_root=tmp_path, + ) + assert result["ok"] is True or "validate" in result + assert (dest / "manifests" / "demo.yaml").is_file() + assert result["postgres_consumer"]["written"] is False + assert result["applied"] is False + assert result["placed"] is False diff --git a/workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md b/workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md index d472c1e..666b5dc 100644 --- a/workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md +++ b/workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md @@ -4,7 +4,7 @@ type: workplan title: "Greenfield rapp wrap efficiency" domain: infotech repo: repo-manager -status: proposed +status: finished owner: grok topic_slug: infotech created: "2026-08-18" @@ -52,7 +52,7 @@ after the user-engine rollout. ```task id: RMGR-WP-0007-T01 -status: todo +status: done priority: high state_hub_task_id: "dcf0b76c-05e8-44b4-b31a-33f0bc7273ce" ``` @@ -73,11 +73,15 @@ start in the user-engine absorb story. Do not rewrite the family schemas here. +**Result (2026-08-18):** §0 playbook and §3.1 rail sequence added. +Inventory lists `rapp-user-engine` as verified. §12 names the live +commands. + ## Generate a runtime skeleton from the app repo ```task id: RMGR-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "2c2c8306-b5b0-467a-a1a5-20c0f979e13d" ``` @@ -99,11 +103,15 @@ Refuse Helm unless `--package-type helm-managed-platform-service` or a chart already exists. Idempotent: do not overwrite reviewed manifests without `--force`. +**Result (2026-08-18):** `rmgr rapp skeleton --from-app` inspects +Containerfile/deploy, absorbs `deploy/*.yaml` when present, otherwise +emits a hardened Deployment set. + ## Install the fleet image-publish workflow on the app ```task id: RMGR-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "ec2d8fbb-2945-4144-b75e-beddf54a8b87" ``` @@ -113,11 +121,14 @@ As part of wrap/skeleton, copy the fleet Do not invent registry credentials. Do not build on the workstation. Document that the first deploy waits on the CI digest. +**Result (2026-08-18):** `ensure_image_workflow` copies the fleet +workflow when missing and leaves an existing file alone. + ## Draft the postgres consumer, do not apply it ```task id: RMGR-WP-0007-T04 -status: todo +status: done priority: medium state_hub_task_id: "0fe3018a-b245-43b9-a0e2-e0ecb7f6d206" ``` @@ -131,11 +142,14 @@ passed. This task does not apply the consumer in `rapp-postgres` and does not request a CCR. +**Result (2026-08-18):** drafts `handoffs/postgres-consumer.yaml` or +points at an existing `rapp-postgres/consumers/.yaml`. + ## Compose `rmgr rapp wrap` ```task id: RMGR-WP-0007-T05 -status: todo +status: done priority: high state_hub_task_id: "001e7b30-2a86-41f2-b731-415125e3b3e6" ``` @@ -148,11 +162,14 @@ exposure grant. Refuse unknown rails, `rapp-` workload names, and overwrite of a live declaration (same rules as `init`). +**Result (2026-08-18):** `rmgr rapp wrap` composes the steps and reports +`placed: false`, `applied: false`. + ## Explicit place command ```task id: RMGR-WP-0007-T06 -status: todo +status: done priority: medium state_hub_task_id: "076a64c5-cd83-4f7c-ba51-f3c61e5464e8" ``` @@ -161,11 +178,14 @@ Add `rmgr rapp place --path --reef reef-railiance` that sets `bound_reefs` only. Refuse unknown reefs. Do not set `exposure.posture: public`. +**Result (2026-08-18):** `rmgr rapp place` sets `bound_reefs` and +refuses `reef-storage`. + ## Pilot on tenant-engine, files only ```task id: RMGR-WP-0007-T07 -status: todo +status: done priority: high state_hub_task_id: "e3845311-b560-4fc2-ad76-5939145350a7" ``` @@ -181,6 +201,10 @@ absorbed manifests, `rmgr rapp validate` passes, postgres consumer `rapp-secrets-engine` is out of scope (likely not a long-running workload). `rapp-vergabe-teilnahme` stays a `railiance-apps` residual. +**Result (2026-08-18):** wrap absorbed `tenant-engine/deploy/`, left the +live postgres consumer in place, skipped existing `image.yaml`, +validated `4 declaration(s) ok`. No cluster apply. + ## Residuals - Applying a drafted postgres consumer and CCR remains with