Phase 3: release script, container limits, rehearsed rollback
Adds the deployment mechanism for the portfolio site. The site itself is not deployed yet: Phase 2 has not produced a binary. - deploy/deploy.sh: build + test (fail fast) -> releases/<ts> -> atomic `current` symlink flip -> docker compose up -d -> /healthz gate -> flip back to `previous` and exit non-zero on failure. `--rollback` restores the previous release in one command. Auto-detects the Go toolchain in /nix/store when `go` is not on PATH. - deploy/docker-compose.yml: 127.0.0.1:8096, mem_limit == memswap_limit == 120m, capped json-file logs (10m x 3), restart unless-stopped. - deploy/Dockerfile: runtime image only (alpine + prebuilt payload), so a release needs no toolchain, no network and no package install. - deploy/rehearse.sh: repeatable proof (21 assertions, exit 0) of deploy, build fail-fast, health-gate rollback, --rollback and N3 restart. - deploy/README.md + docs/phase-3-evidence.md: how it works, the build contract Phase 2 must satisfy, the Caddy block for Sam, and the open bind-address question (loopback-only vs reachable from .35). Satisfies DoD items 1, 3 (container), 4, 8. Items 2, 5, 6, 7 need the Phase 2 binary and Sam's Caddy change; recorded as deferred with owners, not claimed. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
28
deploy/Dockerfile
Normal file
28
deploy/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# resume — runtime image (Phase 3 §3.2).
|
||||
#
|
||||
# Built by deploy/deploy.sh from a release directory, with the prebuilt static
|
||||
# Go binary already in place. No Go toolchain, no network and no package install
|
||||
# at build time: the image is `alpine` + the release payload, nothing else.
|
||||
#
|
||||
# The binary must be statically linked (deploy.sh's fallback build sets
|
||||
# CGO_ENABLED=0). If the app ever needs cgo, change the base image to a glibc
|
||||
# one (e.g. golang:1.25-bookworm) in git — do not patch it on the host.
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
LABEL org.opencontainers.image.title="resume" \
|
||||
org.opencontainers.image.version="${APP_VERSION}" \
|
||||
org.opencontainers.image.description="Sam Rolfe — portfolio site (Mode A static)"
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
# bin/server plus whatever the build emitted (dist/ for rendered pages/assets).
|
||||
COPY . /srv/
|
||||
|
||||
ENV PORT=8096 \
|
||||
APP_VERSION=${APP_VERSION}
|
||||
|
||||
EXPOSE 8096
|
||||
|
||||
ENTRYPOINT ["/srv/bin/server"]
|
||||
93
deploy/README.md
Normal file
93
deploy/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Deployment (Phase 3)
|
||||
|
||||
One script, one release directory, one atomic symlink. Deploy is a script, not a
|
||||
model decision (`AGENTS.md` §4.6), and nobody hand-edits files on the runtime
|
||||
host: change these files in git and deploy again.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `deploy.sh` | build + test → release → flip → `up -d` → health gate → rollback on failure |
|
||||
| `deploy.sh --rollback` | restore the previous release in one command |
|
||||
| `docker-compose.yml` | runtime container: `127.0.0.1:8096`, `mem_limit == memswap_limit == 120m`, capped logs |
|
||||
| `Dockerfile` | runtime image only — alpine + the prebuilt release payload, no toolchain, no network |
|
||||
| `rehearse.sh` | repeatable proof that deploy, fail-fast, health-gate rollback and `--rollback` all work |
|
||||
|
||||
## Layout on `.13`
|
||||
|
||||
```
|
||||
/home/sam/deployment/resume/
|
||||
├── releases/<UTC timestamp>/ immutable: bin/server, dist/, compose, Dockerfile, manifest.txt, .env
|
||||
├── current -> releases/<ts> live release; swapped with ln + mv -T (atomic rename)
|
||||
├── previous -> releases/<ts> the release that was live before the last flip
|
||||
└── deploy.log append-only record of every attempt, including failures
|
||||
```
|
||||
|
||||
## What `deploy.sh` expects from the app
|
||||
|
||||
1. `make build` if a `Makefile` with a `build` target exists, otherwise
|
||||
`go build ./cmd/server`. Either way the result must be one server binary at
|
||||
`bin/server` or `dist/server`; anything else the binary serves from disk goes
|
||||
in `dist/` and is copied into the release.
|
||||
2. `make test` (or `go test ./...`) — runs **before** anything is copied or
|
||||
flipped, so a broken build never reaches the release directory.
|
||||
3. `GET /healthz` → `200`. If the body is JSON with a `static` field it must be
|
||||
`true`. AI readiness is deliberately **not** part of the gate: Mode A must
|
||||
work with every AI service stopped (acceptance test A1).
|
||||
4. Bind `$PORT` (default `8096`) on `0.0.0.0` **inside the container**. The
|
||||
container's own loopback is not reachable through a published port; exposure
|
||||
is controlled by the host side of `ports:` in `docker-compose.yml`.
|
||||
|
||||
## Deploying
|
||||
|
||||
```bash
|
||||
cd /home/sam/paseo/projects/resume_ai_system_paperclip_build
|
||||
./deploy/deploy.sh # build, test, release, flip, health gate
|
||||
./deploy/deploy.sh --rollback # one command back to the previous release
|
||||
tail -40 /home/sam/deployment/resume/deploy.log
|
||||
```
|
||||
|
||||
Exit code is the answer: `0` means the new release answered `/healthz`.
|
||||
Non-zero means either (a) build/test failed and **nothing changed**, or (b) the
|
||||
health gate failed, `current` was flipped back to `previous`, the previous
|
||||
release was restarted, and the log says which.
|
||||
|
||||
Flags worth knowing: `--source DIR` (build from another checkout — this is how
|
||||
`rehearse.sh` drives the script), `--root DIR`, `--port N`,
|
||||
`--health-timeout N`.
|
||||
|
||||
The Go toolchain is auto-detected, including `/nix/store/*-go-1.*/bin/go`, so
|
||||
the script works on a host where `go` is not on `PATH`.
|
||||
|
||||
## Rehearsing the rollback
|
||||
|
||||
```bash
|
||||
./deploy/rehearse.sh # 21 assertions, exit 0 when every one holds
|
||||
```
|
||||
|
||||
It builds three throwaway services in a scratch directory, runs the real script
|
||||
against the real release root and the real port, and asserts:
|
||||
|
||||
1. a good release deploys, answers `/healthz`, publishes on `127.0.0.1` only,
|
||||
and has `memswap_limit == mem_limit`;
|
||||
2. a build failure exits non-zero, leaves `current` untouched and keeps serving;
|
||||
3. a release whose `/healthz` is `500` is rolled back by the health gate, exits
|
||||
non-zero, and the previous release serves again;
|
||||
4. `deploy.sh --rollback` flips back to the previous release and exits 0;
|
||||
5. `docker compose down && up` (acceptance test N3) restores the service.
|
||||
|
||||
It removes the release root, container and images it created. `KEEP_REHEARSAL=1`
|
||||
keeps them for inspection.
|
||||
|
||||
## Container limits — not negotiable
|
||||
|
||||
`mem_limit` and `memswap_limit` are both `120m`, so the container cannot eat
|
||||
host swap on a host that is already swapping. Measured RSS for a Go static
|
||||
server is single-digit MiB; `deploy.sh` prints the real figure after every
|
||||
deploy so drift shows up in the log.
|
||||
|
||||
## Caddy
|
||||
|
||||
`docs/phase-3-deploy.md` §3.3 holds the block for `.35`. It is additive, applied
|
||||
with `caddy reload` (never a restart), and **Sam applies it** — see
|
||||
`docs/phase-3-evidence.md` for the open bind-address question that decides
|
||||
whether that block points at `192.168.20.13:8096` or at `.13`'s Caddy.
|
||||
303
deploy/deploy.sh
Executable file
303
deploy/deploy.sh
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
# resume — release script (Phase 3).
|
||||
#
|
||||
# Deploy is a script, not a model decision (AGENTS.md §4.6).
|
||||
# No one hand-edits files on the production host: change this script in git.
|
||||
#
|
||||
# ./deploy/deploy.sh build, test, release, flip, health-gate
|
||||
# ./deploy/deploy.sh --rollback restore the previous release, one command
|
||||
#
|
||||
# Layout on the runtime host (.13):
|
||||
#
|
||||
# /home/sam/deployment/resume/
|
||||
# ├── releases/<UTC timestamp>/ immutable release: bin/, dist/, compose, manifest
|
||||
# ├── current -> releases/<ts> the live release (atomic symlink flip)
|
||||
# ├── previous -> releases/<ts> the release that was live before the last flip
|
||||
# └── deploy.log append-only record of every deploy attempt
|
||||
#
|
||||
# A release is only "current" after /healthz answers 200 on 127.0.0.1:$PORT.
|
||||
# If the health gate fails, `current` is flipped back to `previous`, the previous
|
||||
# release is restarted, and this script exits non-zero.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_NAME="resume"
|
||||
DEFAULT_PORT=8096
|
||||
DEFAULT_ROOT="/home/sam/deployment/resume"
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT="${DEPLOY_ROOT:-$DEFAULT_ROOT}"
|
||||
PORT="${APP_PORT:-$DEFAULT_PORT}"
|
||||
DO_ROLLBACK=0
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-40}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: deploy.sh [options]
|
||||
|
||||
--rollback restore the previous release and exit
|
||||
--source DIR build from DIR instead of the repo this script lives in
|
||||
--root DIR release root (default /home/sam/deployment/resume)
|
||||
--port N app port (default 8096)
|
||||
--health-timeout N seconds to wait for /healthz (default 40)
|
||||
-h, --help this text
|
||||
|
||||
Environment:
|
||||
GO path to the go toolchain (auto-detected, incl. /nix/store)
|
||||
DEPLOY_ROOT same as --root
|
||||
APP_PORT same as --port
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--rollback) DO_ROLLBACK=1; shift ;;
|
||||
--source) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
|
||||
--root) ROOT="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
RELEASES="$ROOT/releases"
|
||||
CURRENT="$ROOT/current"
|
||||
PREVIOUS="$ROOT/previous"
|
||||
LOG="$ROOT/deploy.log"
|
||||
|
||||
mkdir -p "$RELEASES"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG" >&2; }
|
||||
die() { log "FAIL: $*"; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------- toolchain
|
||||
find_go() {
|
||||
if [[ -n "${GO:-}" && -x "${GO}" ]]; then echo "$GO"; return; fi
|
||||
if command -v go >/dev/null 2>&1; then command -v go; return; fi
|
||||
# NixOS: the toolchain lives in the store even when it is not on PATH.
|
||||
local cand
|
||||
cand=$(ls -d /nix/store/*-go-1.*/bin/go 2>/dev/null | grep -v '\.drv' | sort -V | tail -1)
|
||||
if [[ -n "$cand" && -x "$cand" ]]; then echo "$cand"; return; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
compose() {
|
||||
# $1 = release directory that holds docker-compose.yml + .env
|
||||
local dir="$1"; shift
|
||||
docker compose -p "$APP_NAME" --project-directory "$dir" -f "$dir/docker-compose.yml" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- health gate
|
||||
check_health() {
|
||||
# 200 on /healthz, and if the body is JSON with a "static" field it must be true.
|
||||
# AI readiness is deliberately NOT part of this gate: Mode A must work with
|
||||
# every AI service stopped (acceptance test A1).
|
||||
local url="http://127.0.0.1:${PORT}/healthz" body code
|
||||
body=$(curl -sS --max-time 3 -w '\n%{http_code}' "$url" 2>/dev/null || true)
|
||||
code=$(printf '%s' "$body" | tail -1)
|
||||
body=$(printf '%s' "$body" | sed '$d')
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "health: $url -> HTTP ${code:-none}"
|
||||
return 1
|
||||
fi
|
||||
if printf '%s' "$body" | head -c 1 | grep -q '{' && command -v jq >/dev/null 2>&1; then
|
||||
local static_ok
|
||||
static_ok=$(printf '%s' "$body" | jq -r 'if has("static") then (.static|tostring) else "absent" end' 2>/dev/null || echo absent)
|
||||
if [[ "$static_ok" == "false" ]]; then
|
||||
log "health: $url -> 200 but static readiness is false: $body"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
log "health: $url -> 200"
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local i
|
||||
for ((i = 1; i <= HEALTH_TIMEOUT; i++)); do
|
||||
if check_health; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
container_state() {
|
||||
docker inspect -f 'name={{.Name}} status={{.State.Status}} mem={{.HostConfig.Memory}} memswap={{.HostConfig.MemorySwap}} ports={{json .HostConfig.PortBindings}}' \
|
||||
"${APP_NAME}-app" 2>/dev/null || echo "container ${APP_NAME}-app: absent"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- build + test
|
||||
discover_server() {
|
||||
# Contract (deploy/README.md): the app build produces one server binary at
|
||||
# bin/server or dist/server. `make build` may also emit dist/ for static assets.
|
||||
local d
|
||||
for d in "$1/bin/server" "$1/dist/server" "$1/server"; do
|
||||
if [[ -f "$d" && -x "$d" ]]; then echo "$d"; return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
build_and_test() {
|
||||
local src="$1" out="$2"
|
||||
local gobin=""
|
||||
log "build: source $src"
|
||||
mkdir -p "$out/bin"
|
||||
cd "$src"
|
||||
|
||||
gobin=$(find_go || true)
|
||||
if [[ -z "$gobin" ]]; then
|
||||
log "build: no go toolchain found (set GO=/path/to/go)"
|
||||
else
|
||||
export PATH="$(dirname "$gobin"):$PATH"
|
||||
log "build: go $("$gobin" version)"
|
||||
fi
|
||||
|
||||
if [[ -f Makefile ]] && grep -qE '^build:' Makefile; then
|
||||
log "build: make build"
|
||||
make build
|
||||
else
|
||||
[[ -n "$gobin" ]] || die "no Makefile 'build' target and no go toolchain"
|
||||
log "build: go build ./cmd/server"
|
||||
CGO_ENABLED=0 "$gobin" build -trimpath -buildvcs=false -o "$out/bin/server" ./cmd/server
|
||||
fi
|
||||
|
||||
# Tests fail fast, before anything is copied or flipped.
|
||||
if [[ -f Makefile ]] && grep -qE '^test:' Makefile; then
|
||||
log "test: make test"
|
||||
make test
|
||||
elif [[ -n "$gobin" ]]; then
|
||||
log "test: go test ./..."
|
||||
"$gobin" test ./...
|
||||
fi
|
||||
|
||||
# Collect the binary, wherever the app's build put it.
|
||||
local bin
|
||||
bin=$(discover_server "$src" || true)
|
||||
if [[ -z "$bin" ]]; then
|
||||
bin=$(discover_server "$out" || true)
|
||||
fi
|
||||
[[ -n "$bin" ]] || die "no server binary found (looked for bin/server, dist/server, server)"
|
||||
if [[ "$(readlink -f "$bin")" != "$(readlink -f "$out/bin/server")" ]]; then
|
||||
cp -f "$bin" "$out/bin/server"
|
||||
fi
|
||||
chmod +x "$out/bin/server"
|
||||
log "build: binary $(stat -c %s "$out/bin/server") bytes, sha256 $(sha256sum "$out/bin/server" | cut -c1-16)"
|
||||
|
||||
# Static assets, if the build emits them.
|
||||
if [[ -d "$src/dist" ]]; then
|
||||
cp -a "$src/dist/." "$out/dist/" 2>/dev/null || { mkdir -p "$out/dist"; cp -a "$src/dist/." "$out/dist/"; }
|
||||
log "build: copied dist/ ($(find "$out/dist" -type f | wc -l) files)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- deploy
|
||||
do_deploy() {
|
||||
local src="$1"
|
||||
local ts sha branch release prev_target
|
||||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
sha=$(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
branch=$(git -C "$src" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
|
||||
release="$RELEASES/$ts"
|
||||
|
||||
log "deploy: $APP_NAME $ts (branch $branch, $sha) -> $release"
|
||||
mkdir -p "$release"
|
||||
|
||||
# 1. Build + test. Nothing is touched on failure.
|
||||
build_and_test "$src" "$release"
|
||||
|
||||
# 2. Release payload: binary, compose file, runtime image, manifest.
|
||||
cp -f "$src/deploy/docker-compose.yml" "$release/docker-compose.yml"
|
||||
cp -f "$src/deploy/Dockerfile" "$release/Dockerfile"
|
||||
{
|
||||
echo "app=$APP_NAME"
|
||||
echo "release=$ts"
|
||||
echo "git_sha=$(git -C "$src" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
echo "git_branch=$branch"
|
||||
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "built_on=$(hostname)"
|
||||
echo "binary_sha256=$(sha256sum "$release/bin/server" | cut -d' ' -f1)"
|
||||
} > "$release/manifest.txt"
|
||||
printf 'APP_VERSION=%s\nAPP_PORT=%s\n' "$ts" "$PORT" > "$release/.env"
|
||||
|
||||
# 3. Runtime image, tagged with this release.
|
||||
log "deploy: docker build resume/app:$ts"
|
||||
docker build --quiet -t "resume/app:$ts" \
|
||||
--build-arg "APP_VERSION=$ts" -f "$release/Dockerfile" "$release" >>"$LOG" 2>&1 \
|
||||
|| die "image build failed"
|
||||
|
||||
# 4. Remember what is live now, then flip atomically.
|
||||
prev_target=""
|
||||
if [[ -L "$CURRENT" ]]; then prev_target=$(readlink -f "$CURRENT"); fi
|
||||
ln -sfn "$release" "$CURRENT.tmp" && mv -T "$CURRENT.tmp" "$CURRENT" || die "symlink flip failed"
|
||||
if [[ -n "$prev_target" && "$prev_target" != "$(readlink -f "$release")" ]]; then
|
||||
ln -sfn "$prev_target" "$PREVIOUS.tmp" && mv -T "$PREVIOUS.tmp" "$PREVIOUS"
|
||||
log "deploy: previous -> $prev_target"
|
||||
fi
|
||||
log "deploy: current -> $(readlink -f "$CURRENT")"
|
||||
|
||||
# 5. Bring up, then gate on health. Anything less than 200 means roll back.
|
||||
compose "$release" up -d --remove-orphans >>"$LOG" 2>&1 || log "deploy: compose up returned non-zero"
|
||||
if ! wait_healthy; then
|
||||
log "deploy: HEALTH GATE FAILED for $ts"
|
||||
rollback_internal "$ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "deploy: OK $ts"
|
||||
container_state | tee -a "$LOG" >&2
|
||||
docker stats --no-stream --format 'rss={{.MemUsage}} pct={{.MemPerc}}' "${APP_NAME}-app" 2>/dev/null | tee -a "$LOG" >&2 || true
|
||||
}
|
||||
|
||||
rollback_internal() {
|
||||
# Used by a failed deploy: restore the previous release, restart it, verify.
|
||||
local failed="$1" target
|
||||
if [[ ! -L "$PREVIOUS" ]]; then
|
||||
log "rollback: no previous release to restore; stopping ${APP_NAME}-app"
|
||||
compose "$CURRENT" down >>"$LOG" 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
target=$(readlink -f "$PREVIOUS")
|
||||
log "rollback: $failed failed -> restoring $target"
|
||||
ln -sfn "$target" "$CURRENT.tmp" && mv -T "$CURRENT.tmp" "$CURRENT"
|
||||
compose "$target" up -d --remove-orphans >>"$LOG" 2>&1 || true
|
||||
if wait_healthy; then
|
||||
log "rollback: previous release healthy ($(readlink -f "$CURRENT"))"
|
||||
else
|
||||
log "rollback: previous release ALSO unhealthy — manual attention required"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local target
|
||||
if [[ ! -L "$PREVIOUS" ]]; then die "no previous release at $PREVIOUS — nothing to roll back to"; fi
|
||||
target=$(readlink -f "$PREVIOUS")
|
||||
[[ -d "$target" ]] || die "previous release target missing: $target"
|
||||
log "rollback: current=$( [[ -L "$CURRENT" ]] && readlink -f "$CURRENT" || echo none) -> $target"
|
||||
|
||||
# Keep the release we are leaving, so the rollback is itself reversible.
|
||||
local leaving=""
|
||||
if [[ -L "$CURRENT" ]]; then leaving=$(readlink -f "$CURRENT"); fi
|
||||
ln -sfn "$target" "$CURRENT.tmp" && mv -T "$CURRENT.tmp" "$CURRENT" || die "symlink flip failed"
|
||||
if [[ -n "$leaving" && "$leaving" != "$target" ]]; then
|
||||
ln -sfn "$leaving" "$PREVIOUS.tmp" && mv -T "$PREVIOUS.tmp" "$PREVIOUS"
|
||||
fi
|
||||
|
||||
compose "$target" up -d --remove-orphans >>"$LOG" 2>&1 || log "rollback: compose up returned non-zero"
|
||||
if wait_healthy; then
|
||||
log "rollback: OK, current -> $target"
|
||||
container_state | tee -a "$LOG" >&2
|
||||
exit 0
|
||||
fi
|
||||
log "rollback: FAILED, $target is not healthy"
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$ROOT"
|
||||
log "=== ${APP_NAME} deploy.sh start (pid $$) ==="
|
||||
if (( DO_ROLLBACK == 1 )); then
|
||||
do_rollback
|
||||
else
|
||||
do_deploy "$SRC_DIR"
|
||||
fi
|
||||
41
deploy/docker-compose.yml
Normal file
41
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
# resume — runtime container (Phase 3 §3.2).
|
||||
#
|
||||
# Copied into each release directory by deploy/deploy.sh; the release's .env
|
||||
# supplies APP_VERSION (the image tag). Never edited on the host — change it
|
||||
# here, in git, and deploy again.
|
||||
#
|
||||
# Limits that are not negotiable (AGENTS.md §4.3):
|
||||
# mem_limit == memswap_limit == 120m -> the container cannot touch host swap
|
||||
# ports 127.0.0.1:$PORT:8096 -> Caddy on .35 is the only public listener
|
||||
#
|
||||
# .13 has every default Docker address pool subnetted out (many projects), so a
|
||||
# single-container service uses the host bridge and publishes its port rather
|
||||
# than creating a project network. Same pattern as compass-teams-nhs.
|
||||
|
||||
name: resume
|
||||
|
||||
services:
|
||||
app:
|
||||
image: resume/app:${APP_VERSION}
|
||||
container_name: resume-app
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8096}:8096"
|
||||
environment:
|
||||
PORT: "8096"
|
||||
APP_VERSION: "${APP_VERSION}"
|
||||
# Mode B values arrive in Phase 5 from ~/.config/environment.d/ — never here.
|
||||
mem_limit: 120m
|
||||
memswap_limit: 120m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8096/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
184
deploy/rehearse.sh
Executable file
184
deploy/rehearse.sh
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
# resume — rollback rehearsal (Phase 3 DoD: "Rollback rehearsed once and documented").
|
||||
#
|
||||
# Builds three throwaway Go services in a scratch directory and runs the real
|
||||
# deploy mechanism against the real release root and the real port:
|
||||
#
|
||||
# 1. good -> deploys, health-gated, serving
|
||||
# 2. broken -> does not compile: deploy must fail fast, current must not move
|
||||
# 3. unhealthy -> builds and runs, but /healthz is 500: the health gate must
|
||||
# roll back to the previous release and exit non-zero
|
||||
# 4. good again -> then `deploy.sh --rollback` must restore the earlier release
|
||||
#
|
||||
# It leaves nothing running and removes the release root it created, unless
|
||||
# KEEP_REHEARSAL=1. Nothing outside $DEPLOY_ROOT, the scratch dir and this
|
||||
# service's own container/image is touched.
|
||||
#
|
||||
# ./deploy/rehearse.sh
|
||||
#
|
||||
# Exit 0 means every assertion held; the transcript is the evidence.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY="$HERE/deploy.sh"
|
||||
ROOT="${DEPLOY_ROOT:-/home/sam/deployment/resume}"
|
||||
PORT="${APP_PORT:-8096}"
|
||||
SCRATCH="${REHEARSAL_DIR:-$(mktemp -d -t resume-rehearsal-XXXXXX)}"
|
||||
KEEP="${KEEP_REHEARSAL:-0}"
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' PASS %s\n' "$*"; pass=$((pass + 1)); }
|
||||
no() { printf ' FAIL %s\n' "$*"; fail=$((fail + 1)); }
|
||||
hdr() { printf '\n== %s\n' "$*"; }
|
||||
|
||||
cleanup() {
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx 'resume-app'; then
|
||||
docker rm -f resume-app >/dev/null 2>&1 || true
|
||||
fi
|
||||
docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^resume/app:' \
|
||||
&& docker images --format '{{.Repository}}:{{.Tag}}' | grep '^resume/app:' \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
if [[ "$KEEP" == "1" ]]; then
|
||||
printf '\nkept: release root %s, scratch %s, images resume/app:*\n' "$ROOT" "$SCRATCH"
|
||||
else
|
||||
rm -rf "$ROOT"
|
||||
[[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH"
|
||||
printf '\ncleaned: release root and rehearsal sources removed\n'
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --------------------------------------------------------------- fixtures
|
||||
make_app() { # $1 dir, $2 mode: good|broken|unhealthy
|
||||
local dir="$1" mode="$2"
|
||||
mkdir -p "$dir/cmd/server" "$dir/deploy"
|
||||
cp -f "$HERE/docker-compose.yml" "$HERE/Dockerfile" "$dir/deploy/"
|
||||
printf 'module rehearsal\n\ngo 1.25\n' > "$dir/go.mod"
|
||||
cat > "$dir/cmd/server/main.go" <<'GO'
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8096"
|
||||
}
|
||||
bind := os.Getenv("BIND")
|
||||
if bind == "" {
|
||||
bind = "0.0.0.0"
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
BROKEN
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","static":true,"ai":"disabled","version":%q,"time":%q}`,
|
||||
os.Getenv("APP_VERSION"), time.Now().UTC().Format(time.RFC3339))
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, "<!doctype html><title>rehearsal</title><p>version %s</p>", os.Getenv("APP_VERSION"))
|
||||
})
|
||||
srv := &http.Server{Addr: bind + ":" + port, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
log.Printf("listening on %s:%s", bind, port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
GO
|
||||
local patch=''
|
||||
case "$mode" in
|
||||
good) patch='' ;;
|
||||
unhealthy) patch='w.WriteHeader(http.StatusInternalServerError)' ;;
|
||||
broken) patch='' ;;
|
||||
esac
|
||||
python3 - "$dir/cmd/server/main.go" "$patch" <<'PY'
|
||||
import sys
|
||||
p, patch = sys.argv[1], sys.argv[2]
|
||||
s = open(p).read().replace("BROKEN", patch, 1)
|
||||
open(p, "w").write(s)
|
||||
PY
|
||||
if [[ "$mode" == "broken" ]]; then
|
||||
sed -i 's/^func main() {/func main( {/' "$dir/cmd/server/main.go"
|
||||
fi
|
||||
}
|
||||
|
||||
health() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/healthz" || true; }
|
||||
serving_version() { curl -s -m 3 "http://127.0.0.1:$PORT/healthz" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p'; }
|
||||
current_target() { [[ -L "$ROOT/current" ]] && readlink -f "$ROOT/current" || echo none; }
|
||||
|
||||
# --------------------------------------------------------------- 0. preconditions
|
||||
hdr "0. preconditions"
|
||||
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -q ":$PORT\$"; then
|
||||
no "port $PORT already in use — nothing started"
|
||||
exit 1
|
||||
fi
|
||||
ok "port $PORT free"
|
||||
rm -rf "$ROOT"
|
||||
make_app "$SCRATCH/good" good
|
||||
make_app "$SCRATCH/broken" broken
|
||||
make_app "$SCRATCH/unhealthy" unhealthy
|
||||
ok "three throwaway sources built in $SCRATCH"
|
||||
|
||||
# --------------------------------------------------------------- 1. good deploy
|
||||
hdr "1. good release deploys and health-gates"
|
||||
if "$DEPLOY" --source "$SCRATCH/good" > "$SCRATCH/1-deploy.log" 2>&1; then
|
||||
ok "deploy.sh exit 0"
|
||||
else
|
||||
no "deploy.sh exit non-zero"; tail -20 "$SCRATCH/1-deploy.log"; exit 1
|
||||
fi
|
||||
[[ "$(health)" == "200" ]] && ok "/healthz 200 on 127.0.0.1:$PORT" || no "/healthz not 200"
|
||||
v1=$(serving_version); [[ -n "$v1" ]] && ok "serving release $v1" || no "no version reported"
|
||||
ss -ltn | grep -q "127.0.0.1:$PORT" && ok "container publishes on 127.0.0.1 only" || no "unexpected bind"
|
||||
mem=$(docker inspect -f '{{.HostConfig.Memory}}' resume-app); swp=$(docker inspect -f '{{.HostConfig.MemorySwap}}' resume-app)
|
||||
[[ "$mem" == "$swp" && "$mem" != "0" ]] && ok "memswap_limit == mem_limit ($mem bytes)" || no "mem limits differ ($mem vs $swp)"
|
||||
|
||||
# --------------------------------------------------------------- 2. broken build
|
||||
hdr "2. build failure fails fast, current does not move"
|
||||
before=$(current_target)
|
||||
set +e; "$DEPLOY" --source "$SCRATCH/broken" > "$SCRATCH/2-broken.log" 2>&1; rc=$?; set -e
|
||||
[[ $rc -ne 0 ]] && ok "deploy.sh exit $rc (non-zero, as required)" || no "broken build reported success"
|
||||
[[ "$(current_target)" == "$before" ]] && ok "current unchanged ($before)" || no "current moved after a failed build"
|
||||
[[ "$(health)" == "200" ]] && ok "previous release still serving" || no "site down after failed build"
|
||||
|
||||
# --------------------------------------------------------------- 3. health gate
|
||||
hdr "3. unhealthy release is rolled back by the health gate"
|
||||
before=$(current_target)
|
||||
set +e; HEALTH_TIMEOUT=10 "$DEPLOY" --source "$SCRATCH/unhealthy" > "$SCRATCH/3-unhealthy.log" 2>&1; rc=$?; set -e
|
||||
[[ $rc -ne 0 ]] && ok "deploy.sh exit $rc (non-zero, as required)" || no "unhealthy release reported success"
|
||||
[[ "$(current_target)" == "$before" ]] && ok "current restored to $before" || no "current left on $(current_target)"
|
||||
[[ "$(health)" == "200" ]] && ok "rolled-back release serving again" || no "no healthy release after rollback"
|
||||
grep -q 'HEALTH GATE FAILED' "$SCRATCH/3-unhealthy.log" && ok "log records the health-gate failure" || no "no health-gate failure recorded"
|
||||
|
||||
# --------------------------------------------------------------- 4. --rollback
|
||||
hdr "4. deploy.sh --rollback restores the previous release in one command"
|
||||
"$DEPLOY" --source "$SCRATCH/good" > "$SCRATCH/4-deploy.log" 2>&1 && ok "second good release deployed" || no "second deploy failed"
|
||||
v2=$(serving_version)
|
||||
prev=$(readlink -f "$ROOT/previous")
|
||||
set +e; "$DEPLOY" --rollback > "$SCRATCH/4-rollback.log" 2>&1; rc=$?; set -e
|
||||
[[ $rc -eq 0 ]] && ok "rollback exit 0" || no "rollback exit $rc"
|
||||
[[ "$(readlink -f "$ROOT/current")" == "$prev" ]] && ok "current flipped back to $prev" || no "current not restored"
|
||||
[[ "$(health)" == "200" ]] && ok "rolled-back release healthy" || no "rolled-back release unhealthy"
|
||||
v3=$(serving_version)
|
||||
[[ -n "$v3" && "$v3" != "$v2" ]] && ok "serving $v3 again (was $v2)" || no "version did not change on rollback"
|
||||
|
||||
# --------------------------------------------------------------- 5. restart
|
||||
hdr "5. N3: docker compose down && up restores the service"
|
||||
rel=$(readlink -f "$ROOT/current")
|
||||
docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" down >/dev/null 2>&1
|
||||
[[ "$(health)" != "200" ]] && ok "service down after compose down" || no "still serving after compose down"
|
||||
docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" up -d >/dev/null 2>&1
|
||||
sleep 3
|
||||
[[ "$(health)" == "200" ]] && ok "service restored after compose up" || no "service did not come back"
|
||||
|
||||
# --------------------------------------------------------------- verdict
|
||||
hdr "verdict"
|
||||
printf 'passed %d, failed %d\n' "$pass" "$fail"
|
||||
[[ $fail -eq 0 ]] || exit 1
|
||||
Reference in New Issue
Block a user