#!/usr/bin/env bash # End-to-end checks for v5 Nodes on the QEMU development environment of this checkout. # # Usage: # scripts/node-e2e [--only [,]] [--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= # NODE_E2E_SENTINEL_VERSION= # 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 : 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 : 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 : 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 ...: 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 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 [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 : 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 : 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 : 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 : " " 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 "$@"