fix(control-plane): redeploy wykonywalny — dispatch do host-side deploy-runnera
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>
2026-08-03 18:26:30 +02:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# jobs/deploy-runner/deploy-runner.sh — host-side executor for `redeploy` actions.
|
|
|
|
|
#
|
fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:12:24 +02:00
|
|
|
# WHY THIS EXISTS (kb/subsystems/recon-multiagent.md D14/D15):
|
fix(control-plane): redeploy wykonywalny — dispatch do host-side deploy-runnera
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>
2026-08-03 18:26:30 +02:00
|
|
|
# the control-plane executor could never carry out a redeploy — it ran
|
|
|
|
|
# deploy-node.sh inside its own container, a script that ignores its arguments
|
|
|
|
|
# and expects a repo at ${HOME}/homelab-codex-ws that does not exist there (nor
|
|
|
|
|
# does git or the docker CLI). Every healthcheck_failed → redeploy action
|
|
|
|
|
# dead-ended, which is the single biggest reason the self-healing loop had
|
|
|
|
|
# 18 pending / 0 completed actions.
|
|
|
|
|
#
|
fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:12:24 +02:00
|
|
|
# The fix keeps the architecture decision from kb/phases/backlog.md ("Remediacja
|
fix(control-plane): redeploy wykonywalny — dispatch do host-side deploy-runnera
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>
2026-08-03 18:26:30 +02:00
|
|
|
# floty bez SSH"): the VPS never initiates a connection to a node. The executor
|
|
|
|
|
# only WRITES an action file; this runner, on the target node, pulls it, runs
|
|
|
|
|
# the deploy locally, and reports the outcome back through the existing event
|
|
|
|
|
# pipeline.
|
|
|
|
|
#
|
|
|
|
|
# executor (vps) → /opt/homelab/actions/deploy/<node>/<action_id>.json
|
|
|
|
|
# → this runner rsync-pulls it (--remove-source-files: collected once)
|
|
|
|
|
# → validates it (jobs/deploy-runner/action.py)
|
|
|
|
|
# → scripts/deploy/deploy-service.sh --force-recreate (same compose
|
|
|
|
|
# invocation as a human deploy — identical project name)
|
|
|
|
|
# → writes an action_result event and rsync-pushes it back to the VPS
|
|
|
|
|
# → executor._reconcile_running_actions() → completed / failed
|
|
|
|
|
#
|
|
|
|
|
# It runs on the HOST, not in a container: compose then resolves relative bind
|
|
|
|
|
# mounts and the project name exactly as it does for a human `deploy.sh`, and
|
|
|
|
|
# no agent container needs a docker CLI. It is deliberately independent of
|
|
|
|
|
# node-agent (own rsync, own result push) so it can redeploy node-agent itself —
|
|
|
|
|
# the solaria case, where node-agent has been docker-blind for weeks.
|
|
|
|
|
#
|
|
|
|
|
# It NEVER runs `git pull`: a redeploy reconciles the node to the checkout it
|
|
|
|
|
# already has. Shipping new code stays a human `scripts/deploy/deploy.sh` action.
|
|
|
|
|
#
|
|
|
|
|
# Config comes from the environment (systemd EnvironmentFile) — see env.example.
|
|
|
|
|
# Install: see README.md.
|
|
|
|
|
|
|
|
|
|
# NOT set -e: one malformed or failing action must be reported and skipped, not
|
|
|
|
|
# abort the whole run.
|
|
|
|
|
set -uo pipefail
|
|
|
|
|
|
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
|
HELPER="${SCRIPT_DIR}/action.py"
|
|
|
|
|
|
|
|
|
|
NODE_NAME="${NODE_NAME:-}"
|
|
|
|
|
REPO_PATH="${REPO_PATH:-${HOME}/homelab-codex-ws}"
|
|
|
|
|
RUNTIME_PATH="${RUNTIME_PATH:-/opt/homelab}"
|
|
|
|
|
VPS_EVENTS_HOST="${VPS_EVENTS_HOST:-}"
|
|
|
|
|
VPS_EVENTS_USER="${VPS_EVENTS_USER:-oskar}"
|
|
|
|
|
VPS_EVENTS_PATH="${VPS_EVENTS_PATH:-/opt/homelab/events}"
|
|
|
|
|
VPS_DEPLOY_PATH="${VPS_DEPLOY_PATH:-/opt/homelab/actions/deploy}"
|
|
|
|
|
DEPLOY_TIMEOUT_SECS="${DEPLOY_TIMEOUT_SECS:-600}"
|
|
|
|
|
|
|
|
|
|
INBOX="${RUNTIME_PATH}/actions/deploy/${NODE_NAME}"
|
|
|
|
|
EVENTS_DIR="${RUNTIME_PATH}/events/${NODE_NAME}"
|
|
|
|
|
STATE_DIR="${RUNTIME_PATH}/state"
|
|
|
|
|
MARKER_DIR="${STATE_DIR}/processed-deploy-actions"
|
|
|
|
|
LOG_DIR="${RUNTIME_PATH}/logs/deploy-runner"
|
|
|
|
|
HOST_DIR="${REPO_PATH}/hosts/${NODE_NAME}"
|
|
|
|
|
|
|
|
|
|
# Same ssh settings node-agent uses for its rsync channel: no ~/.ssh/config, no
|
|
|
|
|
# host-key prompts, batch mode so a missing key fails fast instead of hanging.
|
|
|
|
|
SSH_CMD="ssh -F /dev/null -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o BatchMode=yes"
|
|
|
|
|
if [[ -n "${SSH_KEY:-}" ]]; then
|
|
|
|
|
SSH_CMD="${SSH_CMD} -i ${SSH_KEY}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [deploy-runner] $*"; }
|
|
|
|
|
die() { log "ERROR: $*"; exit 1; }
|
|
|
|
|
|
|
|
|
|
[[ -n "$NODE_NAME" ]] || die "NODE_NAME is not set (see env.example)"
|
|
|
|
|
[[ -d "$REPO_PATH" ]] || die "repo not found at ${REPO_PATH} (set REPO_PATH)"
|
|
|
|
|
[[ -d "$HOST_DIR" ]] || die "no hosts/${NODE_NAME}/ directory in ${REPO_PATH}"
|
|
|
|
|
[[ -x "${REPO_PATH}/scripts/deploy/deploy-service.sh" ]] \
|
|
|
|
|
|| die "scripts/deploy/deploy-service.sh missing or not executable in ${REPO_PATH}"
|
|
|
|
|
command -v python3 >/dev/null || die "python3 not found"
|
|
|
|
|
|
|
|
|
|
mkdir -p "$INBOX" "$EVENTS_DIR" "$MARKER_DIR" "$LOG_DIR" || die "cannot create runtime dirs under ${RUNTIME_PATH}"
|
|
|
|
|
|
|
|
|
|
# Single instance: the timer fires every 60s but a deploy may take minutes.
|
|
|
|
|
exec 9>"${STATE_DIR}/deploy-runner.lock" || die "cannot open lock file"
|
|
|
|
|
if ! flock -n 9; then
|
|
|
|
|
log "another deploy-runner run is in progress — exiting"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ── transport ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# Remote nodes fetch their inbox from the VPS. On the VPS itself the executor
|
|
|
|
|
# writes straight into the shared /opt/homelab mount, so there is nothing to
|
|
|
|
|
# pull and nothing to push — the executor reads the result event in place.
|
|
|
|
|
is_remote() { [[ -n "$VPS_EVENTS_HOST" ]]; }
|
|
|
|
|
|
|
|
|
|
pull_actions() {
|
|
|
|
|
is_remote || return 0
|
|
|
|
|
local rc=0
|
|
|
|
|
rsync -az --remove-source-files \
|
|
|
|
|
--omit-dir-times --no-perms --no-owner --no-group \
|
|
|
|
|
-e "$SSH_CMD" \
|
|
|
|
|
"${VPS_EVENTS_USER}@${VPS_EVENTS_HOST}:${VPS_DEPLOY_PATH}/${NODE_NAME}/" \
|
|
|
|
|
"${INBOX}/" || rc=$?
|
|
|
|
|
# 23/24 = "partial transfer"/"vanished source files": what an empty or
|
|
|
|
|
# concurrently-drained remote inbox looks like. Not worth logging each run.
|
|
|
|
|
if [[ $rc -ne 0 && $rc -ne 23 && $rc -ne 24 ]]; then
|
|
|
|
|
log "WARN: dispatch pull failed (rsync rc=${rc})"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
push_event() {
|
|
|
|
|
local event_file="$1"
|
|
|
|
|
is_remote || return 0
|
|
|
|
|
local rc=0
|
|
|
|
|
rsync -az --remove-source-files \
|
|
|
|
|
--omit-dir-times --no-perms --no-owner --no-group \
|
|
|
|
|
-e "$SSH_CMD" \
|
|
|
|
|
"$event_file" \
|
|
|
|
|
"${VPS_EVENTS_USER}@${VPS_EVENTS_HOST}:${VPS_EVENTS_PATH}/${NODE_NAME}/" || rc=$?
|
|
|
|
|
if [[ $rc -ne 0 && $rc -ne 23 && $rc -ne 24 ]]; then
|
|
|
|
|
# Left in place on failure: node-agent's own event shipping will pick it
|
|
|
|
|
# up on its next cycle, so a result is delayed, never lost.
|
|
|
|
|
log "WARN: result push failed (rsync rc=${rc}) — leaving ${event_file} for node-agent to ship"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
report() {
|
|
|
|
|
local action_id="$1" service="$2" success="$3" error="$4"
|
|
|
|
|
local event_file
|
|
|
|
|
event_file=$(python3 "$HELPER" emit-result \
|
|
|
|
|
--events-dir "$EVENTS_DIR" \
|
|
|
|
|
--node "$NODE_NAME" \
|
|
|
|
|
--action-id "$action_id" \
|
|
|
|
|
--service "$service" \
|
|
|
|
|
--success "$success" \
|
|
|
|
|
--error "$error") || { log "ERROR: could not write result event for ${action_id}"; return 1; }
|
|
|
|
|
log "reported ${action_id}: success=${success}${error:+ error=${error}}"
|
|
|
|
|
push_event "$event_file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ── main ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
pull_actions
|
|
|
|
|
|
|
|
|
|
shopt -s nullglob
|
|
|
|
|
for action_file in "${INBOX}"/*.json; do
|
|
|
|
|
OK="false"; ACTION_ID=""; SERVICE=""; ERROR=""
|
|
|
|
|
eval "$(python3 "$HELPER" validate --file "$action_file" --node "$NODE_NAME" --repo "$REPO_PATH")"
|
|
|
|
|
|
|
|
|
|
if [[ "$OK" != "true" ]]; then
|
|
|
|
|
log "rejected $(basename "$action_file"): ${ERROR}"
|
|
|
|
|
if [[ -n "$ACTION_ID" ]]; then
|
|
|
|
|
report "$ACTION_ID" "$SERVICE" "false" "$ERROR"
|
|
|
|
|
fi
|
|
|
|
|
rm -f "$action_file"
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
marker="${MARKER_DIR}/${ACTION_ID}.done"
|
|
|
|
|
if [[ -e "$marker" ]]; then
|
|
|
|
|
log "action ${ACTION_ID} already processed — skipping (idempotency)"
|
|
|
|
|
rm -f "$action_file"
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
log "deploying ${SERVICE} for action ${ACTION_ID}"
|
|
|
|
|
action_log="${LOG_DIR}/${ACTION_ID}-$(date -u +%Y%m%dT%H%M%SZ).log"
|
|
|
|
|
rc=0
|
|
|
|
|
timeout "$DEPLOY_TIMEOUT_SECS" \
|
|
|
|
|
"${REPO_PATH}/scripts/deploy/deploy-service.sh" \
|
|
|
|
|
--repo "$REPO_PATH" \
|
|
|
|
|
--host-dir "$HOST_DIR" \
|
|
|
|
|
--service "$SERVICE" \
|
|
|
|
|
--force-recreate \
|
|
|
|
|
>"$action_log" 2>&1 || rc=$?
|
|
|
|
|
|
|
|
|
|
case "$rc" in
|
|
|
|
|
0)
|
|
|
|
|
report "$ACTION_ID" "$SERVICE" "true" ""
|
|
|
|
|
;;
|
|
|
|
|
3)
|
|
|
|
|
report "$ACTION_ID" "$SERVICE" "false" \
|
|
|
|
|
"${SERVICE} owns its deploy path (services/${SERVICE}/deploy-local.sh) — needs an operator deploy, not an automated redeploy"
|
|
|
|
|
;;
|
|
|
|
|
124)
|
|
|
|
|
report "$ACTION_ID" "$SERVICE" "false" \
|
|
|
|
|
"deploy timed out after ${DEPLOY_TIMEOUT_SECS}s (log: ${action_log})"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
# Last 20 lines are the useful part of a compose failure; the full
|
|
|
|
|
# output stays in the per-action log on the node.
|
|
|
|
|
err=$(tail -n 20 "$action_log" 2>/dev/null | tr -d '\000' | tail -c 2000)
|
|
|
|
|
report "$ACTION_ID" "$SERVICE" "false" \
|
|
|
|
|
"deploy-service.sh exited ${rc} (log: ${action_log}): ${err}"
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
touch "$marker" || log "WARN: could not write idempotency marker ${marker}"
|
|
|
|
|
rm -f "$action_file"
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
exit 0
|