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 observe --path .
rmgr reconcile --path . rmgr reconcile --path .
rmgr update-task-status --path . --task-id <ID> --status progress 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 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). 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/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) - [`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: This guide answers:
1. What a managed Railiance application **is**. 1. How to wrap a new app from scratch (§0).
2. How rails, reefs, and existing rapps already do it. 2. Which rail to start on, and when Knative is justified (§3.1).
3. Which files, declarations, and operator surfaces a new wrapper needs. 3. What a managed Railiance application **is**.
4. How `user-engine` should be wrapped as `rapp-user-engine`. 4. Which files, declarations, and operator surfaces a wrapper needs.
5. What Repo Manager must later automate, in order. 5. How `user-engine` was absorbed as `rapp-user-engine` (§10).
It is the first deliverable of `RMGR-WP-0006`. Implementation of the `RMGR-WP-0006` recorded the contract and the first wrap.
scaffolder comes after this shape is accepted. `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-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. | | `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 This is a **default plus a specialization gate**, not a maturity ladder.
Knative lifecycle semantics. Do not invent a new rail for an adapter or An app does not graduate from Kubernetes to Knative.
values profile.
```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 `rail-kubernetes` also owns the generic staged-promotion contract
`railiance/app.toml` (Stage 1 local, Stage 2 canary, Stage 3 promote) and `railiance/app.toml` (Stage 1 local, Stage 2 canary, Stage 3 promote) and
the compatibility overlay-repo pattern. That overlay is the migration-era the compatibility overlay-repo pattern. That overlay is the
wrapper. Durable first-class packaging belongs in `rapp-*`. migration-era wrapper. `app.toml` is **not** required on the first wrap.
### 3.2 Reefs ### 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-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-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-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-secrets-engine` | **no** | — | — | — | stub | README only. Same family as user-engine. |
| `rapp-tenant-engine` | **no** | — | — | — | stub | README only. Same family. | | `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-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`. | | `rapp-forgejo` | not built | — | — | — | — | Deferred; forge still mixed into `railiance-forge` / `railiance-apps`. |
The platform-service wrapper is proven twice. The first-party application The platform-service wrapper is proven twice. The first-party application
wrapper is only half-proven (`rapp-policy-nexus` declared; `rapp-qonto` wrapper is proven once as an absorb (`rapp-user-engine`) and once as a
drifted; `rapp-vergabe-teilnahme` never extracted). `rapp-user-engine` is static Helm site (`rapp-policy-nexus`). `rapp-qonto` is drifted.
the next application-shaped proof, not a substitute for the missing `rapp-vergabe-teilnahme` was never extracted. `rapp-tenant-engine` is
Vergabe extraction. the greenfield/absorb pilot for `RMGR-WP-0007`.
### 3.4 Ownership repos that still hold wrappers ### 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. product manifesto.
- [ ] Author `declarations/rapp.yaml` to the schema. Start - [ ] Author `declarations/rapp.yaml` to the schema. Start
`readiness_state: draft`. `readiness_state: draft`.
- [ ] Add `railiance/app.toml` if the primary rail is `rail-kubernetes`. - [ ] Add `railiance/app.toml` only if you need Stage 1/2/3 rail
- [ ] Add the Helm chart or manifest set. Pin images by digest. 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. - [ ] Add Makefile targets from §8.1.
- [ ] Name smoke **outcomes** and rollback **order**. - [ ] Name smoke **outcomes** and rollback **order**.
- [ ] Leave `exposure` unset. - [ ] 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 Implemented under `RMGR-WP-0006` (P1, P4) and `RMGR-WP-0007` (P2, P3, P5,
command or command flag on the existing governed-mutation path compose).
(`RMGR-WP-0004` scaffolding, specialized for the rapp family).
| Phase | Repo Manager does | Still human | | Phase | Command | Still human |
| --- | --- | --- | | --- | --- | --- |
| **P0 — this guide** | Record the shape. | Accept or amend the shape. | | **P0 — this guide** | — | 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. | | **P1 — bootstrap** | `rmgr rapp init` | Purpose sentence, classification. |
| **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. | | **P2 — package skeleton** | `rmgr rapp skeleton --from-app` | NetworkPolicy and resource review. |
| **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. | | **P3 — platform drafts** | consumer draft + app `image.yaml` | CCR / consumer **apply**. |
| **P4 — validate** | Run family-declaration validation and `helm lint` / render. Refuse `readiness_state` promotions that skip evidence. | Promote `draft``declared`. | | **P4 — validate** | `rmgr rapp validate` | Promote `draft``declared`. |
| **P5 — place** | Set `bound_reefs: [reef-railiance]` only on an explicit place command. | Production gate and any exposure grant. | | **P5 — place** | `rmgr rapp place --reef reef-railiance` | Production gate, exposure grant. |
Command sketch (not implemented):
```text ```text
rmgr rapp init --app user-engine --ownership-repo user-engine \ rmgr rapp wrap --path ../rapp-<app> --app <app> \
--rail rail-kubernetes --classification confidential --criticality high --ownership-repo <app> --from-app ../<app>
rmgr rapp validate --path ../rapp-<app> --family-root ..
rmgr rapp skeleton --path ../rapp-user-engine --from-app ../user-engine rmgr rapp pin-image --path ../rapp-<app> --digest sha256:<64 hex>
rmgr rapp place --path ../rapp-<app> --reef reef-railiance
rmgr rapp validate --path ../rapp-user-engine --family-root ..
``` ```
Constraints on the scaffolder: Constraints:
- Files stay authoritative. The hub is not written to except via - Files stay authoritative. The hub is only updated via `fix-consistency`.
`fix-consistency` after the files exist.
- No secret values, ever. - No secret values, ever.
- Do not invent package types, rails, or reefs. - Do not invent package types, rails, or reefs.
- Idempotent: re-running `init` on an already-declared rapp must refuse - `init` / `wrap` refuse to overwrite a live declaration.
or update in a reviewed diff, not overwrite a live contract. - `tenant-engine` is the `RMGR-WP-0007` pilot. `rapp-secrets-engine` is
- The three engine stubs (`user-engine`, `secrets-engine`, not a wrap target.
`tenant-engine`) are the first cohort; `user-engine` is the pilot.
--- ---

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 add_rapp_parser
from repo_manager.commands.rapp import init as rapp_init 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 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 validate as rapp_validate
from repo_manager.commands.rapp import wrap as rapp_wrap
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@ -147,6 +150,36 @@ def main(argv: list[str] | None = None) -> int:
Path(args.path), Path(args.path),
family_root=Path(args.family_root) if args.family_root else None, 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": elif args.rapp_command == "pin-image":
result = rapp_pin_image(Path(args.path), args.digest) result = rapp_pin_image(Path(args.path), args.digest)
else: else:

View file

@ -3,8 +3,8 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import re import re
import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -18,8 +18,17 @@ PACKAGE_TYPES = (
RAILS = ("rail-kubernetes", "rail-knative") RAILS = ("rail-kubernetes", "rail-knative")
CLASSIFICATIONS = ("public", "internal", "confidential", "restricted") CLASSIFICATIONS = ("public", "internal", "confidential", "restricted")
CRITICALITIES = ("low", "medium", "high", "critical") CRITICALITIES = ("low", "medium", "high", "critical")
COMPUTE_REEFS = ("reef-railiance",)
SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") 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}$") 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" _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") path.write_text(content if content.endswith("\n") else content + "\n")
def _underscore(app: str) -> str:
return app.replace("-", "_")
def init( def init(
path: Path, path: Path,
*, *,
@ -45,18 +58,9 @@ def init(
purpose: str | None = None, purpose: str | None = None,
force: bool = False, force: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not SLUG.match(app) or app.startswith("rapp-"): check = _check_identity(app, ownership_repo, rail, package_type, classification, criticality)
return _refuse("app must be a workload slug without the rapp- prefix") if check:
if rail not in RAILS: return check
return _refuse(f"unknown rail {rail!r}; allowed: {', '.join(RAILS)}")
if package_type not in PACKAGE_TYPES:
return _refuse(f"unknown package_type {package_type!r}")
if classification not in CLASSIFICATIONS:
return _refuse(f"unknown classification {classification!r}")
if criticality not in CRITICALITIES:
return _refuse(f"unknown criticality {criticality!r}")
if not SLUG.match(ownership_repo) or ownership_repo.startswith("rapp-"):
return _refuse("ownership_repo must be an app or layer slug, not the rapp itself")
rapp_id = f"rapp-{app}" rapp_id = f"rapp-{app}"
dest = path.expanduser().resolve() dest = path.expanduser().resolve()
@ -111,12 +115,38 @@ def init(
} }
written = [] written = []
for rel, content in files.items(): 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) written.append(rel)
(dest / "workplans" / "archived").mkdir(exist_ok=True) (dest / "workplans" / "archived").mkdir(exist_ok=True)
return {"ok": True, "path": str(dest), "rapp_id": rapp_id, "written": written} 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( def _declaration(
*, *,
rapp_id: str, rapp_id: str,
@ -147,6 +177,7 @@ def _declaration(
"bound_reefs: []\n" "bound_reefs: []\n"
"runtime_dependencies:\n" "runtime_dependencies:\n"
" - kubernetes-api\n" " - kubernetes-api\n"
" - openbao-database-secrets-engine\n"
"composition:\n" "composition:\n"
f" purpose: {purpose}\n" f" purpose: {purpose}\n"
" member_repos:\n" " member_repos:\n"
@ -161,10 +192,272 @@ def _declaration(
" - healthz-ok\n" " - healthz-ok\n"
"rollback_contract:\n" "rollback_contract:\n"
" order:\n" " order:\n"
" - previous-immutable-image-digest\n"
" - apply-reviewed-git-revision\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]: def validate(path: Path, *, family_root: Path | None = None) -> dict[str, Any]:
dest = path.expanduser().resolve() dest = path.expanduser().resolve()
declaration = dest / "declarations" / "rapp.yaml" declaration = dest / "declarations" / "rapp.yaml"
@ -228,33 +521,364 @@ def pin_image(path: Path, digest: str) -> dict[str, Any]:
if not DIGEST.fullmatch(digest): if not DIGEST.fullmatch(digest):
return _refuse("digest must be sha256:<64 lowercase hex>") return _refuse("digest must be sha256:<64 lowercase hex>")
dest = path.expanduser().resolve() dest = path.expanduser().resolve()
runtime = dest / "manifests" / "runtime.yaml" rewritten = 0
if not runtime.is_file(): for rel in ("manifests", "bindings", "declarations"):
return _refuse(f"missing {runtime}") root = dest / rel
text = runtime.read_text() if not root.exists():
updated, n = re.subn( continue
r"(forgejo\.coulomb\.social/coulomb/user-engine@)sha256:[0-9a-f]{64}", files = [root] if root.is_file() else list(root.rglob("*"))
rf"\g<1>{digest}", for file in files:
text, 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)
) )
if n == 0: return {"ok": True, "path": str(dest), "digest": digest, "rewritten": rewritten}
return _refuse("no user-engine digest pin found in manifests/runtime.yaml")
runtime.write_text(updated)
binding = dest / "bindings" / "reef-railiance.yaml" def _runtime_manifest(app: str, facts: dict[str, Any]) -> str:
if binding.is_file(): port = facts["port"]
binding.write_text( uid = facts["uid"]
re.sub(r"sha256:[0-9a-f]{64}", digest, binding.read_text(), count=1) health = facts["health_path"]
) ready = facts["ready_path"]
declaration = dest / "declarations" / "rapp.yaml" image = f"{facts['image_repository']}@{facts['image_digest']}"
if declaration.is_file(): return f"""apiVersion: v1
declaration.write_text( kind: Namespace
re.sub( metadata:
r"(source: forgejo\.coulomb\.social/coulomb/user-engine\n version: )sha256:[0-9a-f]{64}", name: {app}
rf"\g<1>{digest}", ---
declaration.read_text(), apiVersion: apps/v1
) kind: Deployment
) metadata:
return {"ok": True, "path": str(dest), "digest": digest, "rewritten": n} 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: 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("--purpose", default=None)
p_init.add_argument("--force", action="store_true") 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 = rapp_sub.add_parser("validate", help="Validate a rapp-* checkout")
p_val.add_argument("--path", required=True) p_val.add_argument("--path", required=True)
p_val.add_argument("--family-root", default=None) 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("--path", required=True)
p_pin.add_argument("--digest", required=True) p_pin.add_argument("--digest", required=True)

View file

@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from repo_manager.cli import main 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): 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 assert main(["rapp", "pin-image", "--path", str(dest), "--digest", "latest"]) == 1
out = capsys.readouterr().out out = capsys.readouterr().out
assert "sha256" in out or "digest" in 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" title: "Greenfield rapp wrap efficiency"
domain: infotech domain: infotech
repo: repo-manager repo: repo-manager
status: proposed status: finished
owner: grok owner: grok
topic_slug: infotech topic_slug: infotech
created: "2026-08-18" created: "2026-08-18"
@ -52,7 +52,7 @@ after the user-engine rollout.
```task ```task
id: RMGR-WP-0007-T01 id: RMGR-WP-0007-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "dcf0b76c-05e8-44b4-b31a-33f0bc7273ce" 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. 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 ## Generate a runtime skeleton from the app repo
```task ```task
id: RMGR-WP-0007-T02 id: RMGR-WP-0007-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "2c2c8306-b5b0-467a-a1a5-20c0f979e13d" 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 chart already exists. Idempotent: do not overwrite reviewed manifests
without `--force`. 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 ## Install the fleet image-publish workflow on the app
```task ```task
id: RMGR-WP-0007-T03 id: RMGR-WP-0007-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "ec2d8fbb-2945-4144-b75e-beddf54a8b87" 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. Do not invent registry credentials. Do not build on the workstation.
Document that the first deploy waits on the CI digest. 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 ## Draft the postgres consumer, do not apply it
```task ```task
id: RMGR-WP-0007-T04 id: RMGR-WP-0007-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "0fe3018a-b245-43b9-a0e2-e0ecb7f6d206" 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 This task does not apply the consumer in `rapp-postgres` and does not
request a CCR. 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` ## Compose `rmgr rapp wrap`
```task ```task
id: RMGR-WP-0007-T05 id: RMGR-WP-0007-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "001e7b30-2a86-41f2-b731-415125e3b3e6" 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 Refuse unknown rails, `rapp-` workload names, and overwrite of a live
declaration (same rules as `init`). declaration (same rules as `init`).
**Result (2026-08-18):** `rmgr rapp wrap` composes the steps and reports
`placed: false`, `applied: false`.
## Explicit place command ## Explicit place command
```task ```task
id: RMGR-WP-0007-T06 id: RMGR-WP-0007-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "076a64c5-cd83-4f7c-ba51-f3c61e5464e8" 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 `bound_reefs` only. Refuse unknown reefs. Do not set
`exposure.posture: public`. `exposure.posture: public`.
**Result (2026-08-18):** `rmgr rapp place` sets `bound_reefs` and
refuses `reef-storage`.
## Pilot on tenant-engine, files only ## Pilot on tenant-engine, files only
```task ```task
id: RMGR-WP-0007-T07 id: RMGR-WP-0007-T07
status: todo status: done
priority: high priority: high
state_hub_task_id: "e3845311-b560-4fc2-ad76-5939145350a7" 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 `rapp-secrets-engine` is out of scope (likely not a long-running
workload). `rapp-vergabe-teilnahme` stays a `railiance-apps` residual. 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 ## Residuals
- Applying a drafted postgres consumer and CCR remains with - Applying a drafted postgres consumer and CCR remains with