Import rail-kubernetes lifecycle tooling

This commit is contained in:
codex 2026-07-26 02:10:05 +02:00
parent 303be3928e
commit f31064a3ba
11 changed files with 2010 additions and 12 deletions

View file

@ -41,7 +41,9 @@ The boundary and source material come from:
## Initial Layout
- `declarations/rail.yaml` — source-controlled rail contract
- `bin/` — thin generic rail lifecycle entrypoints
- `docs/` — wave-1 contract and import plan
- `tools/` — rail-owned helper implementations and scaffolds
- `schemas/` — machine-readable workload contract schema
- `examples/` — canonical example workload declarations for the rail
- `workplans/` — repo-local execution work

View file

@ -10,6 +10,6 @@
| --- | --- | --- | --- | --- |
| workplan | RAIL-K8S-WP-0001 | active | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |
| task | RAIL-K8S-WP-0001-T01 | done | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |
| task | RAIL-K8S-WP-0001-T02 | progress | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |
| task | RAIL-K8S-WP-0001-T02 | done | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |
| task | RAIL-K8S-WP-0001-T03 | wait | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |
| task | RAIL-K8S-WP-0001-T04 | progress | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |
| task | RAIL-K8S-WP-0001-T04 | done | — | workplans/RAIL-K8S-WP-0001-bootstrap-and-wave1-import.md |

35
bin/railiance Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# bin/railiance — generic rail dispatcher; implementation lives in tools/cmd/*
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PATH="${ROOT}/tools/cmd:${PATH}"
usage() {
cat <<'EOF'
Usage: bin/railiance <command> [args]
Commands:
create-overlay Scaffold a Railiance overlay repo for an upstream app
run Run Stage 1 local validation from railiance/app.toml
deploy Plan/apply Stage 2 canary deployment
observe Plan/run Stage 2 observation checks
promote Plan/apply Stage 3 stable promotion
rollback Plan/apply rollback to previous stable
help Show this help
EOF
}
cmd="${1:-help}"
shift || true
case "$cmd" in
help) usage ;;
create-overlay) bash "$ROOT/tools/create_railiance_overlay_repo.sh" "$@" ;;
run) exec railiance-run "$@" ;;
deploy) exec railiance-stage2 deploy "$@" ;;
observe) exec railiance-stage2 observe "$@" ;;
promote) exec railiance-stage3 promote "$@" ;;
rollback) exec railiance-stage3 rollback "$@" ;;
*) echo "Unknown command: $cmd" >&2; usage; exit 2 ;;
esac

View file

@ -7,5 +7,6 @@
- `stage2-deploy-observe.md` — stage 2 command behavior contract
- `promote-rollback-onboarding.md` — representative lifecycle command path
- `railiance-run-command.md` — stage 1 command behavior contract
- `create-overlay-command.md` — overlay scaffolder behavior contract
- `wave-1-contract.md` — current rail contract and boundary summary
- `source-import-plan.md` — source material and migration direction from existing repos

View file

@ -0,0 +1,58 @@
# Create Overlay Command
`bin/railiance create-overlay` scaffolds a local Railiance overlay repo for a
third-party upstream application.
The command is intentionally local and conservative:
- records the upstream source in `railiance/upstream.toml`;
- generates a stage-aware `railiance/app.toml`;
- creates a starter Helm chart, stage values, tests, and runbooks;
- initializes only local files and directories;
- does not clone upstream code, create remotes, fetch secrets, or push
anything.
## Usage
```bash
bin/railiance create-overlay \
--app-id forgejo \
--upstream-url https://codeberg.org/forgejo/forgejo \
--name Forgejo \
--owner platform \
--criticality high \
--init-git
```
Required arguments:
- `--app-id`
- `--upstream-url`
Useful optional arguments:
- `--name`
- `--owner`
- `--criticality`
- `--upstream-revision`
- `--upstream-tracking`
- `--out-dir`
- `--init-git`
## Generated Structure
The scaffold creates:
- `README.md`
- `railiance/upstream.toml`
- `railiance/app.toml`
- `charts/<app-id>/templates/`
- `values/`
- `patches/upstream/`
- `tests/`
- `runbooks/`
- `docs/`
The output is a compatibility-era overlay starting point on the path toward
future `rapp-*` packaging. It keeps the current migration window usable without
moving workload ownership into `rail-kubernetes`.

View file

@ -20,16 +20,16 @@ Imported source assets from `railiance-cluster`:
- `schemas/railiance-app.schema.json`
- `examples/railiance/app.toml`
## Pending First-Wave Imports
Expected source assets from `railiance-cluster`:
- `bin/railiance` generic lifecycle dispatcher surface
- `tools/create_railiance_overlay_repo.sh`
- `tools/cmd/railiance-run`
- `tools/cmd/railiance-stage2`
- `tools/cmd/railiance-stage3`
## Pending First-Wave Imports
No additional first-wave imports remain pending for this repo-local bootstrap.
## Deferred Migration Debt
Do not import these into `rail-kubernetes` as part of wave 1:

301
tools/cmd/railiance-run Executable file
View file

@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Railiance Stage 1 local validation command."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import time
import tomllib
import urllib.error
import urllib.request
import urllib.parse
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SUPPORTED_SCHEMA = "railiance.app.v1"
def utc_now() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def load_contract(app_dir: Path) -> tuple[Path, dict[str, Any]]:
path = app_dir / "railiance" / "app.toml"
if not path.exists():
raise SystemExit(f"Missing Railiance contract: {path}")
with path.open("rb") as handle:
data = tomllib.load(handle)
if data.get("schema_version") != SUPPORTED_SCHEMA:
raise SystemExit(
f"Unsupported schema_version {data.get('schema_version')!r}; expected {SUPPORTED_SCHEMA}"
)
return path, data
def command_result(
command: str, cwd: Path, timeout_seconds: int | None, command_ref: str
) -> dict[str, Any]:
started = time.monotonic()
timeout = timeout_seconds or 900
try:
completed = subprocess.run(
command,
cwd=cwd,
shell=True,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
status = "passed" if completed.returncode == 0 else "failed"
return {
"command_ref": command_ref,
"status": status,
"exit_code": completed.returncode,
"duration_seconds": round(time.monotonic() - started, 3),
"stdout_bytes": len(completed.stdout.encode()),
"stderr_bytes": len(completed.stderr.encode()),
}
except subprocess.TimeoutExpired as exc:
return {
"command_ref": command_ref,
"status": "failed",
"exit_code": None,
"duration_seconds": round(time.monotonic() - started, 3),
"error": f"timeout after {timeout}s",
"stdout_bytes": len((exc.stdout or "").encode()) if isinstance(exc.stdout, str) else 0,
"stderr_bytes": len((exc.stderr or "").encode()) if isinstance(exc.stderr, str) else 0,
}
def check_required(check: dict[str, Any]) -> bool:
return bool(check.get("required", True))
def skipped(check: dict[str, Any], reason: str) -> dict[str, Any]:
required = check_required(check)
return {
"id": check.get("id"),
"type": check.get("type"),
"required": required,
"status": "failed" if required else "skipped",
"reason": reason,
}
def scrub_url(url: str) -> str:
try:
parts = urllib.parse.urlsplit(url)
except ValueError:
return "<invalid-url>"
netloc = parts.netloc.rsplit("@", 1)[-1]
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, "", ""))
def run_http_check(check: dict[str, Any]) -> dict[str, Any]:
started = time.monotonic()
url = str(check.get("url", ""))
timeout = int(check.get("timeout_seconds", 10))
expected_status = int(check.get("expected_status", 200))
required = check_required(check)
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
status_code = response.getcode()
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
return {
"id": check.get("id"),
"type": "http",
"required": required,
"status": "failed" if required else "skipped",
"url": scrub_url(url),
"duration_seconds": round(time.monotonic() - started, 3),
"reason": str(exc),
}
status = "passed" if status_code == expected_status else "failed"
return {
"id": check.get("id"),
"type": "http",
"required": required,
"status": status if required or status == "passed" else "skipped",
"url": scrub_url(url),
"expected_status": expected_status,
"actual_status": status_code,
"duration_seconds": round(time.monotonic() - started, 3),
}
def run_helm_check(check: dict[str, Any], app_dir: Path, release: str) -> dict[str, Any]:
if shutil.which("helm") is None:
return skipped(check, "helm is not installed")
chart = str(check.get("chart", ""))
values = str(check.get("values", ""))
mode = str(check.get("mode", "template"))
if mode not in {"template", "server-dry-run"}:
return skipped(check, f"unsupported helm mode for Stage 1: {mode}")
command = f"helm template {release} {chart}"
if values:
command += f" -f {values}"
result = command_result(
command, app_dir, int(check.get("timeout_seconds", 120)), f"checks.{check.get('id')}.helm"
)
return {
"id": check.get("id"),
"type": "helm",
"required": check_required(check),
"status": result["status"],
"mode": mode,
"command_ref": result.get("command_ref"),
"exit_code": result.get("exit_code"),
"duration_seconds": result.get("duration_seconds"),
"stdout_bytes": result.get("stdout_bytes"),
"stderr_bytes": result.get("stderr_bytes"),
}
def run_check(check: dict[str, Any], app_dir: Path, release: str) -> dict[str, Any]:
check_type = check.get("type")
if check.get("stage") != "stage1":
return skipped(check, "not a Stage 1 check")
if check_type == "command":
command = str(check.get("run", ""))
if not command:
return skipped(check, "command check has no run field")
result = command_result(
command, app_dir, int(check.get("timeout_seconds", 900)), f"checks.{check.get('id')}.command"
)
return {
"id": check.get("id"),
"type": "command",
"required": check_required(check),
**result,
}
if check_type == "http":
return run_http_check(check)
if check_type == "helm":
return run_helm_check(check, app_dir, release)
if check_type == "manual":
return skipped(check, "manual check cannot be satisfied by railiance run")
return skipped(check, f"unsupported local check type: {check_type}")
def required_failures(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in items if item.get("required", True) and item.get("status") != "passed"]
def build_result(app_dir: Path, contract_path: Path, data: dict[str, Any]) -> dict[str, Any]:
stage = data.get("stages", {}).get("stage1", {})
if not stage.get("enabled", False):
raise SystemExit("Stage 1 is disabled in railiance/app.toml")
app = data.get("app", {})
source = data.get("source", {})
started_at = utc_now()
started_monotonic = time.monotonic()
stage_commands = list(stage.get("commands", []))
command_results = [
command_result(command, app_dir, None, f"stages.stage1.commands[{index}]")
for index, command in enumerate(stage_commands)
]
check_ids = list(stage.get("checks", []))
all_checks = {check.get("id"): check for check in data.get("checks", [])}
check_results = []
for check_id in check_ids:
check = all_checks.get(check_id)
if check is None:
check_results.append(
{
"id": check_id,
"type": None,
"required": True,
"status": "failed",
"reason": "check id is referenced by Stage 1 but not defined",
}
)
continue
check_results.append(run_check(check, app_dir, str(stage.get("release", app.get("id", "app")))))
command_failures = [item for item in command_results if item.get("status") != "passed"]
check_failures = required_failures(check_results)
status = "passed" if not command_failures and not check_failures else "failed"
return {
"schema_version": "railiance.run-result.v1",
"status": status,
"stage": "stage1",
"started_at": started_at,
"finished_at": utc_now(),
"duration_seconds": round(time.monotonic() - started_monotonic, 3),
"app": {
"id": app.get("id"),
"name": app.get("name"),
"repo": app.get("repo"),
"owner": app.get("owner"),
"criticality": app.get("criticality"),
},
"source": {
"revision": source.get("revision"),
"artifact": source.get("artifact"),
"digest_policy": source.get("digest_policy"),
},
"contract": str(contract_path),
"app_dir": str(app_dir),
"release": stage.get("release"),
"namespace": stage.get("namespace"),
"requires_approval": bool(stage.get("requires_approval", False)),
"evidence_expected": list(stage.get("evidence", [])),
"commands": command_results,
"checks": check_results,
"summary": {
"commands_total": len(command_results),
"commands_failed": len(command_failures),
"checks_total": len(check_results),
"required_checks_failed": len(check_failures),
},
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run Railiance Stage 1 local validation from railiance/app.toml."
)
parser.add_argument(
"app_dir",
nargs="?",
default=".",
help="Application or overlay repository directory (default: current directory).",
)
parser.add_argument(
"--json-out",
help="Optional path to write the machine-readable run result.",
)
parser.add_argument(
"--pretty",
action="store_true",
help="Pretty-print JSON output to stdout.",
)
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
app_dir = Path(args.app_dir).resolve()
contract_path, data = load_contract(app_dir)
result = build_result(app_dir, contract_path, data)
rendered = json.dumps(result, indent=2 if args.pretty else None, sort_keys=True)
print(rendered)
if args.json_out:
output_path = Path(args.json_out)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered + "\n", encoding="utf-8")
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

439
tools/cmd/railiance-stage2 Executable file
View file

