Files
electric-cherry/push_scripts.sh
T
josh d99711563e Initial commit — SvelteKit fleet manager for T5 Plutonium servers
Ports push_scripts.sh (1497 lines of bash) to a SvelteKit + SQLite web app
that manages a fleet of T5 Plutonium servers running on Komodo. Same push
and beta workflow as the shell script, with a live SSE pipeline view, a
!rt-token-gated auth modal that hard-pauses deployments on stale tokens,
per-file audit diffs, and a Komodo-container deployment target.

- Push/beta engines port the exact filter, held-by-beta, conflict, and
  stale-backup rules from the shell script
- SQLite (better-sqlite3) replaces script_metadata.json; a one-shot
  importer moves legacy state over on first boot
- 16 vitest tests cover push add/update/skip, beta open→rollback,
  open→promote, conflict guards, sweep frozen/manual branches, and IW4M
  login/status parsing
- docker/ + komodo/stack.toml deploy as a managed container on the same
  host as the game servers, /root/zombies bind-mounted at the same path
  the shell script used
2026-09-12 00:23:51 -04:00

1497 lines
53 KiB
Bash

#!/usr/bin/env bash
#
# push_scripts.sh - push shared GSC scripts out to every T5 server
#
# source: /root/zombies/shared/scripts
# staging: /root/zombies/shared/scripts/betas <- drop scripts to test here
# open: /root/zombies/shared/scripts/betas/<beta> <- one folder per open beta
# dest: /root/zombies/t5/<server>/runtime/plutonium/storage/t5/raw/scripts/sp
# metadata: script_metadata.json next to this script <- source of truth for betas
#
# The betas folder is never pushed to a server.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="${SRC:-/root/zombies/shared/scripts}"
SERVERS_ROOT="${SERVERS_ROOT:-/root/zombies/t5}"
REL_DEST="runtime/plutonium/storage/t5/raw/scripts/sp"
METADATA="${METADATA:-$SCRIPT_DIR/script_metadata.json}"
LEGACY_STATE_DIR="${STATE_DIR:-/root/zombies/.betas}"
BETA_SRC="$SRC/betas"
# integrations: read from env, or from push_scripts.env next to this script
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/push_scripts.env}"
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
KOMODO_ADDRESS="${KOMODO_ADDRESS:-}"
KOMODO_API_KEY="${KOMODO_API_KEY:-}"
KOMODO_API_SECRET="${KOMODO_API_SECRET:-}"
IW4M_ADDRESS="${IW4M_ADDRESS:-}"
IW4M_CLIENT_ID="${IW4M_CLIENT_ID:-}"
IW4M_PASSWORD="${IW4M_PASSWORD:-}"
HTTP_TIMEOUT="${HTTP_TIMEOUT:-10}"
DEPLOY_KEEP_DAYS="${DEPLOY_KEEP_DAYS:-7}"
DEPLOY_ACTION="push"
IW4M_STATUS_CACHE=""
IW4M_JAR=""
IW4M_ERR=""
CHANGED_SERVERS=()
MODE=push
DRY_RUN=0
DELETE=0
CREATE=0
BETA_NAME=""
# ------------------------------------------------------------------ style ---
if [[ -t 1 && "${NO_COLOR:-}" == "" ]]; then
B=$'\e[1m'; D=$'\e[2m'; R=$'\e[0m'
GN=$'\e[32m'; YL=$'\e[33m'
else
B=""; D=""; R=""; GN=""; YL=""
fi
WIDTH=66
rule() { printf '%s%s%s\n' "$D" "$(printf '─%.0s' $(seq 1 $WIDTH))" "$R"; }
hdr() {
local title="$1" note="${2:-}" pad
pad=$(( WIDTH - ${#title} - ${#note} - 1 ))
(( pad < 1 )) && pad=1
printf ' %s%s%s%*s%s%s%s\n' "$B" "$title" "$R" "$pad" "" "$D" "$note" "$R"
}
row() {
local col="$1" name="$2" note="${3:-}" pad
pad=$(( WIDTH - ${#name} - ${#note} - 3 ))
(( pad < 1 )) && pad=1
printf ' %s●%s %s%*s%s%s%s\n' "$col" "$R" "$name" "$pad" "" "$D" "$note" "$R"
}
field() { printf ' %s%-9s%s %s\n' "$D" "$1" "$R" "$2"; }
detail(){ printf ' %s%-8s%s %s\n' "$D" "$1" "$R" "$2"; }
warn() { printf ' %s! %s%s\n' "$YL" "$1" "$R"; }
note() { printf ' %s%s%s\n' "$D" "$1" "$R"; }
field_wrap() {
local label="$1" text="$2"
printf '%s\n' "$text" | fold -s -w $(( WIDTH - 12 )) \
| awk -v l="$label" -v d="$D" -v r="$R" \
'NR==1 {printf " %s%-9s%s %s\n", d, l, r, $0; next}
{printf " %s%-9s%s %s\n", d, "", r, $0}'
}
ago() {
local when now diff
when="$(date -d "$1" +%s 2>/dev/null || printf '0')"
[[ "$when" == "0" ]] && { printf '%s' "$1"; return; }
now="$(date +%s)"; diff=$(( now - when ))
if (( diff < 60 )); then printf 'just now'
elif (( diff < 3600 )); then printf '%dm ago' $(( diff / 60 ))
elif (( diff < 86400 )); then printf '%dh ago' $(( diff / 3600 ))
else printf '%dd ago' $(( diff / 86400 )); fi
}
when_str() {
local pretty
pretty="$(date -d "$1" '+%b %-d, %H:%M' 2>/dev/null || printf '%s' "$1")"
printf '%s (%s)' "$pretty" "$(ago "$1")"
}
plural() { [[ "$1" == "1" ]] && printf '%s' "$2" || printf '%ss' "$2"; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# ------------------------------------------------------------------- args ---
usage() {
cat <<EOF
push_scripts.sh - push shared GSC scripts out to every T5 server
source: $SRC
staging: $BETA_SRC
dest: $SERVERS_ROOT/<server>/$REL_DEST
metadata: $METADATA
Usage: push_scripts.sh [options] [server ...]
-n, --dry-run show what would change, change nothing
-d, --delete delete files at the dest that no longer exist in source
-c, --create create the sp directory on servers that don't have one
-h, --help this
Betas:
Drop the scripts you want to test straight into $BETA_SRC,
then open a beta. Only those staged files go into it - the rest of the shared
folder keeps pushing to everyone as normal, and betas/ itself is never pushed.
-b, --beta NAME [server ...]
open a beta. Each staged script lands on the named servers as
script-beta.gsc, the live copy is parked as script.OLD, and the staged
files move to betas/<name>/ so the staging folder is free for the next one.
-ub, --update-beta NAME
push a new take on scripts already in an open beta. Stage the new file in
betas/, run this, and it replaces the copy in betas/<name>/ and on every
server in that beta. Backups and beta membership are left alone.
-r, --rollback NAME undo a beta: delete its scripts from the servers, restore
every .OLD, and delete betas/<name>/
-pb, --promote-beta NAME
keep a beta: clear the beta files and .OLD backups off the servers, move the
scripts back into the shared folder under their real names, delete
betas/<name>/, then push to every server
-lb, --list-betas show what is staged and every open beta
Restarts and deployments:
After anything lands on a server, that server is checked against IW4MAdmin.
Empty servers are restarted through Komodo straight away; servers with players
on them are left alone and queued, so they can be picked up later.
-ad, --active-deployments
re-check everything still queued, restart whatever is empty now, then
list what is still waiting. Good as a cron job.
-ld, --list-deployments show every deployment from the last $DEPLOY_KEEP_DAYS days
Credentials come from the environment or from $ENV_FILE:
KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET
IW4M_ADDRESS, and IW4M_CLIENT_ID + IW4M_PASSWORD if your webfront
requires a login to read /api/status
Without them the pushes still work, they just aren't checked or restarted.
Only one beta may own a given script on a given server at a time.
Open betas are tracked in $METADATA.
Server args match folder names; globs and comma lists both work:
push_scripts.sh # push to every server
push_scripts.sh -n # dry run
push_scripts.sh 't5_cotd_*' # one group
push_scripts.sh -b "New HUD Test" "t5_kino_1, t5_ascension_1"
push_scripts.sh -b "Plugin Test" # no servers = every server
push_scripts.sh -ub "New HUD Test"
push_scripts.sh -lb
push_scripts.sh -r "New HUD Test"
push_scripts.sh -pb "New HUD Test"
push_scripts.sh -ad
push_scripts.sh -ld
EOF
exit "${1:-0}"
}
need_name() {
if [[ -z "${1:-}" || "${1:0:1}" == "-" ]]; then
echo "$2 needs a beta name, e.g. $2 \"New HUD Test\"" >&2
exit 1
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=1 ;;
-d|--delete) DELETE=1 ;;
-c|--create) CREATE=1 ;;
-b|--beta) need_name "${2:-}" "$1"; MODE=beta; BETA_NAME="$2"; shift ;;
-ub|-up|--update-beta) need_name "${2:-}" "$1"; MODE=update; BETA_NAME="$2"; shift ;;
-r|--rollback) need_name "${2:-}" "$1"; MODE=rollback; BETA_NAME="$2"; shift ;;
-pb|--promote-beta) need_name "${2:-}" "$1"; MODE=promote; BETA_NAME="$2"; shift ;;
-lb|--list-betas) MODE=list ;;
-ad|--active-deployments) MODE=active ;;
-ld|--list-deployments) MODE=deployments ;;
-h|--help) usage 0 ;;
-*) echo "unknown option: $1" >&2; usage 1 ;;
*) break ;;
esac
shift
done
PATTERNS=()
for arg in "$@"; do
IFS=',' read -r -a _parts <<<"$arg"
for p in "${_parts[@]}"; do
p="$(printf '%s' "$p" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
if [[ -n "$p" ]]; then PATTERNS+=("$p"); fi
done
done
if [[ ${#PATTERNS[@]} -eq 0 ]]; then PATTERNS=("*"); fi
if [[ "$MODE" != list ]]; then
[[ -d "$SERVERS_ROOT" ]] || { echo "servers dir not found: $SERVERS_ROOT" >&2; exit 1; }
fi
[[ -d "$SRC" ]] || { echo "source dir not found: $SRC" >&2; exit 1; }
# --------------------------------------------------------------- metadata ---
require_jq() {
command -v jq >/dev/null 2>&1 && return 0
echo "jq is needed to read $METADATA" >&2
echo "install it with: apt-get install -y jq" >&2
exit 1
}
meta_exists() { [[ -f "$METADATA" ]]; }
meta_q() { require_jq; jq -r "$@" "$METADATA"; }
meta_apply() {
require_jq
local tmp
tmp="$(mktemp "${METADATA}.XXXXXX")"
if jq "$@" "$METADATA" >"$tmp"; then
mv -f "$tmp" "$METADATA"
else
rm -f "$tmp"
echo "could not update $METADATA" >&2
exit 1
fi
}
meta_touch() {
meta_apply --arg now "$(now_iso)" --arg src "$SRC" --arg stg "$BETA_SRC" \
--arg root "$SERVERS_ROOT" --arg rel "$REL_DEST" \
'.updated = $now
| .paths = {shared_scripts:$src, staging:$stg,
servers_root:$root, server_script_path:$rel}'
}
# build the file, importing any legacy .beta manifests on first run
meta_init() {
if meta_exists; then
if ! meta_q 'has("servers") and has("deployments")' | grep -qx true; then
meta_apply '.servers //= {} | .deployments //= []'
fi
return 0
fi
require_jq
jq -n --arg now "$(now_iso)" --arg src "$SRC" --arg stg "$BETA_SRC" \
--arg root "$SERVERS_ROOT" --arg rel "$REL_DEST" \
'{schema:1, generated_by:"push_scripts.sh", updated:$now,
paths:{shared_scripts:$src, staging:$stg,
servers_root:$root, server_script_path:$rel},
betas:{}, servers:{}, deployments:[]}' >"$METADATA"
migrate_legacy
}
migrate_legacy() {
local f slug name created base rel sha server had n=0
[[ -d "$LEGACY_STATE_DIR" ]] || return 0
for f in "$LEGACY_STATE_DIR"/*.beta; do
[[ -e "$f" ]] || continue
slug="$(basename "$f" .beta)"
name="$(awk -F'\t' '$1=="name"{print $2; exit}' "$f")"
created="$(awk -F'\t' '$1=="created"{print $2; exit}' "$f")"
beta_create "$slug" "$name" "$created"
while IFS=$'\t' read -r _ base rel sha; do
beta_add_script "$slug" "$base" "$rel" "$sha"
done < <(awk -F'\t' '$1=="staged"' "$f")
while IFS=$'\t' read -r _ server rel had; do
beta_add_deploy "$slug" "$rel" "$server" "$had"
done < <(awk -F'\t' '$1=="file"' "$f")
beta_sync_servers "$slug"
mv "$f" "$f.migrated"
n=$((n + 1))
done
if [[ $n -gt 0 ]]; then
echo
note "imported $n open $(plural "$n" beta) from $LEGACY_STATE_DIR into $METADATA"
note "the old manifests were renamed to *.beta.migrated"
fi
}
beta_create() {
meta_apply --arg slug "$1" --arg name "$2" --arg opened "$3" --arg folder "betas/$1" \
'.betas[$slug] = {name:$name, folder:$folder, opened:$opened,
updated:$opened, revision:1, servers:[], scripts:[]}'
}
beta_add_script() { # slug staged_as target sha
meta_apply --arg slug "$1" --arg staged "$2" --arg target "$3" --arg sha "$4" \
--arg bfile "$(beta_file "$3")" --arg backup "$(old_file "$3")" \
'.betas[$slug].scripts += [{staged_as:$staged, target:$target,
beta_file:$bfile, backup:$backup,
sha256:$sha, revision:1, deployed:[]}]'
}
beta_add_deploy() { # slug target server had_original(0|1)
local had=false
[[ "$4" == "1" || "$4" == "true" ]] && had=true
meta_apply --arg slug "$1" --arg target "$2" --arg srv "$3" --argjson had "$had" \
'(.betas[$slug].scripts[] | select(.target==$target) | .deployed)
+= [{server:$srv, had_original:$had}]'
}
beta_sync_servers() {
meta_apply --arg slug "$1" \
'.betas[$slug].servers = ([.betas[$slug].scripts[].deployed[].server] | unique)'
}
beta_bump_script() { # slug target sha
meta_apply --arg slug "$1" --arg target "$2" --arg sha "$3" --arg now "$(now_iso)" \
'(.betas[$slug].scripts[] | select(.target==$target) | .sha256) = $sha
| (.betas[$slug].scripts[] | select(.target==$target) | .revision) += 1
| .betas[$slug].revision += 1
| .betas[$slug].updated = $now'
}
beta_drop() { meta_apply --arg slug "$1" 'del(.betas[$slug])'; }
beta_slugs() { meta_exists || return 0; meta_q '.betas | keys[]'; }
beta_has() { meta_exists || return 1; meta_q --arg s "$1" '.betas | has($s)' | grep -qx true; }
beta_field() { meta_q --arg s "$1" --arg k "$2" '.betas[$s][$k] // ""'; }
beta_servers() { meta_q --arg s "$1" '.betas[$s].servers[]?'; }
# staged_as <TAB> target <TAB> beta_file <TAB> backup <TAB> revision
beta_scripts() {
meta_q --arg s "$1" \
'.betas[$s].scripts[]? | [.staged_as,.target,.beta_file,.backup,(.revision|tostring)] | @tsv'
}
# server <TAB> target <TAB> beta_file <TAB> backup <TAB> had_original
beta_deploys() {
meta_q --arg s "$1" \
'.betas[$s].scripts[]? as $x | $x.deployed[]?
| [.server,$x.target,$x.beta_file,$x.backup,(.had_original|tostring)] | @tsv'
}
owner_of() { # server target -> slug of the beta holding it
local out
meta_exists || return 1
out="$(meta_q --arg srv "$1" --arg t "$2" \
'[.betas | to_entries[]
| select(any(.value.scripts[]?;
.target==$t and any(.deployed[]?; .server==$srv)))
| .key][0] // empty')"
[[ -n "$out" ]] || return 1
printf '%s\n' "$out"
}
held_by_beta() { # server -> target <TAB> slug
meta_exists || return 0
meta_q --arg srv "$1" \
'.betas | to_entries[] as $b | $b.value.scripts[]?
| select(any(.deployed[]?; .server==$srv))
| "\(.target)\t\($b.key)"'
}
resolve_beta() {
local slug
slug="$(slugify "$BETA_NAME")"
if ! beta_has "$slug"; then
echo "no open beta called '$BETA_NAME' in $METADATA" >&2
echo "run 'push_scripts.sh -lb' to see what's open" >&2
exit 1
fi
printf '%s\n' "$slug"
}
# ---------------------------------------------------------------- helpers ---
slugify() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//; s/-$//'
}
dest_of() { printf '%s/%s/%s\n' "$SERVERS_ROOT" "$1" "$REL_DEST"; }
join_list() { sort -u | awk 'NR==1{printf "%s",$0;next}{printf ", %s",$0} END{if(NR)print ""}'; }
sha_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
printf '%s\n' '-'
fi
}
list_source_files() {
( cd "$SRC" && find . -path './betas' -prune -o -type f \
! -path './.git/*' ! -name '*.swp' ! -name '*~' \
! -name '*.OLD' ! -name '*-beta.*' -printf '%P\n' | sort )
}
staged_files() {
[[ -d "$BETA_SRC" ]] || return 0
( cd "$BETA_SRC" && find . -maxdepth 1 -type f \
! -name '*.swp' ! -name '*~' ! -name '.*' -printf '%P\n' | sort )
}
find_targets() {
( cd "$SRC" && find . -path './betas' -prune -o -type f -name "$1" -printf '%P\n' | sort )
}
all_servers() {
local dir
for dir in "$SERVERS_ROOT"/*/; do
[[ -d "$dir" ]] || continue
basename "$dir"
done
}
match_servers() {
local dir server p
for dir in "$SERVERS_ROOT"/*/; do
[[ -d "$dir" ]] || continue
server="$(basename "$dir")"
for p in "${PATTERNS[@]}"; do
# shellcheck disable=SC2053
if [[ "$server" == $p ]]; then printf '%s\n' "$server"; break; fi
done
done
}
beta_file() {
local rel="$1" dir base stem ext=""
dir="$(dirname "$rel")"; base="$(basename "$rel")"
if [[ "$base" == *.* ]]; then stem="${base%.*}"; ext=".${base##*.}"; else stem="$base"; fi
if [[ "$dir" == "." ]]; then printf '%s-beta%s\n' "$stem" "$ext"
else printf '%s/%s-beta%s\n' "$dir" "$stem" "$ext"; fi
}
old_file() {
local rel="$1" dir base stem
dir="$(dirname "$rel")"; base="$(basename "$rel")"
if [[ "$base" == *.* ]]; then stem="${base%.*}"; else stem="$base"; fi
if [[ "$dir" == "." ]]; then printf '%s.OLD\n' "$stem"
else printf '%s/%s.OLD\n' "$dir" "$stem"; fi
}
ensure_dest() {
local server="$1" dest
dest="$(dest_of "$server")"
if [[ ! -d "$dest" ]]; then
if [[ $CREATE -eq 1 && $DRY_RUN -eq 0 ]]; then
mkdir -p "$dest"
else
row "$YL" "$server" "no sp folder - use -c"
return 1
fi
fi
return 0
}
# ----------------------------------------------------------- integrations ---
have_komodo() { [[ -n "$KOMODO_ADDRESS" && -n "$KOMODO_API_KEY" && -n "$KOMODO_API_SECRET" ]]; }
have_iw4m() { [[ -n "$IW4M_ADDRESS" ]]; }
integrations_on() { have_komodo && have_iw4m; }
# body, then the http status code on the last line
komodo_call() { # path json-body
curl -sS -m "$HTTP_TIMEOUT" -X POST "${KOMODO_ADDRESS%/}/$1" \
-H 'Content-Type: application/json' \
-H "X-Api-Key: $KOMODO_API_KEY" \
-H "X-Api-Secret: $KOMODO_API_SECRET" \
-w '\n%{http_code}' \
-d "$2" 2>/dev/null
}
# POST /api/client/<id>/login, keep the session cookie for the rest of the run
iw4m_login() {
local jar code
if [[ -z "$IW4M_CLIENT_ID" || -z "$IW4M_PASSWORD" ]]; then
IW4M_ERR="iw4madmin needs a login"
return 1
fi
[[ -n "$IW4M_JAR" ]] && return 0
jar="$(mktemp -t push_scripts_iw4m.XXXXXX)" || return 1
chmod 600 "$jar"
code="$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \
-c "$jar" -X POST \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg p "$IW4M_PASSWORD" '{password:$p}')" \
"${IW4M_ADDRESS%/}/api/client/${IW4M_CLIENT_ID}/login" 2>/dev/null)" || {
rm -f "$jar"; IW4M_ERR="iw4madmin unreachable"; return 1; }
if [[ "$code" != 2* ]]; then
rm -f "$jar"
if [[ "$code" == "401" ]]; then
IW4M_ERR="iw4madmin login rejected"
else
IW4M_ERR="iw4madmin login failed ($code)"
fi
return 1
fi
IW4M_JAR="$jar"
return 0
}
iw4m_logout() {
[[ -n "$IW4M_JAR" ]] || return 0
curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -b "$IW4M_JAR" -X POST \
"${IW4M_ADDRESS%/}/api/client/${IW4M_CLIENT_ID}/logout" >/dev/null 2>&1 || true
rm -f "$IW4M_JAR"
IW4M_JAR=""
}
trap iw4m_logout EXIT
iw4m_fetch_status() {
local out
if [[ -n "$IW4M_JAR" ]]; then
out="$(curl -sS -m "$HTTP_TIMEOUT" -b "$IW4M_JAR" \
"${IW4M_ADDRESS%/}/api/status" 2>/dev/null)" || return 1
else
out="$(curl -sS -m "$HTTP_TIMEOUT" "${IW4M_ADDRESS%/}/api/status" 2>/dev/null)" || return 1
fi
jq -e 'type=="array"' >/dev/null 2>&1 <<<"$out" || return 1
IW4M_STATUS_CACHE="$out"
return 0
}
# pull /api/status once per run and keep it in memory. Most webfronts serve it
# without a login, so only sign in if the anonymous read doesn't work - that
# also avoids burning a one-shot !rt token when it isn't needed.
iw4m_load() {
have_iw4m || return 1
[[ -n "$IW4M_STATUS_CACHE" ]] && return 0
IW4M_ERR=""
if iw4m_fetch_status; then return 0; fi
IW4M_ERR="iw4madmin unreachable"
iw4m_login || return 1
IW4M_ERR=""
iw4m_fetch_status || { IW4M_ERR="iw4madmin unreachable"; return 1; }
return 0
}
server_reg_seed() {
local server
while read -r server; do
[[ -n "$server" ]] || continue
if meta_q --arg s "$server" '.servers | has($s)' | grep -qx true; then continue; fi
meta_apply --arg s "$server" --arg k "$(printf '%s' "$server" | tr '_' '-')" \
'.servers[$s] = {komodo_type:"deployment", komodo_name:$k,
komodo_server:"", iw4m_match:"", iw4m_value:""}'
done < <(all_servers)
}
server_reg() { meta_q --arg s "$1" --arg k "$2" '.servers[$s][$k] // ""'; }
server_reg_set() { meta_apply --arg s "$1" --arg k "$2" --arg v "$3" '.servers[$s][$k] = $v'; }
# how many players are on this server right now, blank if we can't tell
iw4m_players() {
local server="$1" match value out
[[ -n "$IW4M_STATUS_CACHE" ]] || return 1
match="$(server_reg "$server" iw4m_match)"
value="$(server_reg "$server" iw4m_value)"
if [[ -z "$match" || -z "$value" ]]; then
iw4m_autolink "$server" >/dev/null || return 1
match="$(server_reg "$server" iw4m_match)"
value="$(server_reg "$server" iw4m_value)"
fi
out="$(jq -r --arg m "$match" --arg v "$value" '
def norm: ascii_downcase | gsub("[^a-z0-9]";"");
[ .[] | {id:(.id // .Id), port:(.port // .Port),
name:((.name // .Name) // ""),
players:(.currentPlayers // .CurrentPlayers)} ]
| map(select(
if $m=="port" then ((.port|tostring)==$v)
elif $m=="id" then ((.id|tostring)==$v)
else ((.name|norm)==($v|norm)) end))
| if length==1 then (.[0].players|tostring) else empty end
' <<<"$IW4M_STATUS_CACHE" 2>/dev/null)" || return 1
[[ -n "$out" && "$out" != "null" ]] || return 1
printf '%s\n' "$out"
}
# first run: try to work out which IW4MAdmin server a folder is, and remember it
iw4m_autolink() {
local server="$1" token hit
token="$(printf '%s' "$server" | tr -cs 'a-zA-Z0-9' ' ' \
| awk '{for(i=1;i<=NF;i++) if ($i !~ /^(t4|t5|t6|iw5|iw4|[0-9]+)$/) print $i}' \
| head -1 | tr 'A-Z' 'a-z')"
[[ -n "$token" ]] || return 1
hit="$(jq -r --arg t "$token" '
def norm: ascii_downcase | gsub("[^a-z0-9]";"");
[ .[] | select((((.name // .Name) // "") | norm) | contains($t)) | (.port // .Port) ]
| if length==1 then (.[0]|tostring) else empty end' \
<<<"$IW4M_STATUS_CACHE" 2>/dev/null)" || return 1
[[ -n "$hit" ]] || return 1
server_reg_set "$server" iw4m_match port
server_reg_set "$server" iw4m_value "$hit"
printf '%s\n' "$hit"
}
komodo_restart() {
local server="$1" ktype name ksrv path body resp code
ktype="$(server_reg "$server" komodo_type)"; [[ -n "$ktype" ]] || ktype=deployment
name="$(server_reg "$server" komodo_name)"
[[ -n "$name" ]] || return 2
case "$ktype" in
stack)
path="execute/RestartStack"
body="$(jq -nc --arg n "$name" '{stack:$n}')" ;;
container)
ksrv="$(server_reg "$server" komodo_server)"
[[ -n "$ksrv" ]] || return 2
path="execute/RestartContainer"
body="$(jq -nc --arg n "$name" --arg s "$ksrv" '{server:$s, container:$n}')" ;;
*)
path="execute/RestartDeployment"
body="$(jq -nc --arg n "$name" '{deployment:$n}')" ;;
esac
resp="$(komodo_call "$path" "$body")" || return 1
[[ -n "$resp" ]] || return 1
code="$(tail -n1 <<<"$resp")"
resp="$(sed '$d' <<<"$resp")"
[[ "$code" == 2* ]] || return 1
# note: jq's // treats false as empty, so test for the key explicitly
jq -e 'if type=="object" and has("success") then .success == true else true end' \
>/dev/null 2>&1 <<<"$resp" || return 1
return 0
}
# ----------------------------------------------------------- deployments ---
deploy_open() { # action (reads CHANGED_SERVERS), prints the new id
local action="$1" id server files state="pending"
id="dep-$(date -u +%Y%m%dT%H%M%SZ)-$$"
integrations_on || state="manual"
meta_apply --arg id "$id" --arg a "$action" --arg now "$(now_iso)" \
'.deployments += [{id:$id, action:$a, started:$now, servers:[]}]'
for entry in "${CHANGED_SERVERS[@]}"; do
IFS=$'\t' read -r server files <<<"$entry"
[[ -n "$server" ]] || continue
meta_apply --arg id "$id" --arg s "$server" --argjson f "${files:-0}" --arg st "$state" \
'(.deployments[] | select(.id==$id) | .servers) += [{
server:$s, files:$f, restart:$st, players:null,
checked:null, restarted:null, attempts:0, error:null}]'
done
printf '%s\n' "$id"
}
deploy_set() { # id server restart players error
meta_apply --arg id "$1" --arg s "$2" --arg r "$3" --arg p "$4" --arg e "$5" \
--arg now "$(now_iso)" \
'(.deployments[] | select(.id==$id) | .servers[] | select(.server==$s)) |= (
.restart = $r
| .checked = $now
| .players = (if $p=="" then null else ($p|tonumber) end)
| .error = (if $e=="" then null else $e end)
| .attempts = (.attempts + 1)
| (if $r=="done" then .restarted = $now else . end))'
}
deploy_prune() {
local cutoff
cutoff="$(date -u -d "$DEPLOY_KEEP_DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf '')"
[[ -n "$cutoff" ]] || return 0
meta_apply --arg cutoff "$cutoff" \
'.deployments = [ .deployments[]
| select((.started >= $cutoff)
or any(.servers[]; .restart=="pending" or .restart=="failed")) ]'
}
# id <TAB> server for every server still waiting on a restart
deploy_pending() {
local filter="${1:-}"
meta_exists || return 0
meta_q --arg id "$filter" \
'.deployments[]
| select($id=="" or .id==$id) as $d
| $d.servers[]
| select(.restart=="pending" or .restart=="failed")
| "\($d.id)\t\(.server)"'
}
# check each waiting server, restart the empty ones
deploy_sweep() {
local filter="${1:-}" line id server players rc
local checked=0 restarted=0 held=0 failed=0
local -a pending=() unmatched=()
declare -A SEEN_PLAYERS SEEN_RESULT
mapfile -t pending < <(deploy_pending "$filter")
[[ ${#pending[@]} -gt 0 ]] || return 0
if ! integrations_on; then
echo
hdr "RESTARTS" "not configured"
rule
note "set KOMODO_ADDRESS / KOMODO_API_KEY / KOMODO_API_SECRET / IW4M_ADDRESS"
note "in $ENV_FILE to let this restart containers for you"
rule
echo
return 0
fi
iw4m_load || true
echo
hdr "RESTARTS" "$(printf '%s\n' "${pending[@]}" | cut -f2 | sort -u | grep -c .) $(plural "$(printf '%s\n' "${pending[@]}" | cut -f2 | sort -u | grep -c .)" server)"
rule
for line in "${pending[@]}"; do
IFS=$'\t' read -r id server <<<"$line"
[[ -n "$server" ]] || continue
if [[ -n "${SEEN_RESULT[$server]:-}" ]]; then
# already handled this server in another deployment this run
deploy_set "$id" "$server" "${SEEN_RESULT[$server]}" "${SEEN_PLAYERS[$server]:-}" ""
continue
fi
checked=$((checked + 1))
if [[ -z "$IW4M_STATUS_CACHE" ]]; then
deploy_set "$id" "$server" "pending" "" "${IW4M_ERR:-iw4madmin unreachable}"
SEEN_RESULT[$server]="pending"
row "$YL" "$server" "${IW4M_ERR:-iw4madmin unreachable} - held"
held=$((held + 1)); continue
fi
if ! players="$(iw4m_players "$server")"; then
deploy_set "$id" "$server" "pending" "" "no iw4madmin match"
SEEN_RESULT[$server]="pending"
unmatched+=("$server")
row "$YL" "$server" "no iw4madmin match - held"
held=$((held + 1)); continue
fi
SEEN_PLAYERS[$server]="$players"
if [[ "$players" -gt 0 ]]; then
deploy_set "$id" "$server" "pending" "$players" ""
SEEN_RESULT[$server]="pending"
row "$D" "$server" "$players $(plural "$players" player) on - held"
held=$((held + 1)); continue
fi
rc=0; komodo_restart "$server" || rc=$?
case "$rc" in
0) deploy_set "$id" "$server" "done" "0" ""
SEEN_RESULT[$server]="done"
row "$GN" "$server" "empty - restarted"
restarted=$((restarted + 1)) ;;
2) deploy_set "$id" "$server" "failed" "0" "no komodo name"
SEEN_RESULT[$server]="failed"
row "$YL" "$server" "no komodo name - skipped"
failed=$((failed + 1)) ;;
*) deploy_set "$id" "$server" "failed" "0" "komodo restart failed"
SEEN_RESULT[$server]="failed"
row "$YL" "$server" "komodo restart failed"
failed=$((failed + 1)) ;;
esac
done
rule
note "$restarted restarted · $held held · $failed failed"
[[ $((held + failed)) -gt 0 ]] && note "check again later with: push_scripts.sh -ad"
if [[ "$IW4M_ERR" == *login* ]]; then
echo
note "set IW4M_CLIENT_ID and IW4M_PASSWORD in $ENV_FILE"
note "an in-game !rt token works but dies after 2 minutes and only once -"
note "use !setpassword in game for something that keeps working"
fi
if [[ ${#unmatched[@]} -gt 0 && -n "$IW4M_STATUS_CACHE" ]]; then
echo
note "couldn't work out which IW4MAdmin server these are:"
note " $(printf '%s\n' "${unmatched[@]}" | sort -u | join_list)"
note "set iw4m_match/iw4m_value for them in $METADATA using:"
jq -r '.[] | " port \((.port // .Port)) \((.name // .Name))"' \
<<<"$IW4M_STATUS_CACHE" 2>/dev/null | while IFS= read -r l; do
printf ' %s%s%s\n' "$D" "$l" "$R"
done
fi
echo
}
deploy_finish() { # action
local id
[[ $DRY_RUN -eq 0 ]] || return 0
[[ ${#CHANGED_SERVERS[@]} -gt 0 ]] || return 0
meta_init
server_reg_seed
id="$(deploy_open "$1")"
deploy_prune
if integrations_on; then
# sweep everything queued, not just this deployment: one restart makes
# every change waiting on that server live
deploy_sweep ""
else
note "restart checks are off - no KOMODO_ADDRESS / IW4M_ADDRESS in $ENV_FILE"
echo
fi
}
deploy_rows() { # all deployments, newest first
meta_exists || return 0
meta_q '[.deployments[]] | sort_by(.started) | reverse | .[]
| [ .id, .action, .started,
([.servers[] | select(.restart=="pending" or .restart=="failed")] | length),
(.servers | length) ] | @tsv'
}
deploy_servers_by_state() { # id state
meta_q --arg id "$1" --arg st "$2" \
'.deployments[] | select(.id==$id) | .servers[] | select(.restart==$st)
| if .players != null and .players > 0
then "\(.server) (\(.players)p)" else .server end'
}
do_list_deployments() {
local mode="${1:-all}" id action started npend ntotal line shown=0 active=0
meta_init
deploy_prune
local total
total="$(deploy_rows | grep -c . || true)"
active="$(deploy_rows | awk -F'\t' '$4>0' | grep -c . || true)"
echo
if [[ "$mode" == "active" ]]; then
hdr "ACTIVE DEPLOYMENTS" "$active of $total open"
else
hdr "DEPLOYMENTS" "last $DEPLOY_KEEP_DAYS days · $active active"
fi
rule
while IFS=$'\t' read -r id action started npend ntotal; do
[[ -n "$id" ]] || continue
if [[ "$mode" == "active" && "$npend" -eq 0 ]]; then continue; fi
shown=$((shown + 1))
if [[ "$npend" -gt 0 ]]; then
row "$YL" "$action" "$(when_str "$started")"
else
row "$GN" "$action" "$(when_str "$started")"
fi
line="$(deploy_servers_by_state "$id" done | join_list)"
[[ -n "$line" ]] && field_wrap "restarted" "$line"
line="$(deploy_servers_by_state "$id" pending | join_list)"
[[ -n "$line" ]] && field_wrap "waiting" "$line"
line="$(deploy_servers_by_state "$id" failed | join_list)"
[[ -n "$line" ]] && field_wrap "failed" "$line"
line="$(deploy_servers_by_state "$id" manual | join_list)"
[[ -n "$line" ]] && field_wrap "no check" "$line"
printf ' %s%-9s%s %s\n' "$D" "id" "$R" "${D}$id${R}"
echo
done < <(deploy_rows)
if [[ $shown -eq 0 ]]; then
if [[ "$mode" == "active" ]]; then note "nothing waiting on a restart"
else note "no deployments recorded yet"; fi
rule
echo
return 0
fi
rule
note "restart sweep: push_scripts.sh -ad"
echo
}
do_active_deployments() {
meta_init
if [[ $DRY_RUN -eq 0 ]]; then deploy_sweep ""; fi
do_list_deployments active
}
# ------------------------------------------------------------------- push ---
do_push() {
local total_servers=0 total_changed=0 total_files=0
local server dest out changes rel line slug verb path
local -a held excl rs_opts
declare -A HELD_SET
echo
if [[ $DRY_RUN -eq 1 ]]; then hdr "PUSH (dry run)" "$SRC"; else hdr "PUSH" "$SRC"; fi
rule
while read -r server; do
ensure_dest "$server" || continue
dest="$(dest_of "$server")"
total_servers=$((total_servers + 1))
held=(); excl=(); HELD_SET=()
mapfile -t held < <(held_by_beta "$server")
for line in ${held[@]+"${held[@]}"}; do
[[ -n "$line" ]] || continue
IFS=$'\t' read -r rel slug <<<"$line"
HELD_SET["$rel"]="$slug"
excl+=("--exclude=/$rel")
done
changes=0
if command -v rsync >/dev/null 2>&1; then
rs_opts=(-a --checksum --itemize-changes
--exclude '/betas/' --exclude '.git' --exclude '*.swp'
--exclude '*~' --exclude '*.OLD' --exclude '*-beta.*')
if [[ $DRY_RUN -eq 1 ]]; then rs_opts+=(--dry-run); fi
if [[ $DELETE -eq 1 ]]; then rs_opts+=(--delete); fi
out="$(rsync "${rs_opts[@]}" ${excl[@]+"${excl[@]}"} "$SRC"/ "$dest"/)"
out="$(printf '%s\n' "$out" | awk '
/^\*deleting/ { p=$0; sub(/^[^ ]+[[:space:]]+/,"",p); print "removed\t" p; next }
/^>f/ { p=$0; sub(/^[^ ]+[[:space:]]+/,"",p)
if ($1 ~ /\+\+\+\+\+\+\+/) print "added\t" p; else print "updated\t" p }
')"
changes="$(printf '%s' "$out" | grep -c . || true)"
else
out=""
while read -r rel; do
[[ -n "${HELD_SET[$rel]:-}" ]] && continue
if [[ ! -f "$dest/$rel" ]]; then verb=added
elif ! cmp -s "$SRC/$rel" "$dest/$rel"; then verb=updated
else continue; fi
if [[ $DRY_RUN -eq 0 ]]; then
mkdir -p "$dest/$(dirname "$rel")"
cp -p "$SRC/$rel" "$dest/$rel"
fi
out+="$verb"$'\t'"$rel"$'\n'
done < <(list_source_files)
changes="$(printf '%s' "$out" | grep -c . || true)"
fi
if [[ "$changes" -gt 0 ]]; then
CHANGED_SERVERS+=("$server"$'\t'"$changes")
row "$GN" "$server" "$changes $(plural "$changes" file)"
while IFS=$'\t' read -r verb path; do
[[ -n "$verb" ]] || continue
detail "$verb" "$path"
done <<<"$out"
total_changed=$((total_changed + 1))
total_files=$((total_files + changes))
else
row "$D" "$server" "up to date"
fi
if [[ ${#HELD_SET[@]} -gt 0 ]]; then
for rel in "${!HELD_SET[@]}"; do
detail "held" "$(printf '%-28s' "$rel")${D}beta: ${HELD_SET[$rel]}${R}"
done
fi
done < <(match_servers)
rule
if [[ $DRY_RUN -eq 1 ]]; then
note "would change $total_changed of $total_servers $(plural "$total_servers" server) · $total_files $(plural "$total_files" file)"
else
note "$total_changed of $total_servers $(plural "$total_servers" server) updated · $total_files $(plural "$total_files" file)"
fi
echo
deploy_finish "$DEPLOY_ACTION"
}
# ------------------------------------------------------------------- beta ---
do_beta() {
local slug beta_dir server dest base rel had owner bfile ofile entry
local -a staged=() hits=() targets=() servers=() conflicts=() blocked=() ambiguous=()
slug="$(slugify "$BETA_NAME")"
[[ -n "$slug" ]] || { echo "beta name needs at least one letter or number" >&2; exit 1; }
beta_dir="$BETA_SRC/$slug"
meta_init
if beta_has "$slug"; then
echo "a beta called '$(beta_field "$slug" name)' is already open" >&2
echo "update it (-ub), roll it back (-r) or promote it (-pb) first" >&2
exit 1
fi
if [[ -e "$beta_dir" ]]; then
echo "$beta_dir already exists but no beta is open for it" >&2
echo "move or delete that folder first" >&2
exit 1
fi
if [[ ! -d "$BETA_SRC" ]]; then
echo "no staging folder at $BETA_SRC" >&2
echo "create it and drop the scripts you want to test inside" >&2
exit 1
fi
mapfile -t staged < <(staged_files)
if [[ ${#staged[@]} -eq 0 ]]; then
echo "nothing staged in $BETA_SRC - drop the scripts you want to test in there first" >&2
exit 1
fi
for base in "${staged[@]}"; do
mapfile -t hits < <(find_targets "$base")
case ${#hits[@]} in
0) targets+=("$base"$'\t'"$base") ;;
1) targets+=("$base"$'\t'"${hits[0]}") ;;
*) ambiguous+=("$base"$'\t'"$(printf '%s\n' "${hits[@]}" | join_list)") ;;
esac
done
if [[ ${#ambiguous[@]} -gt 0 ]]; then
echo "more than one script in the shared folder has these names, so I can't tell" >&2
echo "which one the staged copy is meant to replace:" >&2
for entry in "${ambiguous[@]}"; do
IFS=$'\t' read -r base rel <<<"$entry"
printf ' %-26s %s\n' "$base" "$rel" >&2
done
echo "nothing was changed" >&2
exit 1
fi
mapfile -t servers < <(match_servers)
if [[ ${#servers[@]} -eq 0 ]]; then
echo "no servers matched" >&2
exit 1
fi
local -a plan=()
for server in "${servers[@]}"; do
ensure_dest "$server" || continue
dest="$(dest_of "$server")"
for entry in "${targets[@]}"; do
IFS=$'\t' read -r base rel <<<"$entry"
if owner="$(owner_of "$server" "$rel")"; then
conflicts+=("$server"$'\t'"$rel"$'\t'"$owner")
continue
fi
had=0
if [[ -f "$dest/$rel" ]]; then had=1; fi
ofile="$(old_file "$rel")"
if [[ $had -eq 1 && -e "$dest/$ofile" ]]; then
blocked+=("$server"$'\t'"$ofile")
continue
fi
plan+=("$server"$'\t'"$base"$'\t'"$rel"$'\t'"$had")
done
done
if [[ ${#conflicts[@]} -gt 0 ]]; then
echo "conflict - these scripts already belong to another beta:" >&2
for entry in "${conflicts[@]}"; do
IFS=$'\t' read -r server rel owner <<<"$entry"
printf ' %-16s %-26s beta: %s\n' "$server" "$rel" "$owner" >&2
done
echo "nothing was changed" >&2
exit 1
fi
if [[ ${#blocked[@]} -gt 0 ]]; then
echo "stale backups in the way - no beta owns these, so they were never cleaned up:" >&2
for entry in "${blocked[@]}"; do
IFS=$'\t' read -r server ofile <<<"$entry"
printf ' %-16s %s\n' "$server" "$ofile" >&2
done
echo "move or delete them, then reopen the beta. nothing was changed" >&2
exit 1
fi
if [[ ${#plan[@]} -eq 0 ]]; then
echo "no usable servers - nothing was changed" >&2
exit 1
fi
local srv_list scr_list n_srv n_scr
srv_list="$(printf '%s\n' "${plan[@]}" | cut -f1 | join_list)"
scr_list="$(printf '%s\n' "${targets[@]}" | cut -f2 | join_list)"
n_srv="$(printf '%s\n' "${plan[@]}" | cut -f1 | sort -u | grep -c .)"
n_scr="${#targets[@]}"
echo
if [[ $DRY_RUN -eq 1 ]]; then
hdr "OPEN BETA $BETA_NAME (dry run)" "$n_scr $(plural "$n_scr" script) · $n_srv $(plural "$n_srv" server)"
rule
field_wrap "servers" "$srv_list"
field_wrap "scripts" "$scr_list"
field "deploy" "each lands as <name>-beta.gsc, live copy parked as .OLD"
field "move to" "betas/$slug/"
rule
note "nothing was changed"
echo
return 0
fi
beta_create "$slug" "$BETA_NAME" "$(now_iso)"
for entry in "${targets[@]}"; do
IFS=$'\t' read -r base rel <<<"$entry"
beta_add_script "$slug" "$base" "$rel" "$(sha_of "$BETA_SRC/$base")"
done
for entry in "${plan[@]}"; do
IFS=$'\t' read -r server base rel had <<<"$entry"
dest="$(dest_of "$server")"
bfile="$(beta_file "$rel")"
ofile="$(old_file "$rel")"
mkdir -p "$dest/$(dirname "$rel")"
if [[ $had -eq 1 ]]; then mv "$dest/$rel" "$dest/$ofile"; fi
cp -p "$BETA_SRC/$base" "$dest/$bfile"
beta_add_deploy "$slug" "$rel" "$server" "$had"
done
beta_sync_servers "$slug"
meta_touch
mkdir -p "$beta_dir"
for entry in "${targets[@]}"; do
IFS=$'\t' read -r base rel <<<"$entry"
mv "$BETA_SRC/$base" "$beta_dir/$(basename "$(beta_file "$rel")")"
done
hdr "BETA OPEN $BETA_NAME" "$n_scr $(plural "$n_scr" script) · $n_srv $(plural "$n_srv" server)"
rule
field_wrap "servers" "$srv_list"
field_wrap "scripts" "$scr_list"
field "deployed" "as <name>-beta.gsc, live copies parked as .OLD"
field "folder" "betas/$slug/"
rule
note "staging folder is clear for the next beta"
note "update: push_scripts.sh -ub \"$BETA_NAME\""
note "roll back: push_scripts.sh -r \"$BETA_NAME\""
echo
CHANGED_SERVERS=()
while read -r server; do
[[ -n "$server" ]] || continue
CHANGED_SERVERS+=("$server"$'\t'"$(printf '%s\n' "${plan[@]}" | awk -F'\t' -v s="$server" '$1==s' | grep -c .)")
done < <(printf '%s\n' "${plan[@]}" | cut -f1 | sort -u)
deploy_finish "beta:$slug"
}
# ------------------------------------------------------------ update beta ---
do_update_beta() {
local slug beta_dir base staged_as target bfile backup rev server line entry
local touched=0
local -a staged=() matched=() unmatched=() missing=() known=()
meta_init
slug="$(resolve_beta)"
beta_dir="$BETA_SRC/$slug"
mapfile -t staged < <(staged_files)
if [[ ${#staged[@]} -eq 0 ]]; then
echo "nothing staged in $BETA_SRC - drop the new version in there first" >&2
exit 1
fi
# a staged file belongs to this beta if it matches how the script was staged
# originally, or the filename of the script it replaces
mapfile -t known < <(beta_scripts "$slug")
for base in "${staged[@]}"; do
entry=""
for line in ${known[@]+"${known[@]}"}; do
IFS=$'\t' read -r staged_as target bfile backup rev <<<"$line"
if [[ "$base" == "$staged_as" || "$base" == "$(basename "$target")" ]]; then
entry="$base"$'\t'"$target"$'\t'"$bfile"$'\t'"$rev"
break
fi
done
if [[ -n "$entry" ]]; then matched+=("$entry"); else unmatched+=("$base"); fi
done
if [[ ${#matched[@]} -eq 0 ]]; then
echo "none of the staged files belong to '$BETA_NAME':" >&2
printf '%s\n' "${staged[@]}" | sed 's/^/ /' >&2
echo "that beta covers: $(beta_scripts "$slug" | cut -f2 | join_list)" >&2
echo "nothing was changed" >&2
exit 1
fi
local scr_list srv_list n_scr n_srv
scr_list="$(printf '%s\n' "${matched[@]}" | cut -f2 | join_list)"
srv_list="$(beta_servers "$slug" | join_list)"
n_scr="${#matched[@]}"
n_srv="$(beta_servers "$slug" | grep -c . || true)"
echo
if [[ $DRY_RUN -eq 1 ]]; then
hdr "UPDATE BETA $BETA_NAME (dry run)" "$n_scr $(plural "$n_scr" script) · $n_srv $(plural "$n_srv" server)"
rule
field_wrap "replace" "$scr_list"
field_wrap "servers" "$srv_list"
field "keeps" "backups, membership and revision history"
[[ ${#unmatched[@]} -gt 0 ]] && field_wrap "skipped" "$(printf '%s\n' "${unmatched[@]}" | join_list)"
rule
note "nothing was changed"
echo
return 0
fi
for entry in "${matched[@]}"; do
IFS=$'\t' read -r base target bfile rev <<<"$entry"
while IFS=$'\t' read -r server _ _ _ _; do
[[ -n "$server" ]] || continue
dest="$(dest_of "$server")"
if [[ ! -d "$dest" ]]; then
missing+=("$server: no sp folder")
continue
fi
mkdir -p "$dest/$(dirname "$target")"
cp -p "$BETA_SRC/$base" "$dest/$bfile"
done < <(beta_deploys "$slug" | awk -F'\t' -v t="$target" '$2==t')
mv -f "$BETA_SRC/$base" "$beta_dir/$(basename "$bfile")"
beta_bump_script "$slug" "$target" "$(sha_of "$beta_dir/$(basename "$bfile")")"
touched=$((touched + 1))
done
meta_touch
hdr "BETA UPDATED $BETA_NAME" "rev $(beta_field "$slug" revision)"
rule
field_wrap "replaced" "$scr_list"
field_wrap "servers" "$srv_list"
field "folder" "betas/$slug/"
[[ ${#unmatched[@]} -gt 0 ]] && field_wrap "skipped" "$(printf '%s\n' "${unmatched[@]}" | join_list)"
for line in ${missing[@]+"${missing[@]}"}; do warn "$line"; done
rule
note "backups and beta membership unchanged"
[[ ${#unmatched[@]} -gt 0 ]] && note "anything left staged is not part of this beta"
echo
CHANGED_SERVERS=()
while read -r server; do
[[ -n "$server" ]] || continue
CHANGED_SERVERS+=("$server"$'\t'"$n_scr")
done < <(beta_servers "$slug")
deploy_finish "update:$slug"
}
# --------------------------------------------------------------- rollback ---
do_rollback() {
local slug beta_dir server target bfile backup had dest line
local restored=0 removed=0 missing=0 dropped=0
local -a restored_list=() dropped_list=() warn_list=() touched_servers=()
meta_init
slug="$(resolve_beta)"
beta_dir="$BETA_SRC/$slug"
while IFS=$'\t' read -r server target bfile backup had; do
[[ -n "$server" ]] || continue
dest="$(dest_of "$server")"
if [[ $DRY_RUN -eq 1 ]]; then
if [[ "$had" == "true" ]]; then restored_list+=("$target"); else dropped_list+=("$target"); fi
removed=$((removed + 1))
continue
fi
touched_servers+=("$server")
if [[ -e "$dest/$bfile" ]]; then rm -f "$dest/$bfile"; removed=$((removed + 1)); fi
if [[ "$had" == "true" ]]; then
if [[ -e "$dest/$backup" ]]; then
mv "$dest/$backup" "$dest/$target"
restored=$((restored + 1)); restored_list+=("$target")
else
missing=$((missing + 1)); warn_list+=("$server: no $backup to restore")
fi
else
dropped_list+=("$target")
fi
done < <(beta_deploys "$slug")
if [[ -d "$beta_dir" ]]; then dropped="$(find "$beta_dir" -type f | grep -c . || true)"; fi
echo
if [[ $DRY_RUN -eq 1 ]]; then
hdr "ROLLBACK $BETA_NAME (dry run)" "$removed beta $(plural "$removed" file)"
rule
[[ ${#restored_list[@]} -gt 0 ]] && field_wrap "restore" "$(printf '%s\n' "${restored_list[@]}" | join_list)"
[[ ${#dropped_list[@]} -gt 0 ]] && field_wrap "drop" "$(printf '%s\n' "${dropped_list[@]}" | join_list)"
field "delete" "betas/$slug/ ($dropped $(plural "$dropped" script))"
rule
note "nothing was changed"
echo
return 0
fi
rm -rf "$beta_dir"
beta_drop "$slug"
meta_touch
hdr "ROLLED BACK $BETA_NAME" "$removed beta $(plural "$removed" file) removed"
rule
[[ ${#restored_list[@]} -gt 0 ]] && field_wrap "restored" "$(printf '%s\n' "${restored_list[@]}" | join_list)"
[[ ${#dropped_list[@]} -gt 0 ]] && field_wrap "dropped" "$(printf '%s\n' "${dropped_list[@]}" | join_list)"
field "deleted" "betas/$slug/ ($dropped $(plural "$dropped" script))"
for line in ${warn_list[@]+"${warn_list[@]}"}; do warn "$line"; done
rule
note "servers are back on the shared version"
echo
CHANGED_SERVERS=()
while read -r server; do
[[ -n "$server" ]] || continue
CHANGED_SERVERS+=("$server"$'\t'"1")
done < <(printf '%s\n' ${touched_servers[@]+"${touched_servers[@]}"} | sort -u)
deploy_finish "rollback:$slug"
}
# ---------------------------------------------------------------- promote ---
do_promote() {
local slug beta_dir server target bfile backup had dest staged_as rev
local cleaned=0 landed=0
local -a lost=() live_list=()
meta_init
slug="$(resolve_beta)"
beta_dir="$BETA_SRC/$slug"
while IFS=$'\t' read -r staged_as target bfile backup rev; do
[[ -n "$target" ]] || continue
[[ -f "$beta_dir/$(basename "$bfile")" ]] || lost+=("$(basename "$bfile")")
done < <(beta_scripts "$slug")
if [[ ${#lost[@]} -gt 0 ]]; then
echo "can't promote '$BETA_NAME' - these are missing from betas/$slug/:" >&2
printf '%s\n' "${lost[@]}" | sed 's/^/ /' >&2
echo "put them back or roll the beta back instead. nothing was changed" >&2
exit 1
fi
while IFS=$'\t' read -r server target bfile backup had; do
[[ -n "$server" ]] || continue
dest="$(dest_of "$server")"
if [[ $DRY_RUN -eq 0 ]]; then
rm -f "$dest/$bfile"
[[ "$had" == "true" ]] && rm -f "$dest/$backup"
fi
cleaned=$((cleaned + 1))
done < <(beta_deploys "$slug")
while IFS=$'\t' read -r staged_as target bfile backup rev; do
[[ -n "$target" ]] || continue
live_list+=("$target")
if [[ $DRY_RUN -eq 0 ]]; then
mkdir -p "$SRC/$(dirname "$target")"
mv -f "$beta_dir/$(basename "$bfile")" "$SRC/$target"
fi
landed=$((landed + 1))
done < <(beta_scripts "$slug")
echo
if [[ $DRY_RUN -eq 1 ]]; then
hdr "PROMOTE $BETA_NAME (dry run)" "$landed $(plural "$landed" script)"
rule
field_wrap "go live" "$(printf '%s\n' "${live_list[@]}" | join_list)"
field "clear" "$cleaned beta $(plural "$cleaned" file) and backups off the servers"
field "delete" "betas/$slug/"
field "then" "push to every server"
rule
note "nothing was changed"
echo
return 0
fi
rm -rf "$beta_dir"
beta_drop "$slug"
meta_touch
hdr "PROMOTED $BETA_NAME" "$landed $(plural "$landed" script)"
rule
field_wrap "now live" "$(printf '%s\n' "${live_list[@]}" | join_list)"
field "cleared" "$cleaned beta $(plural "$cleaned" file) and backups"
field "deleted" "betas/$slug/"
rule
PATTERNS=("*")
DEPLOY_ACTION="promote:$slug"
do_push
}
# ------------------------------------------------------------------- list ---
do_list() {
local slug name opened updated revision server target bfile backup had dest base
local open=0 nstaged=0
local -a staged=() hits=() slugs=() trouble=()
meta_init
mapfile -t staged < <(staged_files)
mapfile -t slugs < <(beta_slugs)
nstaged=${#staged[@]}
open=${#slugs[@]}
echo
hdr "BETAS" "$open open · $nstaged staged"
rule
if [[ $nstaged -gt 0 ]]; then
printf ' %sSTAGING%s %sready for the next beta you open%s\n' "$B" "$R" "$D" "$R"
for base in "${staged[@]}"; do
mapfile -t hits < <(find_targets "$base")
case ${#hits[@]} in
0) detail "new" "$base" ;;
1) if [[ "${hits[0]}" == "$base" ]]; then
detail "replaces" "$base"
else
detail "replaces" "${hits[0]} ${D}(staged as $base)${R}"
fi ;;
*) detail "ambiguous" "$base ${YL}matches ${hits[*]}${R}" ;;
esac
done
echo
fi
if [[ $open -eq 0 ]]; then
if [[ $nstaged -eq 0 ]]; then
note "nothing staged, nothing open"
note "drop scripts in betas/, then: push_scripts.sh -b \"Name\" <servers>"
else
note "open one with: push_scripts.sh -b \"Name\" <servers>"
fi
rule
echo
return 0
fi
for slug in "${slugs[@]}"; do
name="$(beta_field "$slug" name)"
opened="$(beta_field "$slug" opened)"
updated="$(beta_field "$slug" updated)"
revision="$(beta_field "$slug" revision)"
trouble=()
while IFS=$'\t' read -r server target bfile backup had; do
[[ -n "$server" ]] || continue
dest="$(dest_of "$server")"
[[ -e "$dest/$bfile" ]] || trouble+=("$server: $bfile missing on disk")
if [[ "$had" == "true" && ! -e "$dest/$backup" ]]; then
trouble+=("$server: backup $backup missing")
fi
done < <(beta_deploys "$slug")
if [[ ! -d "$BETA_SRC/$slug" ]] || [[ "$(find "$BETA_SRC/$slug" -type f | grep -c . || true)" -eq 0 ]]; then
trouble+=("betas/$slug/ is empty - promote would fail")
fi
if [[ ${#trouble[@]} -gt 0 ]]; then
row "$YL" "$name" "$(when_str "$opened")"
else
row "$GN" "$name" "$(when_str "$opened")"
fi
field_wrap "maps" "$(beta_deploys "$slug" | cut -f1 | join_list)"
field_wrap "scripts" "$(beta_scripts "$slug" | cut -f2 | join_list)"
field "folder" "betas/$slug/"
if [[ "$revision" != "1" ]]; then
field "revision" "$revision ${D}updated $(ago "$updated")${R}"
fi
for base in ${trouble[@]+"${trouble[@]}"}; do warn "$base"; done
echo
done
rule
note "update: push_scripts.sh -ub \"<name>\""
note "promote: push_scripts.sh -pb \"<name>\""
note "roll back: push_scripts.sh -r \"<name>\""
echo
}
# ------------------------------------------------------------------- main ---
case "$MODE" in
push) do_push ;;
beta) do_beta ;;
update) do_update_beta ;;
rollback) do_rollback ;;
promote) do_promote ;;
list) do_list ;;
active) do_active_deployments ;;
deployments) do_list_deployments all ;;
esac