Executor odpalal scripts/deploy/deploy-node.sh <node> <service> wewnatrz
swojego kontenera: skrypt ignoruje oba argumenty i wymaga repo w
${HOME}/homelab-codex-ws (w kontenerze HOME=/home/homelab) -> exit 1 w 18.
linii. Za tym brak git, brak klienta docker w obrazie, a gdyby przeszedl —
deploy calego zestawu uslug hosta executora zamiast wezla z akcji. Kazdy
redeploy padal (recon D14/D15; 18 pending / 0 completed).
Redeploy idzie teraz ta sama sciezka pull co container_restart — VPS nigdy
nie inicjuje polaczenia do wezla:
executor -> actions/deploy/<node>/<id>.json
-> deploy-runner (systemd na hoscie) rsync-pull, walidacja, deploy
-> action_result event -> executor rozlicza completed/failed
- scripts/deploy/deploy-service.sh: deploy jednej uslugi, wspoldzielony z
deploy-node.sh, wiec inwokacja compose (a przez to nazwa projektu) jest
identyczna jak przy deployu recznym
- jobs/deploy-runner/: host-level, nie kontener — compose rozwiazuje
wzgledne bindy i nazwe projektu tak jak przy deployu czlowieka;
niezalezny od node-agenta, wiec potrafi zredeployowac takze jego
- walidacja: tylko typ redeploy, node musi sie zgadzac, usluga musi byc w
hosts/<node>/services.yaml, zadna tresc z payloadu nie trafia do shella
- --force-recreate bez --build i bez --remove-orphans: redeploy to
rekoncyliacja, nie wysylka kodu
- executor: REDEPLOY_TIMEOUT_SECS=900, /repo zjechany do :ro (nieuzywany)
248 testow zielonych; deploy-node.sh przecwiczony na atrapie dockera —
argv compose bez zmian. Instalacja unitow na wezlach i E2E: backlog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
222 lines
8.3 KiB
Python
Executable file
222 lines
8.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Validation and result-reporting helper for the host-side deploy runner.
|
|
|
|
The runner (deploy-runner.sh) is bash — it does flock, rsync and timeout, which
|
|
bash does well. Everything that needs to parse untrusted JSON, read
|
|
hosts/<node>/services.yaml or write an event in the exact node-agent format
|
|
lives here, where it is testable (jobs/deploy-runner/tests/).
|
|
|
|
Two subcommands, both designed to be consumed from bash:
|
|
|
|
validate --file <action.json> --node <name> --repo <path>
|
|
Prints shell assignments for `eval`:
|
|
OK='true'|'false'
|
|
ACTION_ID='...' (best effort — set even when OK=false, so the
|
|
runner can still report a result for it)
|
|
SERVICE='...'
|
|
ERROR='...'
|
|
Always exits 0 unless the arguments themselves are unusable; the verdict
|
|
is in OK. Nothing from the action file is ever executed or interpolated
|
|
into a command — only a validated service NAME is handed back.
|
|
|
|
emit-result --events-dir <dir> --node <n> --action-id <id> --service <svc>
|
|
--success true|false [--error <msg>]
|
|
Writes evt-<node>-<ts>-action_result-<slug>.json into the node's event
|
|
directory, byte-compatible with node_agent.emit_event() so the control
|
|
plane executor's _find_action_result() picks it up unchanged. Prints the
|
|
path it wrote.
|
|
|
|
Validation rules (all must hold):
|
|
1. the file is JSON with type == "redeploy"
|
|
2. action["node"] == this node — defense in depth; the inbox is already
|
|
node-scoped, but a mis-delivered file must never deploy here
|
|
3. action_id and service match strict name patterns (no path traversal)
|
|
4. the service is listed in hosts/<node>/services.yaml — the repo's desired
|
|
state is the authority on what this node may run, so a rogue or stale
|
|
dispatch cannot pull an arbitrary stack onto the node
|
|
5. services/<service>/docker-compose.yml exists in the repo
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ACTION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
SERVICE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
ACTION_TYPE = "redeploy"
|
|
|
|
|
|
class Invalid(Exception):
|
|
"""Validation failure carrying a human-readable reason."""
|
|
|
|
|
|
def _load_desired_services(repo: Path, node: str) -> set[str]:
|
|
"""Service names listed in hosts/<node>/services.yaml."""
|
|
try:
|
|
import yaml
|
|
except ImportError as exc: # pragma: no cover - environment problem
|
|
raise Invalid(f"PyYAML not available on this node: {exc}") from exc
|
|
|
|
path = repo / "hosts" / node / "services.yaml"
|
|
if not path.is_file():
|
|
raise Invalid(f"no desired-state manifest for node '{node}' ({path})")
|
|
try:
|
|
data = yaml.safe_load(path.read_text()) or {}
|
|
except Exception as exc:
|
|
raise Invalid(f"unreadable {path}: {exc}") from exc
|
|
|
|
services = data.get("services") or {}
|
|
if isinstance(services, dict):
|
|
return {str(k) for k in services.keys()}
|
|
if isinstance(services, list):
|
|
return {str(s) for s in services}
|
|
raise Invalid(f"unexpected 'services' shape in {path}: {type(services).__name__}")
|
|
|
|
|
|
def validate(action_file: Path, node: str, repo: Path) -> tuple[str, str]:
|
|
"""Return (action_id, service) for a valid redeploy action, else raise Invalid.
|
|
|
|
action_id is also returned inside the Invalid path via the caller's
|
|
best-effort parse, so a rejected action can still be reported back.
|
|
"""
|
|
try:
|
|
action = json.loads(action_file.read_text())
|
|
except Exception as exc:
|
|
raise Invalid(f"unparseable action file: {exc}") from exc
|
|
if not isinstance(action, dict):
|
|
raise Invalid("action file is not a JSON object")
|
|
|
|
action_id = str(action.get("action_id") or action_file.stem)
|
|
if not ACTION_ID_RE.match(action_id):
|
|
raise Invalid(f"invalid action_id: {action_id!r}")
|
|
|
|
action_type = action.get("type")
|
|
if action_type != ACTION_TYPE:
|
|
raise Invalid(
|
|
f"action type {action_type!r} is not executable by the deploy runner "
|
|
f"(only {ACTION_TYPE!r})"
|
|
)
|
|
|
|
target_node = action.get("node")
|
|
if target_node != node:
|
|
raise Invalid(f"action addressed to node {target_node!r}, not {node!r} — refused")
|
|
|
|
service = action.get("service")
|
|
if not isinstance(service, str) or not SERVICE_RE.match(service):
|
|
raise Invalid(f"invalid service name: {service!r}")
|
|
|
|
desired = _load_desired_services(repo, node)
|
|
if service not in desired:
|
|
raise Invalid(
|
|
f"service '{service}' is not in hosts/{node}/services.yaml — "
|
|
f"refusing to deploy a service this node is not declared to run"
|
|
)
|
|
|
|
compose = repo / "services" / service / "docker-compose.yml"
|
|
if not compose.is_file():
|
|
raise Invalid(f"no compose file in repo for '{service}' ({compose})")
|
|
|
|
return action_id, service
|
|
|
|
|
|
def _best_effort_action_id(action_file: Path) -> str:
|
|
try:
|
|
data = json.loads(action_file.read_text())
|
|
candidate = str(data.get("action_id") or action_file.stem)
|
|
except Exception:
|
|
candidate = action_file.stem
|
|
return candidate if ACTION_ID_RE.match(candidate) else ""
|
|
|
|
|
|
def _emit_assignments(ok: bool, action_id: str, service: str, error: str) -> str:
|
|
return "\n".join(
|
|
f"{key}={shlex.quote(value)}"
|
|
for key, value in (
|
|
("OK", "true" if ok else "false"),
|
|
("ACTION_ID", action_id),
|
|
("SERVICE", service),
|
|
("ERROR", error),
|
|
)
|
|
)
|
|
|
|
|
|
def emit_result(events_dir: Path, node: str, action_id: str, service: str,
|
|
success: bool, error: str) -> Path:
|
|
"""Write an action_result event in node_agent.emit_event()'s exact format.
|
|
|
|
The control-plane executor globs evt-*-action_result-*.json in the node's
|
|
event directory and matches payload.action_id, comparing the unix timestamp
|
|
embedded in the filename against the action's started_at — so both the name
|
|
and the payload shape matter here (see executor._find_action_result).
|
|
"""
|
|
ts = int(time.time())
|
|
slug = re.sub(r"[^a-z0-9]", "-", (service or "node").lower())[:32].strip("-")
|
|
event_id = f"evt-{node}-{ts}-action_result-{slug}"
|
|
event = {
|
|
"id": event_id,
|
|
"timestamp": ts,
|
|
"date": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
"type": "action_result",
|
|
"severity": "info" if success else "high",
|
|
"node": node,
|
|
"service": service or "",
|
|
"message": f"Action {action_id} {'succeeded' if success else 'failed'}",
|
|
"payload": {
|
|
"action_id": action_id,
|
|
"success": success,
|
|
"error": error,
|
|
"node": node,
|
|
"source": "deploy-runner",
|
|
},
|
|
}
|
|
events_dir.mkdir(parents=True, exist_ok=True)
|
|
path = events_dir / f"{event_id}.json"
|
|
path.write_text(json.dumps(event, indent=2))
|
|
return path
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
p_val = sub.add_parser("validate")
|
|
p_val.add_argument("--file", required=True, type=Path)
|
|
p_val.add_argument("--node", required=True)
|
|
p_val.add_argument("--repo", required=True, type=Path)
|
|
|
|
p_emit = sub.add_parser("emit-result")
|
|
p_emit.add_argument("--events-dir", required=True, type=Path)
|
|
p_emit.add_argument("--node", required=True)
|
|
p_emit.add_argument("--action-id", required=True)
|
|
p_emit.add_argument("--service", default="")
|
|
p_emit.add_argument("--success", required=True, choices=["true", "false"])
|
|
p_emit.add_argument("--error", default="")
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.command == "validate":
|
|
try:
|
|
action_id, service = validate(args.file, args.node, args.repo)
|
|
except Invalid as exc:
|
|
print(_emit_assignments(False, _best_effort_action_id(args.file), "", str(exc)))
|
|
return 0
|
|
print(_emit_assignments(True, action_id, service, ""))
|
|
return 0
|
|
|
|
path = emit_result(
|
|
args.events_dir, args.node, args.action_id, args.service,
|
|
args.success == "true", args.error,
|
|
)
|
|
print(path)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|