@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""Railiance Stage 2 deploy and observe tooling."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import time
import tomllib
import urllib.parse
import urllib.request
import urllib.error
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SUPPORTED_SCHEMA = "railiance.app.v1"
def utc_now() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def scrub_url(url: str) -> str:
try:
parts = urllib.parse.urlsplit(url)
except ValueError:
return "<invalid-url>"
netloc = parts.netloc.rsplit("@", 1)[-1]
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, "", ""))
def load_contract(app_dir: Path) -> tuple[Path, dict[str, Any]]:
contract_path = app_dir / "railiance" / "app.toml"
if not contract_path.exists():
raise SystemExit(f"Missing Railiance contract: {contract_path}")
with contract_path.open("rb") as handle:
data = tomllib.load(handle)
if data.get("schema_version") != SUPPORTED_SCHEMA:
raise SystemExit(
f"Unsupported schema_version {data.get('schema_version')!r}; expected {SUPPORTED_SCHEMA}"
)
return contract_path, data
def check_required(check: dict[str, Any]) -> bool:
return bool(check.get("required", True))
def checks_by_id(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {check.get("id"): check for check in data.get("checks", [])}
def stage2_checks(data: dict[str, Any]) -> list[dict[str, Any]]:
stage = data.get("stages", {}).get("stage2", {})
lookup = checks_by_id(data)
return [lookup[item] for item in stage.get("checks", []) if item in lookup]
def helm_check(data: dict[str, Any]) -> dict[str, Any] | None:
for check in stage2_checks(data):
if check.get("type") == "helm":
return check
return None
def kubernetes_check(data: dict[str, Any]) -> dict[str, Any] | None:
for check in stage2_checks(data):
if check.get("type") == "kubernetes":
return check
return None
def http_checks(data: dict[str, Any]) -> list[dict[str, Any]]:
return [check for check in stage2_checks(data) if check.get("type") == "http"]
def precheck(name: str, status: str, required: bool, detail: str | None = None) -> dict[str, Any]:
item: dict[str, Any] = {"name": name, "status": status, "required": required}
if detail:
item["detail"] = detail
return item
def required_failures(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in items if item.get("required", True) and item.get("status") != "passed"]
def run_command(args: list[str], cwd: Path, timeout: int, command_ref: str) -> dict[str, Any]:
started = time.monotonic()
try:
completed = subprocess.run(
args,
cwd=cwd,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
return {
"command_ref": command_ref,
"status": "passed" if completed.returncode == 0 else "failed",
"exit_code": completed.returncode,
"duration_seconds": round(time.monotonic() - started, 3),
"stdout_bytes": len(completed.stdout.encode()),
"stderr_bytes": len(completed.stderr.encode()),
}
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout if isinstance(exc.stdout, str) else ""
stderr = exc.stderr if isinstance(exc.stderr, str) else ""
return {
"command_ref": command_ref,
"status": "failed",
"exit_code": None,
"duration_seconds": round(time.monotonic() - started, 3),
"error": f"timeout after {timeout}s",
"stdout_bytes": len(stdout.encode()),
"stderr_bytes": len(stderr.encode()),
}
def app_identity(data: dict[str, Any]) -> dict[str, Any]:
app = data.get("app", {})
source = data.get("source", {})
return {
"app": {
"id": app.get("id"),
"name": app.get("name"),
"repo": app.get("repo"),
"owner": app.get("owner"),
"criticality": app.get("criticality"),
},
"source": {
"revision": source.get("revision"),
"artifact": source.get("artifact"),
"digest_policy": source.get("digest_policy"),
},
}
def stage2_context(app_dir: Path, contract_path: Path, data: dict[str, Any]) -> dict[str, Any]:
stage = data.get("stages", {}).get("stage2", {})
if not stage.get("enabled", False):
raise SystemExit("Stage 2 is disabled in railiance/app.toml")
helm = helm_check(data) or {}
chart = app_dir / str(helm.get("chart", f"charts/{data.get('app', {}).get('id', 'app')}"))
values = app_dir / str(helm.get("values", "values/stage2-canary.yaml"))
release = str(stage.get("release", f"{data.get('app', {}).get('id', 'app')}-canary"))
namespace = str(stage.get("namespace", data.get("app", {}).get("id", "default")))
context = {
"contract": str(contract_path),
"app_dir": str(app_dir),
"stage": "stage2",
"namespace": namespace,
"release": release,
"canary_mode": stage.get("canary_mode"),
"observation_minutes": stage.get("observation_minutes"),
"requires_approval": bool(stage.get("requires_approval", False)),
"chart": str(chart),
"values": str(values),
"evidence_expected": list(stage.get("evidence", [])),
"checks_expected": list(stage.get("checks", [])),
}
context.update(app_identity(data))
return context
def local_prechecks(app_dir: Path, data: dict[str, Any], mode: str, approval_id: str | None) -> list[dict[str, Any]]:
stage = data.get("stages", {}).get("stage2", {})
helm = helm_check(data)
checks: list[dict[str, Any]] = []
checks.append(precheck("app.toml", "passed", True))
if helm is None:
checks.append(precheck("stage2-helm-check", "failed", True, "no Stage 2 helm check declared"))
else:
chart = app_dir / str(helm.get("chart", ""))
values = app_dir / str(helm.get("values", ""))
checks.append(precheck("stage2-chart", "passed" if chart.exists() else "failed", True, str(chart)))
checks.append(precheck("stage2-values", "passed" if values.exists() else "failed", True, str(values)))
if mode in {"server-dry-run", "apply"}:
checks.append(
precheck("helm", "passed" if shutil.which("helm") else "failed", True, "helm executable")
)
else:
checks.append(precheck("helm", "not_required", False, "plan mode does not execute helm"))
if mode == "apply" and stage.get("requires_approval", False):
checks.append(
precheck(
"approval-id",
"passed" if approval_id else "failed",
True,
"Stage 2 requires approval before canary exposure",
)
)
elif stage.get("requires_approval", False):
checks.append(precheck("approval-id", "required_before_apply", False))
else:
checks.append(precheck("approval-id", "not_required", False))
return checks
def helm_args(context: dict[str, Any], mode: str, timeout: int) -> list[str]:
args = [
"helm",
"upgrade",
"--install",
context["release"],
context["chart"],
"--namespace",
context["namespace"],
"--create-namespace",
"-f",
context["values"],
]
if mode == "server-dry-run":
args.extend(["--dry-run=server", "--debug"])
if mode == "apply":
args.extend(["--atomic", "--wait", "--timeout", f"{timeout}m"])
return args
def deploy(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Plan or apply a Stage 2 Railiance canary.")
parser.add_argument("app_dir", nargs="?", default=".")
parser.add_argument("--stage", default="2", choices=["2", "stage2"])
parser.add_argument("--mode", choices=["plan", "server-dry-run", "apply"], default="plan")
parser.add_argument("--plan", action="store_const", const="plan", dest="mode")
parser.add_argument("--apply", action="store_const", const="apply", dest="mode")
parser.add_argument("--server-dry-run", action="store_const", const="server-dry-run", dest="mode")
parser.add_argument("--approval-id", help="Operator approval/progress id required before apply when declared.")
parser.add_argument("--stage1-result", help="Optional Stage 1 result JSON for same-candidate evidence.")
parser.add_argument("--timeout-minutes", type=int, default=10)
parser.add_argument("--json-out")
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args(argv)
app_dir = Path(args.app_dir).resolve()
contract_path, data = load_contract(app_dir)
context = stage2_context(app_dir, contract_path, data)
checks = local_prechecks(app_dir, data, args.mode, args.approval_id)
if args.stage1_result:
try:
stage1 = json.loads(Path(args.stage1_result).read_text(encoding="utf-8"))
checks.append(
precheck(
"stage1-result",
"passed" if stage1.get("status") == "passed" else "failed",
args.mode == "apply",
Path(args.stage1_result).name,
)
)
except (OSError, json.JSONDecodeError) as exc:
checks.append(precheck("stage1-result", "failed", args.mode == "apply", str(exc)))
else:
checks.append(precheck("stage1-result", "recommended_before_apply", False))
actions: list[dict[str, Any]] = []
failures = required_failures(checks)
status = "planned" if args.mode == "plan" else "blocked"
if not failures and args.mode in {"server-dry-run", "apply"}:
action = run_command(helm_args(context, args.mode, args.timeout_minutes), app_dir, args.timeout_minutes * 60, "stage2.helm")
actions.append(action)
status = "passed" if action.get("status") == "passed" and args.mode == "server-dry-run" else "applied"
if action.get("status") != "passed":
status = "failed"
elif failures:
status = "blocked"
result: dict[str, Any] = {
"schema_version": "railiance.stage2-deploy-result.v1",
"status": status,
"mode": args.mode,
"generated_at": utc_now(),
**context,
"approval_id": args.approval_id,
"prechecks": checks,
"actions": actions,
"planned_actions": [
{
"action_ref": "stage2.helm",
"tool": "helm",
"mode": args.mode,
"release": context["release"],
"namespace": context["namespace"],
"chart": context["chart"],
"values": context["values"],
}
],
"summary": {
"required_prechecks_failed": len(failures),
"actions_total": len(actions),
"actions_failed": len([item for item in actions if item.get("status") != "passed"]),
},
}
rendered = json.dumps(result, indent=2 if args.pretty else None, sort_keys=True)
print(rendered)
if args.json_out:
output = Path(args.json_out)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(rendered + "\n", encoding="utf-8")
return 0 if result["status"] in {"planned", "passed", "applied"} else 1
def observation_targets(data: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
kube = kubernetes_check(data) or {}
return {
"rollout": kube.get("resource", f"deploy/{context['release']}"),
"pod_selector": f"app.kubernetes.io/instance={context['release']}",
"ingress_selector": f"app.kubernetes.io/instance={context['release']}",
"health_urls": [scrub_url(str(check.get("url", ""))) for check in http_checks(data)],
"metrics": {
"tool": "kubectl top pods",
"selector": f"app.kubernetes.io/instance={context['release']}",
},
}
def observe(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Plan or run Stage 2 Railiance observation checks.")
parser.add_argument("app_dir", nargs="?", default=".")
parser.add_argument("--stage", default="2", choices=["2", "stage2"])
parser.add_argument("--mode", choices=["plan", "live"], default="plan")
parser.add_argument("--plan", action="store_const", const="plan", dest="mode")
parser.add_argument("--live", action="store_const", const="live", dest="mode")
parser.add_argument("--timeout-seconds", type=int, default=120)
parser.add_argument("--json-out")
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args(argv)
app_dir = Path(args.app_dir).resolve()
contract_path, data = load_contract(app_dir)
context = stage2_context(app_dir, contract_path, data)
targets = observation_targets(data, context)
checks = [precheck("app.toml", "passed", True)]
if args.mode == "live":
checks.append(
precheck("kubectl", "passed" if shutil.which("kubectl") else "failed", True, "kubectl executable")
)
else:
checks.append(precheck("kubectl", "not_required", False, "plan mode does not query cluster"))
actions: list[dict[str, Any]] = []
failures = required_failures(checks)
status = "planned"
if args.mode == "live" and not failures:
ns = context["namespace"]
rollout = str(targets["rollout"])
actions.append(
run_command(
["kubectl", "-n", ns, "rollout", "status", rollout, f"--timeout={args.timeout_seconds}s"],
app_dir,
args.timeout_seconds,
"stage2.rollout-status",
)
)
actions.append(
run_command(
["kubectl", "-n", ns, "get", rollout, "-o", "json"],
app_dir,
args.timeout_seconds,
"stage2.rollout-json",
)
)
actions.append(
run_command(
["kubectl", "-n", ns, "get", "pods", "-l", str(targets["pod_selector"]), "-o", "json"],
app_dir,
args.timeout_seconds,
"stage2.pods-json",
)
)
actions.append(
run_command(
["kubectl", "-n", ns, "get", "ingress", "-l", str(targets["ingress_selector"]), "-o", "json"],
app_dir,
args.timeout_seconds,
"stage2.ingress-json",
)
)
metrics = run_command(
["kubectl", "-n", ns, "top", "pods", "-l", str(targets["pod_selector"]), "--no-headers"],
app_dir,
args.timeout_seconds,
"stage2.metrics",
)
if metrics.get("status") != "passed":
metrics["optional"] = True
metrics["status"] = "unavailable"
actions.append(metrics)
status = "passed" if not [item for item in actions if item.get("status") == "failed"] else "failed"
elif failures:
status = "blocked"
result: dict[str, Any] = {
"schema_version": "railiance.stage2-observe-result.v1",
"status": status,
"mode": args.mode,
"generated_at": utc_now(),
**context,
"targets": targets,
"prechecks": checks,
"actions": actions,
"summary": {
"required_prechecks_failed": len(failures),
"actions_total": len(actions),
"actions_failed": len([item for item in actions if item.get("status") == "failed"]),
"metrics_unavailable": len([item for item in actions if item.get("status") == "unavailable"]),
},
}
rendered = json.dumps(result, indent=2 if args.pretty else None, sort_keys=True)
print(rendered)
if args.json_out:
output = Path(args.json_out)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(rendered + "\n", encoding="utf-8")
return 0 if result["status"] in {"planned", "passed"} else 1
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Railiance Stage 2 tooling.")
subparsers = parser.add_subparsers(dest="command", required=True)
deploy_parser = subparsers.add_parser("deploy", help="Plan or apply a Stage 2 canary.")
deploy_parser.add_argument("args", nargs=argparse.REMAINDER)
observe_parser = subparsers.add_parser("observe", help="Plan or run Stage 2 observation.")
observe_parser.add_argument("args", nargs=argparse.REMAINDER)
parsed = parser.parse_args(argv[:1])
if parsed.command == "deploy":
return deploy(argv[1:])
if parsed.command == "observe":
return observe(argv[1:])
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

377
tools/cmd/railiance-stage3 Executable file
View file

@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Railiance Stage 3 promote and rollback tooling."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import time
import tomllib
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SUPPORTED_SCHEMA = "railiance.app.v1"
def utc_now() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def load_contract(app_dir: Path) -> tuple[Path, dict[str, Any]]:
contract_path = app_dir / "railiance" / "app.toml"
if not contract_path.exists():
raise SystemExit(f"Missing Railiance contract: {contract_path}")
with contract_path.open("rb") as handle:
data = tomllib.load(handle)
if data.get("schema_version") != SUPPORTED_SCHEMA:
raise SystemExit(
f"Unsupported schema_version {data.get('schema_version')!r}; expected {SUPPORTED_SCHEMA}"
)
return contract_path, data
def app_identity(data: dict[str, Any]) -> dict[str, Any]:
app = data.get("app", {})
source = data.get("source", {})
return {
"app": {
"id": app.get("id"),
"name": app.get("name"),
"repo": app.get("repo"),
"owner": app.get("owner"),
"criticality": app.get("criticality"),
},
"source": {
"revision": source.get("revision"),
"artifact": source.get("artifact"),
"digest_policy": source.get("digest_policy"),
},
}
def checks_by_id(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {check.get("id"): check for check in data.get("checks", [])}
def stage_checks(data: dict[str, Any], stage_name: str) -> list[dict[str, Any]]:
stage = data.get("stages", {}).get(stage_name, {})
lookup = checks_by_id(data)
return [lookup[item] for item in stage.get("checks", []) if item in lookup]
def stage2_helm_check(data: dict[str, Any]) -> dict[str, Any] | None:
for check in stage_checks(data, "stage2"):
if check.get("type") == "helm":
return check
return None
def precheck(name: str, status: str, required: bool, detail: str | None = None) -> dict[str, Any]:
item: dict[str, Any] = {"name": name, "status": status, "required": required}
if detail:
item["detail"] = detail
return item
def required_failures(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in items if item.get("required", True) and item.get("status") != "passed"]
def run_command(args: list[str], cwd: Path, timeout: int, command_ref: str) -> dict[str, Any]:
started = time.monotonic()
try:
completed = subprocess.run(
args,
cwd=cwd,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
return {
"command_ref": command_ref,
"status": "passed" if completed.returncode == 0 else "failed",
"exit_code": completed.returncode,
"duration_seconds": round(time.monotonic() - started, 3),
"stdout_bytes": len(completed.stdout.encode()),
"stderr_bytes": len(completed.stderr.encode()),
}
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout if isinstance(exc.stdout, str) else ""
stderr = exc.stderr if isinstance(exc.stderr, str) else ""
return {
"command_ref": command_ref,
"status": "failed",
"exit_code": None,
"duration_seconds": round(time.monotonic() - started, 3),
"error": f"timeout after {timeout}s",
"stdout_bytes": len(stdout.encode()),
"stderr_bytes": len(stderr.encode()),
}
def stage3_context(app_dir: Path, contract_path: Path, data: dict[str, Any]) -> dict[str, Any]:
stage = data.get("stages", {}).get("stage3", {})
if not stage.get("enabled", False):
raise SystemExit("Stage 3 is disabled in railiance/app.toml")
app = data.get("app", {})
helm = stage2_helm_check(data) or {}
chart = app_dir / str(helm.get("chart", f"charts/{app.get('id', 'app')}"))
values = app_dir / "values" / "stage3-production.yaml"
release = str(stage.get("release", app.get("id", "app")))
namespace = str(stage.get("namespace", app.get("id", "default")))
context = {
"contract": str(contract_path),
"app_dir": str(app_dir),
"stage": "stage3",
"namespace": namespace,
"release": release,
"chart": str(chart),
"values": str(values),
"promotion_mode": stage.get("promotion_mode"),
"previous_stable": stage.get("previous_stable"),
"requires_approval": bool(stage.get("requires_approval", False)),
"evidence_expected": list(stage.get("evidence", [])),
"checks_expected": list(stage.get("checks", [])),
}
context.update(app_identity(data))
return context
def rollback_context(app_dir: Path, contract_path: Path, data: dict[str, Any]) -> dict[str, Any]:
context = stage3_context(app_dir, contract_path, data)
rollback = data.get("rollback", {})
context["rollback"] = {
"strategy": rollback.get("strategy"),
"command_ref": "rollback.command",
"verification": rollback.get("verification"),
}
return context
def promote_prechecks(app_dir: Path, context: dict[str, Any], mode: str, approval_id: str | None) -> list[dict[str, Any]]:
checks = [precheck("app.toml", "passed", True)]
chart = Path(context["chart"])
values = Path(context["values"])
checks.append(precheck("stage3-chart", "passed" if chart.exists() else "failed", True, str(chart)))
checks.append(precheck("stage3-values", "passed" if values.exists() else "failed", True, str(values)))
checks.append(
precheck(
"previous-stable",
"passed" if context.get("previous_stable") else "failed",
True,
"Stage 3 must record the rollback target before promotion",
)
)
if mode == "apply":
checks.append(precheck("helm", "passed" if shutil.which("helm") else "failed", True, "helm executable"))
else:
checks.append(precheck("helm", "not_required", False, "plan mode does not execute helm"))
if mode == "apply" and context.get("requires_approval"):
checks.append(
precheck(
"approval-id",
"passed" if approval_id else "failed",
True,
"Stage 3 requires approval before stable promotion",
)
)
elif context.get("requires_approval"):
checks.append(precheck("approval-id", "required_before_apply", False))
return checks
def rollback_prechecks(context: dict[str, Any], mode: str, approval_id: str | None, revision: str | None) -> list[dict[str, Any]]:
checks = [precheck("app.toml", "passed", True)]
strategy = context.get("rollback", {}).get("strategy")
checks.append(precheck("rollback-strategy", "passed" if strategy else "failed", True, str(strategy or "")))
if mode == "apply":
checks.append(precheck("helm", "passed" if shutil.which("helm") else "failed", True, "helm executable"))
checks.append(
precheck(
"approval-id",
"passed" if approval_id else "failed",
True,
"Rollback apply requires approval or incident evidence",
)
)
if strategy == "helm-revision":
checks.append(precheck("helm-revision", "passed" if revision else "failed", True))
else:
checks.append(precheck("helm", "not_required", False, "plan mode does not execute helm"))
checks.append(precheck("approval-id", "required_before_apply", False))
if strategy == "helm-revision":
checks.append(precheck("helm-revision", "required_before_apply", False))
return checks
def promote_args(context: dict[str, Any], timeout: int) -> list[str]:
return [
"helm",
"upgrade",
"--install",
context["release"],
context["chart"],
"--namespace",
context["namespace"],
"--create-namespace",
"-f",
context["values"],
"--atomic",
"--wait",
"--timeout",
f"{timeout}m",
]
def rollback_args(context: dict[str, Any], revision: str, timeout: int) -> list[str]:
return [
"helm",
"rollback",
context["release"],
revision,
"--namespace",
context["namespace"],
"--wait",
"--timeout",
f"{timeout}m",
]
def promote(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Plan or apply a Stage 3 stable promotion.")
parser.add_argument("app_dir", nargs="?", default=".")
parser.add_argument("--mode", choices=["plan", "apply"], default="plan")
parser.add_argument("--plan", action="store_const", const="plan", dest="mode")
parser.add_argument("--apply", action="store_const", const="apply", dest="mode")
parser.add_argument("--approval-id")
parser.add_argument("--timeout-minutes", type=int, default=10)
parser.add_argument("--json-out")
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args(argv)
app_dir = Path(args.app_dir).resolve()
contract_path, data = load_contract(app_dir)
context = stage3_context(app_dir, contract_path, data)
checks = promote_prechecks(app_dir, context, args.mode, args.approval_id)
failures = required_failures(checks)
actions: list[dict[str, Any]] = []
status = "planned" if not failures else "blocked"
if args.mode == "apply" and not failures:
action = run_command(promote_args(context, args.timeout_minutes), app_dir, args.timeout_minutes * 60, "stage3.helm-promote")
actions.append(action)
status = "applied" if action.get("status") == "passed" else "failed"
result: dict[str, Any] = {
"schema_version": "railiance.stage3-promote-result.v1",
"status": status,
"mode": args.mode,
"generated_at": utc_now(),
**context,
"approval_id": args.approval_id,
"prechecks": checks,
"actions": actions,
"planned_actions": [
{
"action_ref": "stage3.helm-promote",
"tool": "helm",
"release": context["release"],
"namespace": context["namespace"],
"chart": context["chart"],
"values": context["values"],
}
],
"summary": {
"required_prechecks_failed": len(failures),
"actions_total": len(actions),
"actions_failed": len([item for item in actions if item.get("status") != "passed"]),
},
}
return emit(result, args.json_out, args.pretty, {"planned", "applied"})
def rollback(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Plan or apply a rollback to the previous stable release.")
parser.add_argument("app_dir", nargs="?", default=".")
parser.add_argument("--mode", choices=["plan", "apply"], default="plan")
parser.add_argument("--plan", action="store_const", const="plan", dest="mode")
parser.add_argument("--apply", action="store_const", const="apply", dest="mode")
parser.add_argument("--approval-id")
parser.add_argument("--revision", help="Helm revision to roll back to for helm-revision strategy.")
parser.add_argument("--timeout-minutes", type=int, default=10)
parser.add_argument("--json-out")
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args(argv)
app_dir = Path(args.app_dir).resolve()
contract_path, data = load_contract(app_dir)
context = rollback_context(app_dir, contract_path, data)
checks = rollback_prechecks(context, args.mode, args.approval_id, args.revision)
failures = required_failures(checks)
actions: list[dict[str, Any]] = []
status = "planned" if not failures else "blocked"
if args.mode == "apply" and not failures:
action = run_command(
rollback_args(context, str(args.revision), args.timeout_minutes),
app_dir,
args.timeout_minutes * 60,
"stage3.helm-rollback",
)
actions.append(action)
status = "applied" if action.get("status") == "passed" else "failed"
result: dict[str, Any] = {
"schema_version": "railiance.stage3-rollback-result.v1",
"status": status,
"mode": args.mode,
"generated_at": utc_now(),
**context,
"approval_id": args.approval_id,
"revision": args.revision,
"prechecks": checks,
"actions": actions,
"planned_actions": [
{
"action_ref": "stage3.helm-rollback",
"tool": "helm",
"release": context["release"],
"namespace": context["namespace"],
"revision": args.revision,
}
],
"summary": {
"required_prechecks_failed": len(failures),
"actions_total": len(actions),
"actions_failed": len([item for item in actions if item.get("status") != "passed"]),
},
}
return emit(result, args.json_out, args.pretty, {"planned", "applied"})
def emit(result: dict[str, Any], json_out: str | None, pretty: bool, success_statuses: set[str]) -> int:
rendered = json.dumps(result, indent=2 if pretty else None, sort_keys=True)
print(rendered)
if json_out:
output = Path(json_out)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(rendered + "\n", encoding="utf-8")
return 0 if result["status"] in success_statuses else 1
def main(argv: list[str]) -> int:
if not argv:
print("Usage: railiance-stage3 <promote|rollback> [args]", file=sys.stderr)
return 2
command = argv[0]
if command == "promote":
return promote(argv[1:])
if command == "rollback":
return rollback(argv[1:])
print(f"Unknown Stage 3 command: {command}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,774 @@
#!/usr/bin/env bash
# tools/create_railiance_overlay_repo.sh
# Create a local Railiance overlay repo skeleton for a third-party upstream app.
set -euo pipefail
APP_ID=""
APP_NAME=""
OWNER="platform"
CRITICALITY="medium"
UPSTREAM_URL=""
UPSTREAM_REVISION="main"
UPSTREAM_TRACKING="branch"
OUT_DIR=""
INIT_GIT=false
usage() {
cat <<'EOF'
Usage: tools/create_railiance_overlay_repo.sh --app-id <id> --upstream-url <url> [options]
Required:
--app-id <id> Stable lowercase app id, e.g. forgejo
--upstream-url <url> Upstream source repository or release URL
Options:
--name <name> Human-readable app name (default: app id)
--owner <owner> Owning team/domain (default: platform)
--criticality <level> low|medium|high|critical (default: medium)
--upstream-revision <rev> Upstream branch/tag/commit/release (default: main)
--upstream-tracking <kind> branch|tag|commit|release|digest (default: branch)
--out-dir <path> Output directory (default: <app-id>-railiance-overlay)
--init-git Initialize a local Git repo, without committing
-h|--help Show this help
The script writes local files only. It does not clone upstream code, call Gitea,
fetch secrets, or push a remote.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app-id) APP_ID="${2:?}"; shift 2 ;;
--name) APP_NAME="${2:?}"; shift 2 ;;
--owner) OWNER="${2:?}"; shift 2 ;;
--criticality) CRITICALITY="${2:?}"; shift 2 ;;
--upstream-url) UPSTREAM_URL="${2:?}"; shift 2 ;;
--upstream-revision) UPSTREAM_REVISION="${2:?}"; shift 2 ;;
--upstream-tracking) UPSTREAM_TRACKING="${2:?}"; shift 2 ;;
--out-dir) OUT_DIR="${2:?}"; shift 2 ;;
--init-git) INIT_GIT=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "${APP_ID}" || -z "${UPSTREAM_URL}" ]]; then
echo "ERROR: --app-id and --upstream-url are required" >&2
usage >&2
exit 2
fi
if [[ ! "${APP_ID}" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "ERROR: --app-id must match ^[a-z0-9][a-z0-9-]*$" >&2
exit 2
fi
case "${CRITICALITY}" in
low|medium|high|critical) ;;
*) echo "ERROR: --criticality must be low, medium, high, or critical" >&2; exit 2 ;;
esac
case "${UPSTREAM_TRACKING}" in
branch|tag|commit|release|digest) ;;
*) echo "ERROR: --upstream-tracking must be branch, tag, commit, release, or digest" >&2; exit 2 ;;
esac
if [[ -z "${APP_NAME}" ]]; then
APP_NAME="${APP_ID}"
fi
if [[ -z "${OUT_DIR}" ]]; then
OUT_DIR="${APP_ID}-railiance-overlay"
fi
if [[ -e "${OUT_DIR}" ]]; then
if [[ -n "$(ls -A "${OUT_DIR}")" ]]; then
echo "ERROR: output directory exists and is not empty: ${OUT_DIR}" >&2
exit 1
fi
fi
mkdir -p \
"${OUT_DIR}/railiance" \
"${OUT_DIR}/charts/${APP_ID}/templates" \
"${OUT_DIR}/values" \
"${OUT_DIR}/patches/upstream" \
"${OUT_DIR}/tests" \
"${OUT_DIR}/runbooks" \
"${OUT_DIR}/docs"
touch "${OUT_DIR}/patches/upstream/.gitkeep"
cat > "${OUT_DIR}/README.md" <<EOF
# ${APP_NAME} Railiance Overlay
This repository wraps the upstream ${APP_NAME} application for the Railiance
staged promotion lifecycle.
Upstream source is recorded in \`railiance/upstream.toml\`. Upstream code is not
vendored here by default. Railiance deployment mechanics live in this overlay:
\`railiance/app.toml\`, Helm chart files, stage values, tests, and runbooks.
## Stage 1
Run local validation:
\`\`\`bash
./tests/stage1.sh
\`\`\`
## Stage 2 And Stage 3
Use the Railiance promotion lifecycle once the deploy/observe/promote tooling is
available. Production-critical workloads require human approval before canary
exposure and production promotion.
EOF
cat > "${OUT_DIR}/railiance/upstream.toml" <<EOF
[upstream]
url = "${UPSTREAM_URL}"
revision = "${UPSTREAM_REVISION}"
tracking = "${UPSTREAM_TRACKING}"
license = "see-upstream"
notes = "Railiance overlay only; upstream code is not vendored here."
EOF
cat > "${OUT_DIR}/railiance/app.toml" <<EOF
schema_version = "railiance.app.v1"
[app]
id = "${APP_ID}"
name = "${APP_NAME}"
repo = "${APP_ID}-railiance-overlay"
owner = "${OWNER}"
criticality = "${CRITICALITY}"
description = "Railiance overlay for ${APP_NAME}."
[source]
revision = "${UPSTREAM_REVISION}"
artifact = "image"
digest_policy = "preferred"
[rollback]
strategy = "helm-revision"
command = "railiance rollback . --plan"
verification = "Stable release health check returns 200 after rollback."
[platform]
dependencies = []
[secrets]
references = []
[[observability.health_endpoints]]
name = "local-health"
url = "http://127.0.0.1:8080/health"
stage = "stage1"
expected_status = 200
[[observability.health_endpoints]]
name = "cluster-health"
url = "http://${APP_ID}.${APP_ID}.svc.cluster.local:8080/health"
stage = "stage2"
expected_status = 200
[stages.stage1]
enabled = true
namespace = "local"
release = "${APP_ID}-local"
commands = ["./tests/stage1.sh"]
checks = ["stage1-script", "local-health"]
evidence = ["Stage 1 script result", "local health check or explicit not-run note"]
requires_approval = false
[stages.stage2]
enabled = true
namespace = "${APP_ID}"
release = "${APP_ID}-canary"
commands = ["railiance deploy --stage 2 . --plan", "railiance observe --stage 2 . --plan"]
checks = ["server-dry-run", "canary-ready", "cluster-health"]
evidence = ["release name", "pod readiness", "health 200", "State Hub progress id"]
requires_approval = true
canary_mode = "isolated"
observation_minutes = 30
[stages.stage3]
enabled = true
namespace = "${APP_ID}"
release = "${APP_ID}"
commands = ["railiance promote . --plan", "railiance rollback . --plan"]
checks = ["stage2-accepted", "rollback-target", "cluster-health"]
evidence = ["promotion command id", "new stable digest", "post-promotion smoke"]
requires_approval = true
promotion_mode = "release-replace"
previous_stable = "helm:${APP_ID}:previous"
[[checks]]
id = "stage1-script"
type = "command"
stage = "stage1"
description = "Run generated Stage 1 validation script."
required = true
run = "./tests/stage1.sh"
timeout_seconds = 300
[[checks]]
id = "helm-template"
type = "helm"
stage = "stage1"
description = "Render Helm templates locally when Helm is available."
required = false
chart = "charts/${APP_ID}"
values = "values/stage1.yaml"
mode = "template"
[[checks]]
id = "local-health"
type = "http"
stage = "stage1"
description = "Confirm local service health when a local target is running."
required = false
url = "http://127.0.0.1:8080/health"
expected_status = 200
timeout_seconds = 10
[[checks]]
id = "server-dry-run"
type = "helm"
stage = "stage2"
description = "Render and submit a server-side dry run before canary."
required = true
chart = "charts/${APP_ID}"
values = "values/stage2-canary.yaml"
mode = "server-dry-run"
[[checks]]
id = "canary-ready"
type = "kubernetes"
stage = "stage2"
description = "Canary deployment reaches Available."
required = true
namespace = "${APP_ID}"
resource = "deploy/${APP_ID}-canary"
condition = "Available"
[[checks]]
id = "cluster-health"
type = "http"
stage = "stage2"
description = "Cluster health endpoint returns 200."
required = true
url = "http://${APP_ID}.${APP_ID}.svc.cluster.local:8080/health"
expected_status = 200
timeout_seconds = 10
[[checks]]
id = "stage2-accepted"
type = "manual"
stage = "stage3"
description = "Stage 2 gates passed for the same candidate artifact."
required = true
evidence_required = "State Hub Stage 2 acceptance progress id."
[[checks]]
id = "rollback-target"
type = "manual"
stage = "stage3"
description = "Previous stable release is recorded before promotion."
required = true
evidence_required = "Previous Helm revision or image digest."
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/Chart.yaml" <<EOF
apiVersion: v2
name: ${APP_ID}
description: Railiance overlay chart for ${APP_NAME}
type: application
version: 0.1.0
appVersion: "${UPSTREAM_REVISION}"
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/values.yaml" <<EOF
railiance:
stage: stable
stableRelease: ${APP_ID}
canaryRelease: ${APP_ID}-canary
previousStable:
release: ${APP_ID}
imageTag: ""
imageDigest: ""
traffic:
mode: isolated
provider: standard
stableWeight: 100
canaryWeight: 0
routeName: ${APP_ID}-traffic
entryPoints:
- web
image:
repository: ${APP_ID}
tag: ${UPSTREAM_REVISION}
digest: ""
pullPolicy: IfNotPresent
replicaCount: 1
service:
port: 8080
health:
path: /health
readiness:
initialDelaySeconds: 5
periodSeconds: 10
liveness:
initialDelaySeconds: 15
periodSeconds: 20
prometheus:
enabled: true
scrape: true
path: /metrics
port: http
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
ingress:
enabled: false
className: ""
host: ""
path: /
pathType: Prefix
annotations: {}
tls: []
deployment:
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
env: []
secretRefs: []
podAnnotations: {}
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/templates/_helpers.tpl" <<'EOF'
{{- define "railiance.stage" -}}
{{- default "stable" .Values.railiance.stage -}}
{{- end -}}
{{- define "railiance.releaseName" -}}
{{- if eq (include "railiance.stage" .) "canary" -}}
{{- default (printf "%s-canary" .Chart.Name) .Values.railiance.canaryRelease | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- default .Release.Name .Values.railiance.stableRelease | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "railiance.image" -}}
{{- if .Values.image.digest -}}
{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}}
{{- end -}}
{{- end -}}
{{- define "railiance.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ include "railiance.releaseName" . }}
railiance.coulomb.social/stage: {{ include "railiance.stage" . }}
{{- end -}}
{{- define "railiance.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{ include "railiance.selectorLabels" . }}
{{- end -}}
{{- define "railiance.prometheusAnnotations" -}}
{{- if .Values.prometheus.enabled }}
prometheus.io/scrape: {{ .Values.prometheus.scrape | quote }}
prometheus.io/path: {{ .Values.prometheus.path | quote }}
prometheus.io/port: {{ .Values.prometheus.port | quote }}
{{- end }}
{{- end -}}
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/templates/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "railiance.releaseName" . }}
labels:
{{ include "railiance.labels" . | nindent 4 }}
annotations:
railiance.coulomb.social/stable-release: {{ .Values.railiance.stableRelease | quote }}
railiance.coulomb.social/canary-release: {{ .Values.railiance.canaryRelease | quote }}
railiance.coulomb.social/previous-stable: {{ .Values.railiance.previousStable.release | quote }}
spec:
replicas: {{ .Values.replicaCount }}
revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }}
strategy:
{{ toYaml .Values.deployment.strategy | nindent 4 }}
selector:
matchLabels:
{{ include "railiance.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{ include "railiance.labels" . | nindent 8 }}
annotations:
{{ include "railiance.prometheusAnnotations" . | nindent 8 }}
{{- with .Values.podAnnotations }}
{{ toYaml . | nindent 8 }}
{{- end }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ include "railiance.image" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
readinessProbe:
httpGet:
path: {{ .Values.health.path | quote }}
port: http
initialDelaySeconds: {{ .Values.health.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.health.readiness.periodSeconds }}
livenessProbe:
httpGet:
path: {{ .Values.health.path | quote }}
port: http
initialDelaySeconds: {{ .Values.health.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.health.liveness.periodSeconds }}
{{- with .Values.env }}
env:
{{ toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.secretRefs }}
envFrom:
{{- range .Values.secretRefs }}
- secretRef:
name: {{ . | quote }}
{{- end }}
{{- end }}
resources:
{{ toYaml .Values.resources | nindent 12 }}
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/templates/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata:
name: {{ include "railiance.releaseName" . }}
labels:
{{ include "railiance.labels" . | nindent 4 }}
annotations:
{{ include "railiance.prometheusAnnotations" . | nindent 4 }}
spec:
selector:
{{ include "railiance.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/templates/ingress.yaml" <<'EOF'
{{- if and .Values.ingress.enabled (ne .Values.railiance.traffic.mode "weighted") }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "railiance.releaseName" . }}
labels:
{{ include "railiance.labels" . | nindent 4 }}
annotations:
{{- with .Values.ingress.annotations }}
{{ toYaml . | nindent 4 }}
{{- else }}
railiance.coulomb.social/traffic-mode: {{ .Values.railiance.traffic.mode | quote }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className | quote }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: {{ .Values.ingress.path | quote }}
pathType: {{ .Values.ingress.pathType }}
backend:
service:
name: {{ include "railiance.releaseName" . }}
port:
name: http
{{- with .Values.ingress.tls }}
tls:
{{ toYaml . | nindent 4 }}
{{- end }}
{{- end }}
EOF
cat > "${OUT_DIR}/charts/${APP_ID}/templates/traefik-weighted.yaml" <<'EOF'
{{- if and .Values.ingress.enabled (eq .Values.railiance.traffic.mode "weighted") (eq .Values.railiance.traffic.provider "traefik") }}
{{- $routeName := default (printf "%s-weighted" .Chart.Name) .Values.railiance.traffic.routeName }}
apiVersion: traefik.io/v1alpha1
kind: TraefikService
metadata:
name: {{ $routeName }}
labels:
{{ include "railiance.labels" . | nindent 4 }}
spec:
weighted:
services:
- name: {{ .Values.railiance.stableRelease }}
port: {{ .Values.service.port }}
weight: {{ .Values.railiance.traffic.stableWeight }}
- name: {{ .Values.railiance.canaryRelease }}
port: {{ .Values.service.port }}
weight: {{ .Values.railiance.traffic.canaryWeight }}
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ $routeName }}
labels:
{{ include "railiance.labels" . | nindent 4 }}
spec:
entryPoints:
{{ toYaml .Values.railiance.traffic.entryPoints | nindent 4 }}
routes:
- kind: Rule
match: "Host(`{{ .Values.ingress.host }}`) && PathPrefix(`{{ .Values.ingress.path }}`)"
services:
- name: {{ $routeName }}
kind: TraefikService
port: {{ .Values.service.port }}
{{- end }}
EOF
cat > "${OUT_DIR}/values/stage1.yaml" <<EOF
railiance:
stage: stable
stableRelease: ${APP_ID}
canaryRelease: ${APP_ID}-canary
image:
repository: ${APP_ID}
tag: ${UPSTREAM_REVISION}
EOF
cat > "${OUT_DIR}/values/stage2-canary.yaml" <<EOF
railiance:
stage: canary
stableRelease: ${APP_ID}
canaryRelease: ${APP_ID}-canary
previousStable:
release: ${APP_ID}
imageTag: ""
imageDigest: ""
traffic:
mode: isolated
provider: standard
stableWeight: 100
canaryWeight: 0
routeName: ${APP_ID}-traffic
entryPoints:
- web
image:
repository: ${APP_ID}
tag: ${UPSTREAM_REVISION}
replicaCount: 1
ingress:
enabled: true
host: ${APP_ID}-canary.local
path: /
annotations:
railiance.coulomb.social/canary-mode: isolated
railiance.coulomb.social/stable-release: ${APP_ID}
railiance.coulomb.social/canary-release: ${APP_ID}-canary
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
EOF
cat > "${OUT_DIR}/values/stage3-production.yaml" <<EOF
railiance:
stage: stable
stableRelease: ${APP_ID}
canaryRelease: ${APP_ID}-canary
previousStable:
release: ${APP_ID}
imageTag: ""
imageDigest: ""
traffic:
mode: stable
provider: standard
stableWeight: 100
canaryWeight: 0
routeName: ${APP_ID}-traffic
entryPoints:
- web
image:
repository: ${APP_ID}
tag: ${UPSTREAM_REVISION}
replicaCount: 2
ingress:
enabled: true
host: ${APP_ID}.local
path: /
annotations:
railiance.coulomb.social/stage: stable
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
EOF
cat > "${OUT_DIR}/tests/stage2-template.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd "\$(dirname "\${BASH_SOURCE[0]}")/.."
python3 - <<'PY'
import pathlib
import tomllib
contract = tomllib.loads(pathlib.Path('railiance/app.toml').read_text())
stage2 = contract['stages']['stage2']
assert stage2['release'] == '${APP_ID}-canary'
assert stage2['canary_mode'] in {'isolated', 'weighted', 'header', 'shadow'}
for rel in ('${APP_ID}', '${APP_ID}-canary'):
assert rel
required_paths = [
'charts/${APP_ID}/templates/deployment.yaml',
'charts/${APP_ID}/templates/service.yaml',
'charts/${APP_ID}/templates/ingress.yaml',
'charts/${APP_ID}/templates/traefik-weighted.yaml',
'values/stage2-canary.yaml',
'values/stage3-production.yaml',
]
for item in required_paths:
assert pathlib.Path(item).exists(), item
values = pathlib.Path('values/stage2-canary.yaml').read_text()
assert 'stage: canary' in values
assert 'stableRelease: ${APP_ID}' in values
assert 'canaryRelease: ${APP_ID}-canary' in values
chart = pathlib.Path('charts/${APP_ID}/templates/deployment.yaml').read_text()
assert 'prometheus.io/scrape' in pathlib.Path('charts/${APP_ID}/templates/_helpers.tpl').read_text()
assert 'previous-stable' in chart
print('stage2 canary scaffold ok')
PY
if command -v helm >/dev/null 2>&1; then
helm template ${APP_ID}-canary charts/${APP_ID} -f values/stage2-canary.yaml >/tmp/${APP_ID}-stage2-canary-render.yaml
grep -q 'kind: Deployment' /tmp/${APP_ID}-stage2-canary-render.yaml
grep -q 'kind: Service' /tmp/${APP_ID}-stage2-canary-render.yaml
grep -q 'kind: Ingress' /tmp/${APP_ID}-stage2-canary-render.yaml
echo 'stage2 helm template ok'
else
echo 'helm unavailable; verified stage2 canary scaffold files only'
fi
EOF
chmod +x "${OUT_DIR}/tests/stage2-template.sh"
cat > "${OUT_DIR}/tests/stage1.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd "\$(dirname "\${BASH_SOURCE[0]}")/.."
python3 - <<'PY'
import pathlib
import tomllib
data = tomllib.loads(pathlib.Path('railiance/app.toml').read_text())
assert data['schema_version'] == 'railiance.app.v1'
assert data['app']['id'] == '${APP_ID}'
print('app.toml parse ok')
PY
if command -v helm >/dev/null 2>&1; then
helm template ${APP_ID}-local charts/${APP_ID} -f values/stage1.yaml >/tmp/${APP_ID}-stage1-render.yaml
echo 'helm template ok'
else
echo 'helm unavailable; skipped helm template check'
fi
EOF
chmod +x "${OUT_DIR}/tests/stage1.sh"
cat > "${OUT_DIR}/runbooks/rollback.md" <<EOF
# ${APP_NAME} Rollback
Rollback target: previous stable Helm release revision or image digest.
1. Confirm the current incident symptom and freeze further promotion actions.
2. Run the declared rollback command from \`railiance/app.toml\`.
3. Verify the stable health endpoint returns 200.
4. Record a State Hub progress note with non-secret evidence: release name,
previous stable target, rollback command id, health status, and follow-up.
Do not paste credentials, kubeconfigs, tokens, or private logs into evidence.
EOF
cat > "${OUT_DIR}/docs/promotion.md" <<EOF
# ${APP_NAME} Promotion Notes
This overlay follows the Railiance three-stage lifecycle.
- Stage 1 validates local render and non-production checks.
- Stage 2 deploys an isolated canary by default.
- Stage 3 replaces the stable release only after Stage 2 acceptance.
Run \`tests/stage2-template.sh\` before the first Stage 2 attempt, then run
\`railiance deploy --stage 2 . --plan\` and
\`railiance observe --stage 2 . --plan\`. To use weighted Traefik routing,
change \`railiance.traffic.mode\` to \`weighted\`, set \`provider: traefik\`,
and choose explicit stable/canary weights in \`values/stage2-canary.yaml\`.
Before Stage 2 apply, fill in real image repositories, platform dependencies,
observability endpoints, rollback target details, and approval evidence. Before
Stage 3, run \`railiance promote . --plan\` and \`railiance rollback . --plan\`
so stable promotion and rollback evidence can be reviewed together.
EOF
cat > "${OUT_DIR}/.gitignore" <<'EOF'
.DS_Store
__pycache__/
*.pyc
*.log
*.tmp
*.bak
.secrets/
secrets/
*.kubeconfig
.railiance_gitea.conf
EOF
if [[ "${INIT_GIT}" == true ]]; then
git -C "${OUT_DIR}" init
fi
echo "Created Railiance overlay repo skeleton: ${OUT_DIR}"
echo "Next: edit railiance/app.toml, run tests/stage1.sh, then commit the overlay repo."

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: railiance
created: "2026-07-25"
updated: "2026-07-25"
updated: "2026-07-26"
state_hub_workstream_id: "1f2254f5-a873-46fc-a77c-bc725eeaaeb8"
---
@ -55,7 +55,7 @@ contract summary.
```task
id: RAIL-K8S-WP-0001-T02
status: progress
status: done
priority: high
state_hub_task_id: "148276a2-2e40-4d56-89dd-2930c5e4309c"
```
@ -86,6 +86,15 @@ supporting docs are still pending.
`docs/railiance-run-command.md`. Helper scripts and command implementations
remain pending.
2026-07-26: Imported the remaining reviewed helper surfaces from
`railiance-cluster`: `tools/create_railiance_overlay_repo.sh`,
`tools/cmd/railiance-run`, `tools/cmd/railiance-stage2`, and
`tools/cmd/railiance-stage3`. Added a rail-local `bin/railiance` dispatcher
limited to `create-overlay`, `run`, `deploy`, `observe`, `promote`, and
`rollback`, documented `create-overlay`, refreshed the source import plan, and
verified the imported surface with `bash -n`, `python3 -m py_compile`, and
`bin/railiance help`.
## T03 - Prepare the compatibility handoff from `railiance-cluster`
```task
@ -108,7 +117,7 @@ Acceptance:
```task
id: RAIL-K8S-WP-0001-T04
status: progress
status: done
priority: medium
state_hub_task_id: "d78b5712-f305-4967-9034-9b9471633755"
```
@ -128,5 +137,7 @@ Acceptance:
`ownership_repo: railiance-cluster`. The local repo history is now reconciled
with the remote bootstrap commit and pushed to
`forgejo-remote:coulomb/rail-kubernetes.git`. A targeted live Fabric registry
sync was attempted on July 25, 2026, but `http://127.0.0.1:8765` refused the
connection, so live registry ingestion remains the only open part of this task.
sync initially failed because `http://127.0.0.1:8765` was down, but after
starting the local registry service the targeted sync succeeded. Fabric now
stores `rail-kubernetes` as a live registered repo with snapshot `id: 96` at
commit `4c2b76e3ecc082bc310523bdcd6c660716e4248b`.