mirror of
https://github.com/kellymichels/zscripts-token-savers
synced 2026-10-07 07:18:18 +00:00
* feat(bash): native bash port of all z-scripts for Linux/macOS/WSL Full port: zhelpers.sh library (jq config, ssh, http/tcp, archive builder, build-version, motd, port-kill), all commands (zstart/zkill/zrestart/zstop, zdeploy with 5 kind handlers, zec2/zec2online/zrepair, zbackup/zbackup_ec2/ zsync/zbackup_and_sync, zstart_docker, zsetup_mail, setup_backup_schedule via cron), Unix-path zconfig.example.json, and a bash/README.md. Verified: bash -n clean on all scripts; archive exclusions, config semantics, and zec2 tested against the real config and live server from Git Bash. Needs a Linux/macOS/WSL shakedown for lsof/rsync/nohup paths before merging. * chore: pin line endings (.gitattributes) - bash LF, powershell CRLF * docs(readme): point Linux/macOS/WSL users at the bash port * fix(bash): don't run the Windows venv python.exe on WSL/Linux/macOS zstart's venv detection fell back to .venv/Scripts/python.exe (a Windows binary) whenever it existed. On a Windows-built project accessed from WSL that file sits on the mount and looks executable, so it got picked and failed with 'exec format error' instead of falling through to python3. Guard that branch to Windows-family shells (msys/cygwin), where a .exe can actually run. Verified on WSL: zstart --detached now backgrounds a stdlib app via python3, serves HTTP 200, logs to /tmp/zstart-<key>.log, and zkill terminates it and frees the port. * feat(bash): add token-usage tracking to the bash port (#6) Adds z_track_start/z_track_stop + z_record to zhelpers.sh and wires z_track_start into every command script. Each run now captures its own output volume (FIFO+tee, ANSI stripped), prints the '--- N lines / N chars / ~N tokens est. (Claude Code) ---' footer, and appends one JSONL row per top-level run in the same shape as the PowerShell tokens.jsonl. A nested-run guard keeps zrestart from double-counting its zkill/zstart children. Data dir precedence: $ZTOKENS_DATA, config ztokens.dataDir, sibling ../../ztokens/data, else ~/.ztokens/data. Docs + example config updated. Verified on WSL (isolated data dir): single run records correctly; nested zrestart produces one combined record, not three; est = round(chars/3.5).
496 lines
25 KiB
Bash
496 lines
25 KiB
Bash
#!/usr/bin/env bash
|
|
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
|
# Created by Kelly Michels · dev@evomedia.net
|
|
# Licensed under the MIT License. See LICENSE.
|
|
#
|
|
# zdeploy — deploy any project defined in zconfig.json to the server.
|
|
# Each project runs its own docker compose stack; the handler is picked by the
|
|
# project's "kind": python | vite | nextjs | edge | docker.
|
|
#
|
|
# Usage:
|
|
# zdeploy <project> [<project> ...] [--note "message"]
|
|
# zdeploy all # every project (edge kinds first), stop at first failure
|
|
#
|
|
# Flow (python/vite/nextjs): zip source -> free server disk space -> scp up ->
|
|
# unzip into remote.path (preserving server-side .env) -> docker compose build + up
|
|
# -> verify the live site reports the new build version. Zips are always deleted.
|
|
|
|
set -uo pipefail
|
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
|
|
z_track_start "$@"
|
|
z_need_config
|
|
z_require ssh scp zip curl jq
|
|
|
|
projects=(); NOTE="Build deployed"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--note) NOTE="${2:-Build deployed}"; shift 2 ;;
|
|
*) projects+=("${1#-}"); shift ;; # tolerate switch-style keys (zdeploy -myproject)
|
|
esac
|
|
done
|
|
|
|
if [ "${#projects[@]}" -eq 0 ]; then
|
|
printf '\n'; warn "Usage: zdeploy <project> [<project> ...] | all [--note \"message\"]"
|
|
dim " Projects in zconfig.json: $(zproj_csv)"
|
|
dim " 'all' deploys everything (edge kinds first) and stops at the first failure."
|
|
exit 1
|
|
fi
|
|
|
|
EC2_IP="$(zec2_ip)"
|
|
STACK_ROOT="$(zq '.ec2.stackRoot')"
|
|
REMOTE_HOME="/home/$(zec2_user)"
|
|
EC2_USER="$(zec2_user)"
|
|
TEMP_ROOT="$(zq '.paths.temp')"
|
|
{ [ -z "$TEMP_ROOT" ] || [ "$TEMP_ROOT" = "null" ]; } && TEMP_ROOT="${TMPDIR:-/tmp}"
|
|
mkdir -p "$TEMP_ROOT"
|
|
|
|
# 'all' -> every project, edge kinds first (the proxy must route before apps ship).
|
|
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
|
|
mapfile -t edge_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG")
|
|
mapfile -t rest_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind != "edge") | .key' "$ZCONFIG")
|
|
projects=("${edge_keys[@]+"${edge_keys[@]}"}" "${rest_keys[@]+"${rest_keys[@]}"}")
|
|
info "Deploying all projects: $(printf '%s ' "${projects[@]}")"
|
|
fi
|
|
|
|
deploy_zip_name() { # <key>
|
|
local zn; zn="$(zproj "$1" .deploy.zipName)"
|
|
[ -n "$zn" ] && printf '%s' "$zn" || printf '%sDeploy.zip' "$1"
|
|
}
|
|
|
|
# Pre-upload cleanup: remove stale deploy zips, prune docker, truncate big logs,
|
|
# fail if under 1.5 GB free.
|
|
preflight_cleanup() { # <extra_zip_to_remove...>
|
|
printf '\n'; info "--- [Preflight] Freeing disk space on the server ---"
|
|
local rm_clause="true"
|
|
[ $# -gt 0 ] && rm_clause="rm -f $*"
|
|
local cmd
|
|
cmd="echo '--- df / before cleanup ---'; df -h /"
|
|
cmd+="; echo '--- removing stale deploy artifacts ---'; $rm_clause"
|
|
cmd+="; echo '--- pruning docker build cache + dangling images + stopped containers ---'"
|
|
cmd+="; sudo docker container prune -f >/dev/null 2>&1 || true"
|
|
cmd+="; sudo docker builder prune -f >/dev/null 2>&1 || true"
|
|
cmd+="; sudo docker image prune -af >/dev/null 2>&1 || true"
|
|
cmd+="; echo '--- truncating large container logs ---'"
|
|
cmd+="; sudo find /var/lib/docker/containers/ -name '*-json.log' -size +50M -exec truncate -s 0 {} + 2>/dev/null || true"
|
|
cmd+="; echo '--- df / after cleanup ---'; df -h /"
|
|
cmd+='; avail_mb=$(df --output=avail -BM / | tail -n 1 | tr -dc 0-9)'
|
|
cmd+='; [ -z "$avail_mb" ] && avail_mb=0'
|
|
cmd+='; echo available_mb=$avail_mb'
|
|
cmd+='; if [ "$avail_mb" -lt 1500 ]; then echo "ERROR: less than 1.5 GB free on /. Grow the root volume or run: sudo docker system prune -af" >&2; exit 11; fi'
|
|
zssh "$cmd" || { err "Server pre-flight cleanup failed. Root volume too full (need ~1.5 GB free, ideally 3+)."; return 1; }
|
|
}
|
|
|
|
# Post-deploy cleanup: prune what this deploy created; running stacks untouched.
|
|
post_cleanup() { # <label>
|
|
printf '\n'; info "--- [Post-deploy] Reclaiming disk space ($1) ---"
|
|
local cmd
|
|
cmd="sudo docker container prune -f >/dev/null 2>&1 || true"
|
|
cmd+="; sudo docker builder prune -f >/dev/null 2>&1 || true"
|
|
cmd+="; sudo docker image prune -af >/dev/null 2>&1 || true"
|
|
cmd+="; sudo find /var/lib/docker/containers/ -name '*-json.log' -size +50M -exec truncate -s 0 {} + 2>/dev/null || true"
|
|
cmd+="; df -h /"
|
|
zssh "$cmd" || warn " Post-deploy cleanup returned non-zero; continuing."
|
|
}
|
|
|
|
send_zip() { # <local_zip> <zip_name>
|
|
scp -i "$(zec2_pem)" "$1" "$(zec2_target):$REMOTE_HOME/" \
|
|
|| { err "SCP upload failed. Likely server disk space. Try: rm -f $REMOTE_HOME/$2"; return 1; }
|
|
}
|
|
|
|
remote_unzip() { # <zip_name> <dest_path>
|
|
zec2_step "unzip $1" \
|
|
"test -f $REMOTE_HOME/$1 || { echo 'missing $REMOTE_HOME/$1'; exit 2; }; unzip -t $REMOTE_HOME/$1 || exit 3; unzip -o $REMOTE_HOME/$1 -d $2; uc=\$?; if [ \$uc -gt 1 ]; then exit \$uc; fi; exit 0"
|
|
}
|
|
|
|
# ── Deploy verification (build-version match, not just HTTP 200 — a 200 can be
|
|
# a stale cached build; the version match proves the new build is live) ─────
|
|
|
|
wait_verify_api() { # <key> <expected_label> <timeout_sec>
|
|
local key="$1" expected="$2" timeout="${3:-60}" domain live deadline
|
|
domain="$(zproj "$key" .domain)"
|
|
printf '\n'; info "--- [$key] Live build verification (expect $expected) ---"
|
|
deadline=$((SECONDS + timeout))
|
|
while [ $SECONDS -lt $deadline ]; do
|
|
live="$(http_get "http://$EC2_IP/api/build-version" "$domain" | jq -r '.build_version // empty' 2>/dev/null)"
|
|
if [ -n "$live" ]; then
|
|
if [ "$live" = "$expected" ]; then
|
|
ok " PASS - live build $live matches expected."
|
|
return 0
|
|
fi
|
|
warn " Live build is $live, expected $expected - waiting..."
|
|
else
|
|
dim " /api/build-version not ready yet - waiting..."
|
|
fi
|
|
sleep 3
|
|
done
|
|
warn " WARNING: live build did not match $expected within ${timeout}s (a stale build may be cached)."
|
|
return 1
|
|
}
|
|
|
|
wait_verify_static() { # <key> <pre_product_version> <pre_build_number>
|
|
local key="$1" pv="$2" bn="$3" expected container domain live deadline raw
|
|
if [ -z "$pv" ] || [ -z "$bn" ]; then
|
|
printf '\n'; warn "--- [$key version] SKIPPED (no local build-version.json - see 'Enabling deploy verification' in README) ---"
|
|
return 0
|
|
fi
|
|
expected="v${pv}.$((bn + 1))"
|
|
container="$(zproj "$key" .remote.containerName)"
|
|
domain="$(zproj "$key" .domain)"
|
|
printf '\n'; info "--- [$key] Live build verification (expect $expected) ---"
|
|
deadline=$((SECONDS + 45))
|
|
while [ $SECONDS -lt $deadline ]; do
|
|
if [ -n "$container" ]; then
|
|
# build-version.json may be blocked externally by the edge proxy;
|
|
# read it inside the running container instead.
|
|
raw="$(zssh "sudo docker exec $container cat /usr/share/nginx/html/build-version.json 2>/dev/null" 2>/dev/null)"
|
|
else
|
|
raw="$(http_get "http://$EC2_IP/build-version.json" "$domain")"
|
|
fi
|
|
live="$(printf '%s' "$raw" | jq -r '"v\(.productVersion).\(.buildNumber)"' 2>/dev/null)"
|
|
if [ -n "$live" ] && [ "$live" != "null" ] && [ "$live" != "v." ]; then
|
|
if [ "$live" = "$expected" ]; then
|
|
ok " PASS - live build $live matches expected."
|
|
return 0
|
|
fi
|
|
warn " Live build is $live, expected $expected - waiting..."
|
|
else
|
|
dim " Container not ready yet - waiting..."
|
|
fi
|
|
sleep 3
|
|
done
|
|
warn " WARNING: live build did not match $expected within 45s (upload or Docker build may have failed, or a stale build is cached)."
|
|
}
|
|
|
|
# Fast-forward the checkout before a deploy when deploy.gitPull is set — zdeploy
|
|
# zips the working tree, so a stale checkout would ship stale code while still
|
|
# bumping the build number. Abort rather than deploy uncertain code.
|
|
deploy_git_pull() { # <key>
|
|
local key="$1" root branch
|
|
[ "$(zproj "$key" .deploy.gitPull)" = "true" ] || return 0
|
|
root="$(zproj "$key" .localRoot)"
|
|
if [ ! -d "$root/.git" ]; then
|
|
warn " gitPull set but '$root' is not a git repo - skipping pull."
|
|
return 0
|
|
fi
|
|
printf '\n'; info "--- [0] git pull --ff-only ---"
|
|
branch="$(cd "$root" && git rev-parse --abbrev-ref HEAD)"
|
|
dim " Branch: $branch"
|
|
(cd "$root" && git pull --ff-only) || {
|
|
err "git pull --ff-only failed in '$root' (branch '$branch'). Resolve it, then re-run - refusing to deploy possibly-stale code."
|
|
return 1
|
|
}
|
|
dim " Now at: $(cd "$root" && git log -1 --oneline)"
|
|
}
|
|
|
|
# ── Kind handlers ────────────────────────────────────────────────────────────
|
|
|
|
deploy_python() { # <key>
|
|
local key="$1" start=$SECONDS root remote_path compose_dir app_svc zip_name zip_local
|
|
local bv="" has_version_tool=0 excl=() attempt out label domain
|
|
root="$(zproj "$key" .localRoot)"
|
|
remote_path="$(zproj "$key" .remote.path)"
|
|
compose_dir="$(zremote_compose_dir "$key")"
|
|
app_svc="$(zproj "$key" .remote.appService)"; [ -n "$app_svc" ] || app_svc="app"
|
|
zip_name="$(deploy_zip_name "$key")"
|
|
zip_local="$TEMP_ROOT/$zip_name"
|
|
label="$(zproj "$key" .label)"
|
|
domain="$(zproj "$key" .domain)"
|
|
[ -f "$root/scripts/build_version_tool.py" ] && has_version_tool=1
|
|
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
|
|
|
printf '\n'; info "=== $label deploy (python) ==="
|
|
dim "Local zip: $zip_local"
|
|
|
|
printf '\n'; info "--- [1] Zipping $label ---"
|
|
find "$root" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
|
mapfile -t excl < <(z_archive_excludes "$key" 0)
|
|
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
|
|
|
|
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
|
|
|
|
printf '\n'; info "--- [2] Uploading zip ---"
|
|
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
|
|
rm -f "$zip_local"
|
|
|
|
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
|
|
zec2_step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
|
|
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
|
|
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
|
zec2_step "backup .env if present" "if [ -f $remote_path/.env ]; then cp $remote_path/.env $REMOTE_HOME/.env.${key}_bak; fi" || return 1
|
|
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
|
remote_unzip "$zip_name" "$remote_path" || return 1
|
|
zec2_step "restore .env from backup" "if [ -f $REMOTE_HOME/.env.${key}_bak ]; then cp $REMOTE_HOME/.env.${key}_bak $remote_path/.env; fi" || return 1
|
|
zec2_step "require compose directory" "test -d $compose_dir" || return 1
|
|
zec2_step "docker compose build $app_svc" "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose build $app_svc" || return 1
|
|
zec2_step "docker compose up -d" "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
|
|
zec2_step "record deploy time; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
|
|
|
|
if [ "$has_version_tool" -eq 1 ]; then
|
|
printf '\n'; info "--- [4] Incrementing build version ---"
|
|
for attempt in 1 2 3 4 5; do
|
|
out="$(zssh "cd $compose_dir && sudo docker compose exec -T $app_svc python scripts/build_version_tool.py bump" 2>/dev/null | tail -1)"
|
|
[ -n "$out" ] && { bv="$out"; break; }
|
|
warn " Attempt $attempt failed, retrying in 3s..."
|
|
sleep 3
|
|
done
|
|
[ -n "$bv" ] || { err "Build version bump failed after 5 attempts"; return 1; }
|
|
|
|
(cd "$root" && { python3 scripts/build_version_tool.py set "$bv" >/dev/null 2>&1 || python scripts/build_version_tool.py set "$bv" >/dev/null 2>&1; })
|
|
zssh "echo '$bv' | sudo tee $remote_path/.build_version > /dev/null"
|
|
if [ -f "$root/scripts/build_changelog_tool.py" ]; then
|
|
(cd "$root" && { python3 scripts/build_changelog_tool.py append --version "$bv" --note "$NOTE" >/dev/null 2>&1 \
|
|
|| python scripts/build_changelog_tool.py append --version "$bv" --note "$NOTE" >/dev/null 2>&1; })
|
|
fi
|
|
|
|
printf '\n'; info "--- [5] Restarting app to pick up new version ---"
|
|
zssh "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose restart $app_svc" \
|
|
|| { err "App restart after build bump failed"; return 1; }
|
|
|
|
wait_verify_api "$key" "$bv" 30 || true
|
|
else
|
|
printf '\n'; info "--- [4] Basic reachability check (no build_version_tool - see 'Enabling deploy verification' in README) ---"
|
|
local deadline=$((SECONDS + 30)) code up=0
|
|
while [ $SECONDS -lt $deadline ]; do
|
|
sleep 3
|
|
code="$(http_code "http://$EC2_IP/" "$domain")"
|
|
if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then up=1; break; fi
|
|
dim " App not ready yet - waiting..."
|
|
done
|
|
if [ "$up" -eq 1 ]; then ok " App is responding."; else warn " WARNING: app did not respond within 30s."; fi
|
|
fi
|
|
|
|
post_cleanup "$key"
|
|
|
|
local elapsed=$((SECONDS - start))
|
|
printf '\n'; ok "--- [Done] $label deployed! ---"
|
|
[ -n "$domain" ] && warn "Site: https://$domain"
|
|
[ -n "$bv" ] && note "Build Version: $bv"
|
|
info "Change Note: $NOTE"
|
|
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
|
|
}
|
|
|
|
deploy_vite() { # <key>
|
|
local key="$1" start=$SECONDS root remote_path zip_name zip_local excl=()
|
|
local pre_pv="" pre_bn="" label domain edge_pc
|
|
root="$(zproj "$key" .localRoot)"
|
|
remote_path="$(zproj "$key" .remote.path)"
|
|
zip_name="$(deploy_zip_name "$key")"
|
|
zip_local="$TEMP_ROOT/$zip_name"
|
|
label="$(zproj "$key" .label)"
|
|
domain="$(zproj "$key" .domain)"
|
|
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
|
|
|
printf '\n'; info "=== $label deploy (vite/static) ==="
|
|
dim "Local zip: $zip_local"
|
|
|
|
printf '\n'; info "--- [1] Zipping site ---"
|
|
if [ -f "$root/build-version.json" ]; then
|
|
pre_pv="$(jq -r '.productVersion // empty' "$root/build-version.json")"
|
|
pre_bn="$(jq -r '.buildNumber // empty' "$root/build-version.json")"
|
|
[ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
|
|
fi
|
|
mapfile -t excl < <(z_archive_excludes "$key" 0)
|
|
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
|
|
|
|
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
|
|
|
|
printf '\n'; info "--- [2] Uploading zip ---"
|
|
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
|
|
rm -f "$zip_local"
|
|
|
|
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
|
|
zec2_step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
|
|
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
|
|
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
|
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
|
remote_unzip "$zip_name" "$remote_path" || return 1
|
|
zec2_step "require compose file" "test -f $remote_path/docker-compose.yml" || return 1
|
|
zec2_step "docker compose build" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose build" || return 1
|
|
zec2_step "docker compose up -d" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
|
|
|
|
local ek; ek="$(zedge_key)"
|
|
if [ -n "$ek" ]; then
|
|
edge_pc="$(zproj "$ek" .proxyContainer)"
|
|
[ -n "$edge_pc" ] && { zec2_step "reload edge nginx (flush DNS cache for new container IP)" "sudo docker exec $edge_pc nginx -s reload" || return 1; }
|
|
fi
|
|
zec2_step "record deploy time; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
|
|
|
|
wait_verify_static "$key" "$pre_pv" "$pre_bn"
|
|
post_cleanup "$key"
|
|
|
|
local elapsed=$((SECONDS - start))
|
|
printf '\n'; ok "--- [Done] $label deployed! ---"
|
|
[ -n "$domain" ] && warn "Site: https://$domain"
|
|
info "Change Note: $NOTE"
|
|
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
|
|
}
|
|
|
|
deploy_next() { # <key>
|
|
local key="$1" start=$SECONDS root remote_path app_svc zip_name zip_local excl=()
|
|
local pre_pv="" pre_bn="" label domain db_user db_name prod_port
|
|
root="$(zproj "$key" .localRoot)"
|
|
remote_path="$(zproj "$key" .remote.path)"
|
|
app_svc="$(zproj "$key" .remote.appService)"; [ -n "$app_svc" ] || app_svc="web"
|
|
zip_name="$(deploy_zip_name "$key")"
|
|
zip_local="$TEMP_ROOT/$zip_name"
|
|
label="$(zproj "$key" .label)"
|
|
domain="$(zproj "$key" .domain)"
|
|
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
|
|
|
printf '\n'; info "=== $label deploy (nextjs) ==="
|
|
dim "Local zip: $zip_local"
|
|
|
|
if [ -f "$root/public/build-version.json" ]; then
|
|
pre_pv="$(jq -r '.productVersion // empty' "$root/public/build-version.json")"
|
|
pre_bn="$(jq -r '.buildNumber // empty' "$root/public/build-version.json")"
|
|
[ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
|
|
fi
|
|
|
|
printf '\n'; info "--- [1] Zipping project files ---"
|
|
mapfile -t excl < <(z_archive_excludes "$key" 0)
|
|
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
|
|
|
|
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
|
|
|
|
printf '\n'; info "--- [2] Uploading zip ---"
|
|
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
|
|
rm -f "$zip_local"
|
|
|
|
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
|
|
zec2_step "ensure unzip installed" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
|
|
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
|
|
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
|
zec2_step "backup .env if present" "if [ -f $remote_path/.env ]; then cp $remote_path/.env $REMOTE_HOME/.env.${key}_bak; fi" || return 1
|
|
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
|
remote_unzip "$zip_name" "$remote_path" || return 1
|
|
zec2_step "restore .env from backup" "if [ -f $REMOTE_HOME/.env.${key}_bak ]; then cp $REMOTE_HOME/.env.${key}_bak $remote_path/.env; fi" || return 1
|
|
|
|
printf '\n'; info "--- [4] Docker compose rebuild ---"
|
|
zec2_step "docker compose down" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose down" || return 1
|
|
zec2_step "docker compose build" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose build" || return 1
|
|
zec2_step "docker compose up -d" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
|
|
|
|
db_user="$(zproj "$key" .db.user)"; db_name="$(zproj "$key" .db.name)"
|
|
if [ -n "$db_user" ] && [ -n "$db_name" ]; then
|
|
zec2_step "wait for postgres ready" \
|
|
"cd $remote_path && for i in \$(seq 1 30); do sudo docker compose exec -T db pg_isready -U $db_user -d $db_name >/dev/null 2>&1 && break; sleep 2; done" || return 1
|
|
fi
|
|
if [ "$(zproj "$key" .migrations)" = "prisma" ]; then
|
|
zec2_step "apply prisma migrations" "cd $remote_path && sudo docker compose exec -T $app_svc npx prisma migrate deploy" || return 1
|
|
fi
|
|
zec2_step "record deploy timestamp; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
|
|
|
|
printf '\n'; info "--- [5] Verifying deployment ---"
|
|
prod_port="$(zproj "$key" .ports.prod)"
|
|
if [[ "$prod_port" =~ ^[0-9]+$ ]]; then
|
|
local direct="http://${EC2_IP}:${prod_port}/" deadline=$((SECONDS + 60)) code verified=0
|
|
while [ $SECONDS -lt $deadline ]; do
|
|
sleep 4
|
|
code="$(http_code "$direct")"
|
|
if [ "$code" = "200" ]; then
|
|
ok " PASS - app is responding at $direct"
|
|
verified=1; break
|
|
fi
|
|
dim " App not ready yet - waiting..."
|
|
done
|
|
[ "$verified" -eq 1 ] || warn " WARNING: no response at $direct within 60s (is the port open in the security group?)."
|
|
fi
|
|
if [ -n "$pre_pv" ]; then
|
|
wait_verify_api "$key" "v${pre_pv}.$((pre_bn + 1))" 60 || true
|
|
else
|
|
warn " (No public/build-version.json - version verification skipped. See 'Enabling deploy verification' in README.)"
|
|
fi
|
|
|
|
post_cleanup "$key"
|
|
|
|
local elapsed=$((SECONDS - start))
|
|
printf '\n'; ok "--- [Done] $label deployed! ---"
|
|
[ -n "$domain" ] && warn "Site: https://$domain"
|
|
info "Change Note: $NOTE"
|
|
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
|
|
}
|
|
|
|
deploy_edge() { # <key>
|
|
local key="$1" root remote_path pc label cert_mount="" rm_stale="" f
|
|
root="$(zproj "$key" .localRoot)"
|
|
remote_path="$(zproj "$key" .remote.path)"
|
|
pc="$(zproj "$key" .proxyContainer)"
|
|
label="$(zproj "$key" .label)"
|
|
|
|
printf '\n'; info "=== $label deploy (edge nginx ingress) ==="
|
|
[ -d "$root" ] || { err "Edge root not found: $root"; return 1; }
|
|
for f in docker-compose.yml nginx.conf; do
|
|
[ -f "$root/$f" ] || { err "Missing $root/$f"; return 1; }
|
|
done
|
|
|
|
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
|
zec2_step "ensure edge dir" "sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
|
|
|
# Ship every top-level file in the edge folder — nginx.conf, compose, css,
|
|
# htpasswd, whatever the proxy serves. Subdirectories (logs, certs) stay put.
|
|
while IFS= read -r -d '' f; do
|
|
info " >> uploading $(basename "$f")"
|
|
scp -i "$(zec2_pem)" "$f" "$(zec2_target):$remote_path/" \
|
|
|| { err "SCP failed for $(basename "$f")"; return 1; }
|
|
done < <(find "$root" -maxdepth 1 -type f ! -name nul -print0)
|
|
|
|
local certs; certs="$(zproj "$key" .certsSource)"
|
|
[ -n "$certs" ] && cert_mount="-v $certs:/etc/letsencrypt/:ro "
|
|
zec2_step "validate new nginx.conf" \
|
|
"sudo docker run --rm -v $remote_path/nginx.conf:/etc/nginx/nginx.conf:ro ${cert_mount}nginx:1.27-alpine nginx -t -c /etc/nginx/nginx.conf" || return 1
|
|
|
|
# A container from an older compose project may still hold the proxy name;
|
|
# docker refuses a second create with the same name, so remove it first.
|
|
[ -n "$pc" ] && rm_stale="; sudo docker rm -f $pc 2>/dev/null || true"
|
|
zec2_step "edge: compose down + remove stale proxy" "cd $remote_path && sudo docker compose down 2>/dev/null || true$rm_stale" || return 1
|
|
zec2_step "edge compose up -d" "cd $remote_path && sudo docker compose up -d" || return 1
|
|
[ -n "$pc" ] && { zec2_step "edge nginx reload" "sudo docker exec $pc nginx -s reload || true" || return 1; }
|
|
zec2_step "fix nginx-logs permissions (if present)" \
|
|
"if [ -d $remote_path/nginx-logs ]; then sudo chmod 777 $remote_path/nginx-logs; sudo chmod 666 $remote_path/nginx-logs/*.log 2>/dev/null || true; fi" || return 1
|
|
ok "--- [Done] Edge proxy deploy finished ---"
|
|
}
|
|
|
|
deploy_docker() { # <key>
|
|
local key="$1" root remote_path label f domain
|
|
root="$(zproj "$key" .localRoot)"
|
|
remote_path="$(zproj "$key" .remote.path)"
|
|
label="$(zproj "$key" .label)"
|
|
domain="$(zproj "$key" .domain)"
|
|
|
|
printf '\n'; info "=== $label deploy (docker compose) ==="
|
|
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
|
[ -f "$root/docker-compose.yml" ] || { err "Missing $root/docker-compose.yml"; return 1; }
|
|
|
|
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
|
zec2_step "ensure project dir" "sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
|
|
|
while IFS= read -r -d '' f; do
|
|
info " >> uploading $(basename "$f")"
|
|
scp -i "$(zec2_pem)" "$f" "$(zec2_target):$remote_path/" \
|
|
|| { err "SCP failed for $(basename "$f")"; return 1; }
|
|
done < <(find "$root" -maxdepth 1 -type f ! -name nul -print0)
|
|
|
|
zec2_step "docker compose pull" "cd $remote_path && sudo docker compose pull" || return 1
|
|
zec2_step "docker compose up -d" "cd $remote_path && sudo docker compose up -d" || return 1
|
|
|
|
post_cleanup "$key"
|
|
printf '\n'; ok "--- [Done] $label deploy finished ---"
|
|
[ -n "$domain" ] && warn "Site: https://$domain"
|
|
}
|
|
|
|
# ── Dispatch ─────────────────────────────────────────────────────────────────
|
|
|
|
for key in "${projects[@]}"; do
|
|
zproj_require "$key"
|
|
deploy_git_pull "$key" || exit 1
|
|
kind="$(zproj "$key" .kind)"
|
|
case "$kind" in
|
|
python) deploy_python "$key" || exit 1 ;;
|
|
vite) deploy_vite "$key" || exit 1 ;;
|
|
nextjs) deploy_next "$key" || exit 1 ;;
|
|
edge) deploy_edge "$key" || exit 1 ;;
|
|
docker) deploy_docker "$key" || exit 1 ;;
|
|
*) err "No deploy handler for kind '$kind' (project '$key'). Add a deploy_<kind> function in zdeploy."; exit 1 ;;
|
|
esac
|
|
done
|