Import rail-kubernetes lifecycle tooling
This commit is contained in:
parent
303be3928e
commit
f31064a3ba
11 changed files with 2010 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
35
bin/railiance
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
58
docs/create-overlay-command.md
Normal file
58
docs/create-overlay-command.md
Normal 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`.
|
||||
|
|
@ -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
301
tools/cmd/railiance-run
Executable 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
439
tools/cmd/railiance-stage2
Executable 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
377
tools/cmd/railiance-stage3
Executable 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:]))
|
||||
774
tools/create_railiance_overlay_repo.sh
Executable file
774
tools/create_railiance_overlay_repo.sh
Executable 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."
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue