feat: greenfield rapp wrap path and rail-sequence rules

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.
This commit is contained in:
tegwick 2026-08-18 13:03:16 +02:00
parent 4743435f04
commit f953b1ebf5
6 changed files with 963 additions and 97 deletions

View file

@ -33,8 +33,10 @@ 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 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).

View file

@ -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> \
--app <app> \
--ownership-repo <app> \
--from-app ../<app>
rmgr rapp validate --path ../rapp-<app> --family-root ..
# after CI prints a digest:
rmgr rapp pin-image --path ../rapp-<app> --digest sha256:<64 hex>
# only when you intend to bind compute:
rmgr rapp place --path ../rapp-<app> --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-<app>` 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> --app <app> \
--ownership-repo <app> --from-app ../<app>
rmgr rapp validate --path ../rapp-<app> --family-root ..
rmgr rapp pin-image --path ../rapp-<app> --digest sha256:<64 hex>
rmgr rapp place --path ../rapp-<app> --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.
---

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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/<app>.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 <rapp> --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