Files
Andras Bacsai 2976472841 fix(nodes): apply internal DNS changes without workload redeploys
Key ingress routes by workload UUID and replicate internal DNS names through cluster network revisions on every node. Reconcile name changes and deletions without relying on mutable container labels.

Require route and name reconciliation for network convergence and extend lifecycle, ingress, and end-to-end coverage.
2026-10-06 14:51:23 +02:00

823 lines
34 KiB
Bash
Executable File

#!/usr/bin/env bash
# End-to-end checks for v5 Nodes on the QEMU development environment of this checkout.
#
# Usage:
# scripts/node-e2e [--only <scenario>[,<scenario>]] [--list] [--build]
#
# --list list the scenarios and exit
# --only run only these scenarios, in the given order
# --build build Sentinel (host binary) and Flux from $SENTINEL_DIR and install them first
#
# Runs every scenario except offline-removal, which is destructive and runs only when named
# with --only. Each scenario prints PASS or FAIL with the reason and restores what it changed
# (Coolify, VMs, workload, firewall rule, CA rotation, ingress), also when it fails. Exits non-zero
# when a scenario fails.
#
# Needs the instance of this checkout (./scripts/dev start) with the KVM workers node-worker-a
# and node-worker-b in the cluster "Development QEMU mesh" and one workload on worker A, plus
# jq and virsh on the host. Coolify-side steps run through `php artisan dev:node-e2e`.
#
# Env: SENTINEL_DIR=/root/devel/sentinel NODE_E2E_WORK=<dir for build artifacts and the SSH key>
# NODE_E2E_SENTINEL_VERSION=<version baked into the Sentinel build>
#
set -euo pipefail
set -o errtrace
cd "$(dirname "$0")/.."
ROOT="$(pwd -P)"
SCENARIOS=(preflight coolify-down-dns ingress reboot-recovery stop-marker flux-restart offline-node ca-rotation ca-rotation-offline offline-removal)
DEFAULT_SCENARIOS=(preflight coolify-down-dns ingress reboot-recovery stop-marker flux-restart offline-node ca-rotation ca-rotation-offline)
declare -A DESCRIPTIONS=(
[preflight]='stack up, both Nodes connected, cluster active, workload running, DNS on both Nodes'
[coolify-down-dns]='Coolify down for 150 s: DNS keeps resolving and endpoint expiry keeps moving'
[ingress]='HTTP ingress on both Nodes: domain served, stop gives 502, start serves, Coolify down 150 s still serves'
[reboot-recovery]='Coolify down, reboot both VMs: network, Corrosion, DNS, and workload come back'
[stop-marker]='a stopped workload stays stopped across a reboot; a started one comes back'
[flux-restart]='restart Flux during a deployment: no operation stays active, workload runs'
[offline-node]='Node B offline: A converges, B pending, cluster degraded, notifications, B catches up'
[ca-rotation]='full staged Flux CA rotation with both Nodes online, then system.ping'
[ca-rotation-offline]='CA rotation with Node B offline during distribution: B blocks the switch, then catches up'
[offline-removal]='(opt-in, destructive) remove offline Node B, deferred leave on boot, add B back'
)
SENTINEL_DIR="${SENTINEL_DIR:-/root/devel/sentinel}"
WORK="${NODE_E2E_WORK:-$HOME/.cache/coolify-node-e2e}"
GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir)"
ENV_DIR="$(dirname "$GIT_COMMON_DIR")/.dev-instances"
PROJECT="$(./scripts/dev container)"
INSTANCE="${PROJECT#coolify-dev-}"
APP="$PROJECT"
FLUX="$PROJECT-flux"
ENV_FILE="$ENV_DIR/$INSTANCE.env"
FLUX_OVERRIDE="$ENV_DIR/$INSTANCE-flux-override.yml"
RESTART_DIR="$ROOT/storage/app/node-e2e"
KEY="$WORK/ssh-key"
SSH_OPTS=(-i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes)
declare -A NODE_IP=() NODE_WG=()
WORKLOAD_UUID=''
WORKLOAD_DNS_NAME=''
CONTAINER_NAME=''
WORKLOAD_IP=''
ROTATION_START_VERSION=''
usage() {
awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
}
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
# ---------------------------------------------------------------------------
# Scenario bookkeeping: fail() records the reason, note() adds a detail to the result line.
REASON_FILE=''
NOTES_FILE=''
RESTORE_STEPS=()
fail() {
[[ -s "$REASON_FILE" ]] || printf '%s' "$*" >"$REASON_FILE"
exit 1
}
note() {
log "$*"
printf '%s; ' "$*" >>"$NOTES_FILE"
}
# on_exit <command...>: run when the scenario ends, newest first, also after a failure.
on_exit() { RESTORE_STEPS+=("$*"); }
run_restore_steps() {
local index
for ((index = ${#RESTORE_STEPS[@]} - 1; index >= 0; index--)); do
log "restore: ${RESTORE_STEPS[index]}"
(eval "${RESTORE_STEPS[index]}") || log "restore step failed: ${RESTORE_STEPS[index]}"
done
log 'restore: baseline'
restore_baseline || log 'restore: baseline is not reached'
}
# wait_until <seconds> <description> <command...>: poll every 5 s.
wait_until() {
local timeout="$1" description="$2" deadline
shift 2
deadline=$((SECONDS + timeout))
while ! "$@" >/dev/null 2>&1; do
if ((SECONDS >= deadline)); then
fail "timeout after ${timeout}s: $description"
fi
sleep 5
done
}
# Same as wait_until, but returns 1 instead of failing the scenario.
try_until() {
local timeout="$1" deadline
shift
deadline=$((SECONDS + timeout))
while ! "$@" >/dev/null 2>&1; do
((SECONDS < deadline)) || return 1
sleep 5
done
}
# ---------------------------------------------------------------------------
# Coolify, Flux, VMs, Nodes
artisan() { docker exec -i "$APP" php artisan dev:node-e2e "$@"; }
status_json() { artisan status; }
# st <jq filter>: one value from the status JSON.
st() { status_json | jq -r "$1"; }
st_is() { [[ "$(st "$1" 2>/dev/null)" == "$2" ]]; }
coolify_running() {
[[ "$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$APP" 2>/dev/null)" == 'running healthy' ]] &&
artisan status >/dev/null 2>&1
}
coolify_stop() {
log "stopping Coolify ($APP)"
docker stop "$APP" >/dev/null
}
coolify_start() {
if [[ "$(docker inspect --format '{{.State.Status}}' "$APP")" != running ]]; then
log "starting Coolify ($APP)"
docker start "$APP" >/dev/null
fi
try_until 300 coolify_running || {
log 'Coolify did not become healthy within 5 minutes'
return 1
}
}
vm_domain() { echo "coolify-dev-$INSTANCE--node-worker-$1"; }
vm_state() { virsh domstate "$(vm_domain "$1")" 2>/dev/null | head -1; }
node_ssh() {
local node="$1"
shift
ssh "${SSH_OPTS[@]}" "root@${NODE_IP[$node]}" "$@"
}
boot_id() { node_ssh "$1" cat /proc/sys/kernel/random/boot_id 2>/dev/null; }
booted_since() { local current; current="$(boot_id "$1")" && [[ -n "$current" && "$current" != "$2" ]]; }
sentinel_active() { node_ssh "$1" systemctl is-active --quiet sentinel; }
vm_shutdown() {
log "shutting down VM $(vm_domain "$1")"
virsh shutdown "$(vm_domain "$1")" >/dev/null
wait_until 180 "VM $1 shuts off" vm_is_off "$1"
}
vm_is_off() { [[ "$(vm_state "$1")" == 'shut off' ]]; }
vm_start() {
if [[ "$(vm_state "$1")" != running ]]; then
log "starting VM $(vm_domain "$1")"
virsh start "$(vm_domain "$1")" >/dev/null
fi
try_until 240 sentinel_active "$1"
}
# vm_reboot <node>...: reboot and wait until SSH answers with a new boot ID.
vm_reboot() {
local node
declare -A before=()
for node in "$@"; do
before[$node]="$(boot_id "$node")"
log "rebooting VM $(vm_domain "$node")"
node_ssh "$node" 'systemctl reboot' >/dev/null 2>&1 || true
done
for node in "$@"; do
wait_until 240 "VM $node boots again" booted_since "$node" "${before[$node]}"
done
}
node_connected() { st_is ".nodes.$1.connected" true; }
# Connected in Coolify and answering system.ping through Flux right now.
node_reachable() { artisan ping "$1" >/dev/null 2>&1 && node_connected "$1"; }
nodes_reachable() { node_reachable a && node_reachable b; }
container_state() { node_ssh a "podman inspect -f '{{.State.Status}}' $CONTAINER_NAME 2>/dev/null || echo absent"; }
workload_running() { [[ "$(container_state)" == running ]]; }
dns_answer() {
node_ssh "$1" "dig +short +time=2 +tries=1 @${NODE_WG[$1]} $WORKLOAD_DNS_NAME.default.coolify.internal" 2>/dev/null
}
dns_resolves() { [[ "$(dns_answer "$1")" == "$WORKLOAD_IP" ]]; }
dns_resolves_everywhere() { dns_resolves a && dns_resolves b; }
# Expiry of the workload endpoint row in Corrosion on a Node, as Unix seconds.
endpoint_expires_at() {
node_ssh "$1" "corrosion query --config /etc/corrosion/config.toml \"SELECT expires_at FROM workload_endpoints WHERE workload_id = '$WORKLOAD_UUID' AND container_ip = '$WORKLOAD_IP'\"" 2>/dev/null | tail -1
}
cluster_active() { st_is '.cluster.network_status' active; }
no_active_workload_operations() { st_is '.workload.active_operations | length' 0; }
# Brings the environment back to normal: Coolify up, both VMs up and connected, workload running.
restore_baseline() {
coolify_start || return 1
vm_start a || return 1
vm_start b || return 1
try_until 240 nodes_reachable || {
log 'Nodes did not reconnect within 4 minutes'
return 1
}
if [[ "$(st '.workload.desired_state')" != running ]] || ! workload_running; then
try_until 300 no_active_workload_operations || true
artisan lifecycle start >/dev/null || artisan deploy >/dev/null || true
try_until 180 workload_running || {
log 'The workload is not running'
return 1
}
fi
try_until 300 cluster_active || log "cluster network is $(st '.cluster.network_status' 2>/dev/null || echo unknown)"
}
load_environment() {
local status
mkdir -p "$WORK"
if [[ ! -s "$KEY" ]]; then
docker cp "$APP:/var/www/html/storage/app/ssh/keys/ssh_key@ssh" "$KEY" >/dev/null
fi
chmod 600 "$KEY"
status="$(status_json)" || {
echo "Coolify ($APP) does not answer. Start the instance with ./scripts/dev start." >&2
exit 1
}
for node in a b; do
NODE_IP[$node]="$(jq -er ".nodes.$node.ip" <<<"$status")"
NODE_WG[$node]="$(jq -er ".nodes.$node.wireguard_ip" <<<"$status")"
done
WORKLOAD_UUID="$(jq -er '.workload.uuid' <<<"$status")"
WORKLOAD_DNS_NAME="$(jq -er '.workload.dns_name' <<<"$status")"
CONTAINER_NAME="$(jq -er '.workload.container_name' <<<"$status")"
WORKLOAD_IP="$(jq -er '.workload.container_ips[0]' <<<"$status")"
[[ "$(jq -er '.workload.node_uuids[0]' <<<"$status")" == development-qemu-node-worker-a ]] ||
{ echo 'The end-to-end workload must run on worker A.' >&2; exit 1; }
}
cleanup_work() {
rm -f "$KEY" "$WORK"/sentinel "$WORK"/*.log
rmdir "$WORK" 2>/dev/null || true
rm -rf "$RESTART_DIR"
}
# ---------------------------------------------------------------------------
# Mailpit (the instance SMTP settings point at it)
mailpit_url() { echo "http://127.0.0.1:$(grep -E '^FORWARD_MAILPIT_DASHBOARD_PORT=' "$ENV_FILE" | cut -d= -f2-)"; }
# mail_count <subject substring> <since RFC3339>
mail_count() {
curl -fsS "$(mailpit_url)/api/v1/messages?limit=200" |
jq --arg subject "$1" --arg since "$2" '[.messages[] | select((.Subject | contains($subject)) and .Created >= $since)] | length'
}
mail_arrived() { (($(mail_count "$1" "$2") > 0)); }
# ---------------------------------------------------------------------------
# Build and install
build_and_install() {
local version="${NODE_E2E_SENTINEL_VERSION:-0.0.0-dev-e2e-$(date +%Y%m%d%H%M)}" id node
mkdir -p "$WORK"
log "building Sentinel $version from $SENTINEL_DIR"
docker build -q -f "$SENTINEL_DIR/Dockerfile.host" --build-arg VERSION="$version" -t "sentinel-host:$INSTANCE-dev" "$SENTINEL_DIR" >/dev/null
log 'building Flux'
docker build -q -f "$SENTINEL_DIR/Dockerfile.flux" -t "flux:$INSTANCE-dev" "$SENTINEL_DIR" >/dev/null
id="$(docker create "sentinel-host:$INSTANCE-dev" /sentinel)"
docker cp "$id:/sentinel" "$WORK/sentinel" >/dev/null
docker rm "$id" >/dev/null
for node in a b; do
log "installing Sentinel on worker $node"
scp "${SSH_OPTS[@]}" -q "$WORK/sentinel" "root@${NODE_IP[$node]}:/usr/local/bin/sentinel.new"
node_ssh "$node" 'chmod 755 /usr/local/bin/sentinel.new && mv /usr/local/bin/sentinel.new /usr/local/bin/sentinel && systemctl restart sentinel && systemctl try-restart coolify-discovery-dns'
done
rm -f "$WORK/sentinel"
printf 'services:\n flux:\n image: flux:%s-dev\n' "$INSTANCE" >"$FLUX_OVERRIDE"
log 'recreating Flux with the local image'
local -a env_files=()
[[ -f .env ]] && env_files+=(--env-file .env)
docker compose -p "$PROJECT" -f docker-compose.dev-multi.yml -f docker-compose.node-dev.yml -f "$FLUX_OVERRIDE" \
"${env_files[@]}" --env-file "$ENV_FILE" up -d --no-deps flux >/dev/null 2>&1
try_until 180 nodes_reachable || { echo 'Nodes did not reconnect after the install.' >&2; exit 1; }
for node in a b; do
log "worker $node runs $(node_ssh "$node" 'sentinel --version')"
done
}
# Answers the restart request of `dev:node-e2e ca-continue` during the CA switch.
serve_flux_restart() {
local pid="$1"
while kill -0 "$pid" 2>/dev/null; do
if [[ -f "$RESTART_DIR/flux-restart.request" ]]; then
log "restarting Flux for certificate $(cut -c1-23 "$RESTART_DIR/flux-restart.request")..."
rm -f "$RESTART_DIR/flux-restart.request"
docker restart "$FLUX" >/dev/null
touch "$RESTART_DIR/flux-restart.done"
fi
sleep 1
done
}
# ---------------------------------------------------------------------------
# Scenarios
scenario_preflight() {
local container
for container in "$APP" "$FLUX" "$PROJECT-postgres-1" "$PROJECT-redis-1"; do
[[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)" == running ]] || fail "container $container is not running"
done
coolify_running || fail 'Coolify is not healthy'
[[ "$(vm_state a)" == running && "$(vm_state b)" == running ]] || fail 'both worker VMs must run'
wait_until 120 'both Nodes connected and answering system.ping' nodes_reachable
wait_until 300 'cluster network active' cluster_active
[[ "$(st '.workload.desired_state')" == running ]] || fail 'workload desired state is not running'
workload_running || fail "workload container is $(container_state)"
wait_until 60 'DNS resolves on worker A' dns_resolves a
wait_until 60 'DNS resolves on worker B' dns_resolves b
note "Sentinel $(st '.nodes.a.sentinel_version')/$(st '.nodes.b.sentinel_version'), revision $(st '.cluster.desired_revision')"
}
scenario_coolify_down_dns() {
local before_a before_b after_a after_b started
before_a="$(endpoint_expires_at a)"
before_b="$(endpoint_expires_at b)"
[[ -n "$before_a" && -n "$before_b" ]] || fail 'workload endpoint row is missing in Corrosion'
on_exit coolify_start
coolify_stop
started=$SECONDS
while ((SECONDS - started < 150)); do
sleep 30
dns_resolves a || fail "DNS stopped resolving on worker A after $((SECONDS - started))s"
dns_resolves b || fail "DNS stopped resolving on worker B after $((SECONDS - started))s"
done
after_a="$(endpoint_expires_at a)"
after_b="$(endpoint_expires_at b)"
((after_a > before_a + 100 && after_b > before_b + 100)) ||
fail "endpoint expiry did not move: A $before_a -> $after_a, B $before_b -> $after_b"
(($(node_ssh a 'date +%s') < after_a)) || fail 'endpoint expired'
note "expires_at A +$((after_a - before_a))s, B +$((after_b - before_b))s while Coolify was down 150s"
coolify_start || fail 'Coolify did not start again'
wait_until 180 'Nodes reconnect after Coolify start' nodes_reachable
}
# ---------------------------------------------------------------------------
# Ingress: requests go to the Node address with the domain in the Host header, so no DNS is needed.
INGRESS_DOMAIN='whoami.node-e2e.test'
# ingress_code <node> [host]: HTTP status of a request through the ingress of a Node (000 without an answer).
ingress_code() {
curl -s -o /dev/null -m 10 -w '%{http_code}' -H "Host: ${2:-$INGRESS_DOMAIN}" "http://${NODE_IP[$1]}/" 2>/dev/null || true
}
# ingress_serves <node>: the whoami workload answers through the ingress of the Node.
ingress_serves() {
curl -fsS -m 10 -H "Host: $INGRESS_DOMAIN" "http://${NODE_IP[$1]}/" 2>/dev/null | grep -q "^IP: $WORKLOAD_IP\$"
}
ingress_serves_everywhere() { ingress_serves a && ingress_serves b; }
ingress_no_upstream_everywhere() { [[ "$(ingress_code a)" == 502 && "$(ingress_code b)" == 502 ]]; }
caddy_active_everywhere() {
node_ssh a 'systemctl is-active --quiet coolify-ingress' && node_ssh b 'systemctl is-active --quiet coolify-ingress'
}
ingress_active_everywhere() { st_is '[.nodes.a.ingress_state, .nodes.b.ingress_state] | join(",")' active,active; }
# restore_ingress <domains> <port> <a on|off> <b on|off>: the workload domains and the ingress setting of both Nodes.
restore_ingress() {
local result
result="$(artisan domains "$1" --http-port="$2")" || log "restoring the workload domains failed: $result"
result="$(artisan ingress a "$3")" || log "restoring ingress on worker A failed: $result"
result="$(artisan ingress b "$4")" || log "restoring ingress on worker B failed: $result"
}
scenario_ingress() {
local status result started domains port ingress_a ingress_b
status="$(status_json)"
# The original state, restored also after a failure.
domains="$(jq -r '.workload.domains | join(",")' <<<"$status")"
port="$(jq -r '.workload.http_port // ""' <<<"$status")"
ingress_a="$(jq -r 'if .nodes.a.is_ingress then "on" else "off" end' <<<"$status")"
ingress_b="$(jq -r 'if .nodes.b.is_ingress then "on" else "off" end' <<<"$status")"
on_exit "restore_ingress '$domains' '$port' $ingress_a $ingress_b"
started=$SECONDS
result="$(artisan ingress a on)" || fail "turning on ingress on worker A failed: $result"
result="$(artisan ingress b on)" || fail "turning on ingress on worker B failed: $result"
result="$(artisan domains "$INGRESS_DOMAIN" --http-port=80)" || fail "setting the domain failed: $result"
wait_until 180 'Caddy active on both Nodes' caddy_active_everywhere
wait_until 180 'the domain is served through both Nodes' ingress_serves_everywhere
note "served through A (local) and B (over WireGuard) $((SECONDS - started))s after the domain was set"
[[ "$(ingress_code a unknown.node-e2e.test)/$(ingress_code b unknown.node-e2e.test)" == 404/404 ]] || fail 'an unknown host is not answered with 404'
wait_until 180 'Coolify shows ingress active on both Nodes' ingress_active_everywhere
result="$(artisan lifecycle stop)" || fail "stop failed: $result"
[[ "$(jq -r .status <<<"$result")" == succeeded ]] || fail "stop operation: $result"
started=$SECONDS
wait_until 60 'both Nodes answer 502 after the stop' ingress_no_upstream_everywhere
note "502 on both Nodes $((SECONDS - started))s after the stop"
result="$(artisan lifecycle start)" || fail "start failed: $result"
[[ "$(jq -r .status <<<"$result")" == succeeded ]] || fail "start operation: $result"
started=$SECONDS
wait_until 60 'served through both Nodes after the start' ingress_serves_everywhere
note "served again $((SECONDS - started))s after the start"
on_exit coolify_start
coolify_stop
started=$SECONDS
while ((SECONDS - started < 150)); do
sleep 30
ingress_serves a || fail "worker A stopped serving after $((SECONDS - started))s without Coolify (HTTP $(ingress_code a))"
ingress_serves b || fail "worker B stopped serving after $((SECONDS - started))s without Coolify (HTTP $(ingress_code b))"
done
note 'served through both Nodes while Coolify was down 150s'
coolify_start || fail 'Coolify did not start again'
wait_until 180 'Nodes reconnect after Coolify start' nodes_reachable
}
# network_restored <node>: WireGuard interface, firewall table, and discovery services after boot.
network_restored() {
node_ssh "$1" 'ip link show coolify0 >/dev/null && nft list table inet coolify_cluster >/dev/null && systemctl is-active --quiet corrosion coolify-discovery-dns'
}
network_and_dns_restored() { network_restored "$1" && dns_resolves "$1"; }
scenario_reboot_recovery() {
local node started
on_exit coolify_start
coolify_stop
started=$SECONDS
vm_reboot a b
for node in a b; do
wait_until 180 "network, Corrosion, and DNS restored on worker $node" network_and_dns_restored "$node"
done
wait_until 180 'workload running after reboot' workload_running
note "restored $((SECONDS - started))s after the reboot command, without Coolify"
coolify_start || fail 'Coolify did not start again'
wait_until 240 'Nodes reconnect after Coolify start' nodes_reachable
}
scenario_stop_marker() {
local result started
on_exit coolify_start
result="$(artisan lifecycle stop)" || fail "stop failed: $result"
[[ "$(jq -r .status <<<"$result")" == succeeded ]] || fail "stop operation: $result"
[[ "$(container_state)" != running ]] || fail 'container still runs after stop'
coolify_stop
vm_reboot a
wait_until 180 'network restored on worker A' network_restored a
# Give the boot restore time to (wrongly) start the container.
started=$SECONDS
while ((SECONDS - started < 60)); do
[[ "$(container_state)" != running ]] || fail 'stopped workload started after reboot'
sleep 10
done
note "stopped workload stayed $(container_state) after reboot"
coolify_start || fail 'Coolify did not start again'
wait_until 240 'worker A reconnects' node_reachable a
wait_until 120 'no active workload operation' no_active_workload_operations
result="$(artisan lifecycle start)" || fail "start failed: $result"
[[ "$(jq -r .status <<<"$result")" == succeeded ]] || fail "start operation: $result"
wait_until 60 'workload running after start' workload_running
coolify_stop
vm_reboot a
wait_until 180 'workload running after second reboot' workload_running
wait_until 120 'DNS resolves on both Nodes after second reboot' dns_resolves_everywhere
coolify_start || fail 'Coolify did not start again'
wait_until 240 'worker A reconnects' node_reachable a
}
operation_status() { artisan operation "$1" | jq -r .status; }
scenario_flux_restart() {
local result operation status started
wait_until 420 'no active workload operation' no_active_workload_operations
# Deployments need fresh Node resource data, which the scheduler collects every minute.
started=$SECONDS
until result="$(artisan deploy --pull=newer)"; do
[[ "$result" == *'resource data'* && $((SECONDS - started)) -lt 180 ]] || fail "deploy failed: $result"
sleep 10
done
operation="$(jq -r .uuid <<<"$result")"
[[ "$(jq -r .created <<<"$result")" == true ]] || fail "deployment was not created: $result"
started=$SECONDS
status="$(artisan await-operation "$operation" | jq -r .status)"
[[ "$status" != queued ]] || fail 'deployment stayed queued for 60s'
# Without a stop timeout: Flux goes away mid-command like after a crash.
log "restarting Flux while the deployment is $status"
docker restart -t 0 "$FLUX" >/dev/null
sleep 5
note "Flux restarted while the operation was $status, 5s later it was $(operation_status "$operation")"
# Stale operation recovery runs every five minutes and confirms an uncertain operation that converged.
wait_until 420 'no active operation for the workload' no_active_workload_operations
result="$(artisan operation "$operation")"
note "operation ended $(jq -r .status <<<"$result")$(jq -r 'if .error then ": " + .error else "" end' <<<"$result") after $((SECONDS - started))s"
wait_until 300 'workload running' workload_running
wait_until 120 'Nodes reconnected to Flux' nodes_reachable
wait_until 60 'DNS resolves on both Nodes' dns_resolves_everywhere
}
offline_network_degraded() {
local status
status="$(status_json)" || return 1
jq -e '.nodes.a.network_state == "converged" and .nodes.a.network_applied_revision == .cluster.desired_revision
and .nodes.b.network_status == "pending" and .cluster.network_status == "degraded"' <<<"$status" >/dev/null
}
unreachable_notified() {
st_is '.nodes.b.unreachable_notified_at != null and .cluster.network_unhealthy_notified_at != null' true
}
recovered() {
local status
status="$(status_json)" || return 1
jq -e '.cluster.network_status == "active" and .nodes.b.network_state == "converged"
and .nodes.b.network_applied_revision == .cluster.desired_revision
and .nodes.b.unreachable_notified_at == null and .cluster.network_unhealthy_notified_at == null' <<<"$status" >/dev/null
}
scenario_offline_node() {
local since started result
since="$(date -u +%Y-%m-%dT%H:%M:%S)"
on_exit 'vm_start b'
vm_shutdown b
started=$SECONDS
wait_until 180 'Coolify sees worker B unreachable' eval '! node_connected b'
note "B disconnected after $((SECONDS - started))s"
on_exit 'artisan firewall-remove >/dev/null'
result="$(artisan firewall-add)" || fail "adding the firewall rule failed: $result"
wait_until 240 'A converged, B pending, cluster degraded after a rule change' offline_network_degraded
wait_until 360 'unreachable and cluster unhealthy notification state set' unreachable_notified
note "notification state set $((SECONDS - started))s after shutdown"
result="$(artisan firewall-remove)" || fail "removing the firewall rule failed: $result"
wait_until 240 'A converged on the second revision while B is offline' offline_network_degraded
if st_is '.notification_channels.server_unreachable | index("EmailChannel") != null' true; then
wait_until 60 'unreachable mail in Mailpit' mail_arrived 'QEMU worker node B) is unreachable' "$since"
wait_until 60 'cluster degraded mail in Mailpit' mail_arrived 'Development QEMU mesh) is degraded' "$since"
else
note 'unreachable mails skipped: email is disabled for server_unreachable'
fi
vm_start b || fail 'worker B did not boot'
started=$SECONDS
wait_until 180 'worker B reconnects' node_reachable b
wait_until 300 'B converges, cluster active, notification state cleared' recovered
note "B converged $((SECONDS - started))s after it reconnected"
wait_until 60 'DNS resolves on both Nodes' dns_resolves_everywhere
if st_is '.notification_channels.server_reachable | index("EmailChannel") != null' true; then
wait_until 120 'recovery mails in Mailpit' mail_arrived 'QEMU worker node B) is reachable' "$since"
else
note 'recovery mails skipped: the team disabled email for server_reachable (the default)'
fi
note "mails: $(curl -fsS "$(mailpit_url)/api/v1/messages?limit=50" | jq -r --arg since "$since" '[.messages[] | select(.Created >= $since) | .Subject] | join(" | ")')"
}
rotation_status() { artisan ca-status; }
rotation_blocking_none() { [[ "$(rotation_status | jq -r .blocking)" == 0 ]]; }
rotation_idle() { [[ "$(rotation_status | jq -r '.rotation.in_progress // false')" == false ]]; }
pings_work() { artisan ping a >/dev/null && artisan ping b >/dev/null; }
# Finishes or cancels a rotation that a failed run left behind.
rotation_restore() {
local step
for _ in 1 2 3 4; do
rotation_idle && return 0
step="$(rotation_status | jq -r .next_step)"
log "rotation restore: $step"
if [[ "$step" == switch ]]; then
artisan ca-cancel >/dev/null || return 1
continue
fi
try_until 180 rotation_blocking_none || true
rotation_continue || return 1
done
}
# Runs `ca-continue` and restarts Flux when the CA switch asks for it.
rotation_continue() {
local output="$WORK/ca-continue.json" pid status
mkdir -p "$RESTART_DIR"
rm -f "$RESTART_DIR"/flux-restart.*
artisan ca-continue >"$output" 2>&1 &
pid=$!
serve_flux_restart "$pid"
wait "$pid" && status=0 || status=$?
if ((status != 0)); then
log "ca-continue failed: $(cat "$output")"
fi
rm -f "$output"
return "$status"
}
# node_trust_bundle <node>: "<certificate count> <bundle version> <sha256 of the first CA>" as Sentinel stores them.
node_trust_bundle() {
node_ssh "$1" 'echo "$(grep -c "BEGIN CERTIFICATE" /etc/coolify/sentinel-flux-ca.pem) $(cat /etc/coolify/sentinel-flux-ca.version) $(openssl x509 -in /etc/coolify/sentinel-flux-ca.pem -noout -fingerprint -sha256 | cut -d= -f2 | tr -d : | tr A-F a-f)"'
}
# rotation_start: start a rotation and print the bundle version before it.
rotation_start() {
local start_version result
rotation_idle || fail 'a CA rotation is already in progress'
start_version="$(rotation_status | jq -r .bundle_version)"
on_exit rotation_restore
result="$(artisan ca-start)" || fail "start failed: $result"
[[ "$(jq -r .rotation.dual_bundle_version <<<"$result")" == $((start_version + 1)) ]] || fail "unexpected dual bundle: $result"
ROTATION_START_VERSION=$start_version
}
# rotation_complete: every step after both Nodes acknowledged the dual bundle, with checks.
rotation_complete() {
local start_version="$ROTATION_START_VERSION" result node
rotation_continue || fail 'switch to the new CA failed'
[[ "$(rotation_status | jq -r .rotation.status)" == switched ]] || fail 'rotation is not switched'
wait_until 240 'Nodes reconnect through the new Flux certificate' nodes_reachable
wait_until 60 'system.ping through the new certificate' pings_work
rotation_continue || fail 'retirement start failed'
wait_until 300 'both Nodes acknowledge the final bundle' rotation_blocking_none
rotation_continue || fail 'retiring the old CA failed'
result="$(rotation_status)"
[[ "$(jq -r .rotation.status <<<"$result")" == completed ]] || fail "rotation did not complete: $(jq -c .rotation <<<"$result")"
[[ "$(jq -r '[.nodes[] | select(.is_usable) | .version] | unique | join(",")' <<<"$result")" == $((start_version + 2)) ]] ||
fail "Nodes did not all acknowledge bundle $((start_version + 2)): $(jq -c '[.nodes[] | {name, version}]' <<<"$result")"
[[ "$(jq -r .rotation.switch_forced <<<"$result")/$(jq -r .rotation.completion_forced <<<"$result")" == false/false ]] || fail 'a step was forced'
for node in a b; do
[[ "$(node_trust_bundle "$node")" == "1 $((start_version + 2)) $(jq -r .rotation.to_fingerprint <<<"$result")" ]] ||
fail "worker $node trusts $(node_trust_bundle "$node") instead of the new CA alone"
done
wait_until 120 'Nodes connected after the rotation' nodes_reachable
wait_until 60 'system.ping on both Nodes' pings_work
}
scenario_ca_rotation() {
local started=$SECONDS
rotation_start
wait_until 300 'both Nodes acknowledge the dual bundle' rotation_blocking_none
note "dual bundle $((ROTATION_START_VERSION + 1)) acknowledged after $((SECONDS - started))s"
rotation_complete
note "completed in $((SECONDS - started))s, bundle $ROTATION_START_VERSION -> $((ROTATION_START_VERSION + 2))"
}
node_acknowledged() { [[ "$(rotation_status | jq -r --arg uuid "development-qemu-node-worker-$1" '.nodes[] | select(.uuid == $uuid) | .acknowledged')" == true ]]; }
scenario_ca_rotation_offline() {
local started output
on_exit 'vm_start b'
vm_shutdown b
wait_until 180 'Coolify sees worker B unreachable' eval '! node_connected b'
rotation_start
wait_until 300 'worker A acknowledges the dual bundle' node_acknowledged a
[[ "$(rotation_status | jq -r .blocking)" == 1 ]] || fail 'offline worker B does not block the switch'
output="$(artisan ca-continue)" && fail 'the switch ran while worker B had not acknowledged the dual bundle'
[[ "$output" == *'QEMU worker node B'* ]] || fail "unexpected switch refusal: $output"
vm_start b || fail 'worker B did not boot'
started=$SECONDS
wait_until 180 'worker B reconnects' node_reachable b
wait_until 360 'worker B catches up with the dual bundle' rotation_blocking_none
note "B acknowledged the dual bundle $((SECONDS - started))s after it booted"
rotation_complete
note "completed, bundle $ROTATION_START_VERSION -> $((ROTATION_START_VERSION + 2))"
}
leave_completed_on_b() {
st_is '.nodes.b.network_pending_leave' false &&
node_ssh b '! ip link show coolify0 >/dev/null 2>&1 && ! nft list table inet coolify_cluster >/dev/null 2>&1 && ! systemctl is-active --quiet corrosion'
}
scenario_offline_removal() {
local result
[[ "$(st '.nodes.b.cluster_id')" == "$(st '.cluster.id')" ]] || fail 'worker B is not in the cluster'
on_exit 'vm_start b'
on_exit readd_node_b
vm_shutdown b
wait_until 180 'Coolify sees worker B unreachable' eval '! node_connected b'
result="$(artisan remove-node b)" || fail "removal failed: $result"
[[ "$(jq -r .pending_leave <<<"$result")" == true ]] || fail "leave was not deferred: $result"
wait_until 240 'cluster active with worker A only' cluster_active
vm_start b || fail 'worker B did not boot'
wait_until 180 'worker B reconnects' node_reachable b
wait_until 300 'deferred leave ran and B network is torn down' leave_completed_on_b
note 'deferred leave completed after B reconnected'
readd_node_b || fail 'adding worker B back failed'
wait_until 240 'cluster active with both Nodes' cluster_active
wait_until 120 'DNS resolves on both Nodes' dns_resolves_everywhere
}
readd_node_b() {
local result
if [[ "$(st '.nodes.b.cluster_id')" == "$(st '.cluster.id')" ]]; then
return 0
fi
try_until 240 node_reachable b || return 1
try_until 300 eval "st_is '.nodes.b.network_pending_leave' false" || return 1
result="$(artisan add-node b)" || {
log "add-node failed: $result"
return 1
}
NODE_WG[b]="$(st '.nodes.b.wireguard_ip')"
}
# ---------------------------------------------------------------------------
run_scenario() {
local name="$1" status started reason notes
REASON_FILE="$(mktemp -p "$WORK")"
NOTES_FILE="$(mktemp -p "$WORK")"
started=$SECONDS
log "=== $name: ${DESCRIPTIONS[$name]}"
set +e
(
set -e
RESTORE_STEPS=()
trap '[[ -s "$REASON_FILE" ]] || printf "unexpected failure at line %s: %s" "$LINENO" "$BASH_COMMAND" >"$REASON_FILE"' ERR
trap run_restore_steps EXIT
"scenario_${name//-/_}"
)
status=$?
set -e
reason="$(cat "$REASON_FILE")"
notes="$(sed 's/; $//' "$NOTES_FILE")"
rm -f "$REASON_FILE" "$NOTES_FILE"
if ((status == 0)); then
RESULTS+=("PASS $name ($((SECONDS - started))s)${notes:+ - $notes}")
return 0
fi
RESULTS+=("FAIL $name ($((SECONDS - started))s) - ${reason:-exit $status}${notes:+ [$notes]}")
return 1
}
main() {
local build=false only='' name failed=0
local -a selected=()
while [[ $# -gt 0 ]]; do
case "$1" in
--list)
for name in "${SCENARIOS[@]}"; do
printf '%-20s %s\n' "$name" "${DESCRIPTIONS[$name]}"
done
exit 0
;;
--only)
only="${2:?--only needs a scenario list}"
shift
;;
--build) build=true ;;
-h | --help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
shift
done
if [[ -n "$only" ]]; then
IFS=, read -ra selected <<<"$only"
for name in "${selected[@]}"; do
[[ -v "DESCRIPTIONS[$name]" ]] || {
echo "Unknown scenario '$name'. Use --list." >&2
exit 2
}
done
else
selected=("${DEFAULT_SCENARIOS[@]}")
fi
trap cleanup_work EXIT
load_environment
if [[ "$build" == true ]]; then
build_and_install
fi
RESULTS=()
for name in "${selected[@]}"; do
run_scenario "$name" || failed=1
done
printf '\n'
printf '%s\n' "${RESULTS[@]}"
return "$failed"
}
main "$@"