From 9224b913748d3c597e86a67dd76eb3c4b634f74f Mon Sep 17 00:00:00 2001 From: S Date: Mon, 2 Mar 2026 22:23:52 -0600 Subject: [PATCH] feat: enhance canary mode to support domain-aware upsert behavior for site blocks --- TODO.md | 7 +- phase7_5_nginx_to_caddy.sh | 216 +++++++++++++++++++++++++++++++------ 2 files changed, 186 insertions(+), 37 deletions(-) diff --git a/TODO.md b/TODO.md index ed20191..e11bcac 100644 --- a/TODO.md +++ b/TODO.md @@ -112,6 +112,7 @@ Phase 7.5 is done only when all are true: 3. Script does not change Cloudflare DNS records automatically. - DNS updates are intentional/manual to keep blast radius controlled. 4. Do not set public Cloudflare proxied records to private `192.168.x.x` addresses. -5. Canary updates are enclosed between markers: - - `# BEGIN_PHASE7_5_CANARY` - - `# END_PHASE7_5_CANARY` +5. Canary upsert behavior is domain-aware: + - if site block for the canary domain does not exist, it is added + - if site block exists, it is replaced in-place + - previous block content is printed in logs before replacement diff --git a/phase7_5_nginx_to_caddy.sh b/phase7_5_nginx_to_caddy.sh index ca4af21..e41ac86 100755 --- a/phase7_5_nginx_to_caddy.sh +++ b/phase7_5_nginx_to_caddy.sh @@ -104,8 +104,6 @@ CANARY_HOST_MAP=( GITEA_ENTRY="${GITEA_DOMAIN}|http://${UNRAID_GITEA_IP}:3000|false||false" CADDY_COMPOSE_DIR="${UNRAID_COMPOSE_DIR}/caddy" -CANARY_BEGIN_MARKER="# BEGIN_PHASE7_5_CANARY" -CANARY_END_MARKER="# END_PHASE7_5_CANARY" SELECTED_HOST_MAP=() if [[ "$MODE" == "canary" ]]; then @@ -212,6 +210,174 @@ emit_site_block_standalone() { } >> "$outfile" } +caddy_block_extract_for_host() { + local infile="$1" host="$2" outfile="$3" + awk -v host="$host" ' + function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s + } + function brace_delta(s, tmp, opens, closes) { + tmp = s + opens = gsub(/\{/, "{", tmp) + closes = gsub(/\}/, "}", tmp) + return opens - closes + } + function has_host(labels, i, n, parts, token) { + labels = trim(labels) + gsub(/[[:space:]]+/, "", labels) + n = split(labels, parts, ",") + for (i = 1; i <= n; i++) { + token = parts[i] + if (token == host) { + return 1 + } + } + return 0 + } + + BEGIN { + depth = 0 + in_target = 0 + target_depth = 0 + found = 0 + } + + { + line = $0 + + if (!in_target) { + if (depth == 0) { + pos = index(line, "{") + if (pos > 0) { + labels = substr(line, 1, pos - 1) + if (trim(labels) != "" && labels !~ /^[[:space:]]*\(/ && has_host(labels)) { + in_target = 1 + target_depth = brace_delta(line) + found = 1 + print line + next + } + } + } + depth += brace_delta(line) + } else { + target_depth += brace_delta(line) + print line + if (target_depth <= 0) { + in_target = 0 + } + } + } + + END { + if (!found) { + exit 1 + } + } + ' "$infile" > "$outfile" +} + +caddy_block_remove_for_host() { + local infile="$1" host="$2" outfile="$3" + awk -v host="$host" ' + function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s + } + function brace_delta(s, tmp, opens, closes) { + tmp = s + opens = gsub(/\{/, "{", tmp) + closes = gsub(/\}/, "}", tmp) + return opens - closes + } + function has_host(labels, i, n, parts, token) { + labels = trim(labels) + gsub(/[[:space:]]+/, "", labels) + n = split(labels, parts, ",") + for (i = 1; i <= n; i++) { + token = parts[i] + if (token == host) { + return 1 + } + } + return 0 + } + + BEGIN { + depth = 0 + in_target = 0 + target_depth = 0 + removed = 0 + } + + { + line = $0 + + if (in_target) { + target_depth += brace_delta(line) + if (target_depth <= 0) { + in_target = 0 + } + next + } + + if (depth == 0) { + pos = index(line, "{") + if (pos > 0) { + labels = substr(line, 1, pos - 1) + if (trim(labels) != "" && labels !~ /^[[:space:]]*\(/ && has_host(labels)) { + in_target = 1 + target_depth = brace_delta(line) + removed = 1 + next + } + } + } + + print line + depth += brace_delta(line) + } + + END { + if (!removed) { + exit 1 + } + } + ' "$infile" > "$outfile" +} + +upsert_site_block_by_host() { + local infile="$1" entry="$2" outfile="$3" + local host upstream streaming body_limit skip_verify + IFS='|' read -r host upstream streaming body_limit skip_verify <<< "$entry" + + local tmp_new_block tmp_old_block tmp_without_old tmp_combined + tmp_new_block=$(mktemp) + tmp_old_block=$(mktemp) + tmp_without_old=$(mktemp) + tmp_combined=$(mktemp) + + : > "$tmp_new_block" + emit_site_block_standalone "$tmp_new_block" "$host" "$upstream" "$streaming" "$body_limit" "$skip_verify" + + if caddy_block_extract_for_host "$infile" "$host" "$tmp_old_block"; then + log_info "Domain '${host}' already exists; replacing existing site block" + log_info "Previous block for '${host}':" + sed 's/^/ | /' "$tmp_old_block" >&2 + caddy_block_remove_for_host "$infile" "$host" "$tmp_without_old" + cat "$tmp_without_old" "$tmp_new_block" > "$tmp_combined" + else + log_info "Domain '${host}' not present; adding new site block" + cat "$infile" "$tmp_new_block" > "$tmp_combined" + fi + + mv "$tmp_combined" "$outfile" + rm -f "$tmp_new_block" "$tmp_old_block" "$tmp_without_old" +} + build_caddyfile() { local outfile="$1" local entry host upstream streaming body_limit skip_verify @@ -260,29 +426,6 @@ build_caddyfile() { done } -build_canary_fragment() { - local outfile="$1" - local entry host upstream streaming body_limit skip_verify - : > "$outfile" - { - echo - echo "${CANARY_BEGIN_MARKER}" - echo "# Managed by phase7_5_nginx_to_caddy.sh (canary additive mode)" - echo "# Existing routes above are preserved." - echo - } >> "$outfile" - - for entry in "${CANARY_HOST_MAP[@]}"; do - IFS='|' read -r host upstream streaming body_limit skip_verify <<< "$entry" - emit_site_block_standalone "$outfile" "$host" "$upstream" "$streaming" "$body_limit" "$skip_verify" - done - - { - echo "${CANARY_END_MARKER}" - echo - } >> "$outfile" -} - if ! validate_backend_tls_policy; then exit 1 fi @@ -340,15 +483,20 @@ if ssh_exec UNRAID "test -f '${CADDY_DATA_PATH}/Caddyfile'" 2>/dev/null; then fi if [[ "$MODE" == "canary" && "$HAS_EXISTING_CADDYFILE" == "true" ]]; then - TMP_EXISTING=$(mktemp) - TMP_CLEAN=$(mktemp) - TMP_FRAGMENT=$(mktemp) - ssh_exec UNRAID "cat '${CADDY_DATA_PATH}/Caddyfile'" > "$TMP_EXISTING" - sed "/^${CANARY_BEGIN_MARKER}\$/,/^${CANARY_END_MARKER}\$/d" "$TMP_EXISTING" > "$TMP_CLEAN" - build_canary_fragment "$TMP_FRAGMENT" - cat "$TMP_CLEAN" "$TMP_FRAGMENT" > "$TMP_CADDYFILE" - rm -f "$TMP_EXISTING" "$TMP_CLEAN" "$TMP_FRAGMENT" - log_info "Canary mode: preserved existing routes and updated canary block only" + TMP_WORK=$(mktemp) + TMP_NEXT=$(mktemp) + cp /dev/null "$TMP_NEXT" + ssh_exec UNRAID "cat '${CADDY_DATA_PATH}/Caddyfile'" > "$TMP_WORK" + + for entry in "${CANARY_HOST_MAP[@]}"; do + upsert_site_block_by_host "$TMP_WORK" "$entry" "$TMP_NEXT" + mv "$TMP_NEXT" "$TMP_WORK" + TMP_NEXT=$(mktemp) + done + + cp "$TMP_WORK" "$TMP_CADDYFILE" + rm -f "$TMP_WORK" "$TMP_NEXT" + log_info "Canary mode: existing routes preserved; canary domains upserted" else build_caddyfile "$TMP_CADDYFILE" fi