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:
parent
4743435f04
commit
f953b1ebf5
6 changed files with 963 additions and 97 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue