Skip to content

Commit 3839ce3

Browse files
baijumclaude
andcommitted
feat: add blue-green deploys, backup verification, credential rotation, tests, and CLI (phases 12-18)
- Blue-green deploy script with zero-downtime slot alternation and automatic rollback - Backup verification script (restore to temp DB, check tables, drop) - Credential rotation script (PostgreSQL/MinIO, no downtime) - Validator test suite (13 tests, tier 1+2 coverage, fixtures) - Developer CLI wrapping 13 common operations (status, logs, deploy, etc.) - ADR 006 documenting blue-green deploy decision - Updated server-contract, bootstrap, ecosystem docs - CI workflow for running platform tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a8d4aac commit 3839ce3

25 files changed

Lines changed: 1328 additions & 10 deletions

.github/workflows/test.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: '3.12'
19+
cache: pip
20+
21+
- name: Install dependencies
22+
run: pip install -r requirements-test.txt
23+
24+
- name: Run tests
25+
run: pytest tests/ -v

cli/towlion

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Towlion CLI — wraps common platform operations
5+
VERSION="1.0.0"
6+
7+
# Colors
8+
RED='\033[0;31m'
9+
GREEN='\033[0;32m'
10+
YELLOW='\033[1;33m'
11+
CYAN='\033[0;36m'
12+
BOLD='\033[1m'
13+
NC='\033[0m'
14+
15+
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
16+
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
17+
error() { echo -e "${RED}[ERROR]${NC} $*"; }
18+
19+
# Load config
20+
CONFIG_FILE="${HOME}/.towlion.conf"
21+
22+
load_config() {
23+
if [ -f "$CONFIG_FILE" ]; then
24+
# shellcheck source=/dev/null
25+
source "$CONFIG_FILE"
26+
fi
27+
28+
SERVER_HOST="${SERVER_HOST:-}"
29+
SERVER_USER="${SERVER_USER:-deploy}"
30+
SSH_KEY_PATH="${SSH_KEY_PATH:-${HOME}/.ssh/id_rsa}"
31+
}
32+
33+
require_config() {
34+
if [ -z "$SERVER_HOST" ]; then
35+
error "SERVER_HOST not configured."
36+
echo "Create ${CONFIG_FILE} with:"
37+
echo " SERVER_HOST=your-server-ip"
38+
echo " SERVER_USER=deploy"
39+
echo " SSH_KEY_PATH=~/.ssh/id_rsa"
40+
exit 1
41+
fi
42+
}
43+
44+
ssh_cmd() {
45+
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new "${SERVER_USER}@${SERVER_HOST}" "$@"
46+
}
47+
48+
# --- Commands ---
49+
50+
cmd_status() {
51+
require_config
52+
echo -e "${BOLD}App Status${NC}"
53+
echo ""
54+
ssh_cmd 'for dir in /opt/apps/*/; do
55+
app=$(basename "$dir")
56+
[[ "$app" == *-pr-* ]] && continue
57+
slot_file="${dir}.deploy-slot"
58+
if [ -f "$slot_file" ]; then
59+
slot=$(cat "$slot_file")
60+
container="${app}-${slot}-app-1"
61+
else
62+
container="${app}-app-1"
63+
fi
64+
status=$(docker inspect --format="{{.State.Status}}" "$container" 2>/dev/null || echo "not found")
65+
health=$(docker inspect --format="{{.State.Health.Status}}" "$container" 2>/dev/null || echo "n/a")
66+
printf " %-20s status=%-10s health=%-10s\n" "$app" "$status" "$health"
67+
done'
68+
}
69+
70+
cmd_logs() {
71+
require_config
72+
local app="${1:-}"
73+
if [ -z "$app" ]; then
74+
error "Usage: towlion logs <app-name>"
75+
exit 1
76+
fi
77+
ssh_cmd "cd /opt/apps/${app} && \
78+
slot_file=.deploy-slot; \
79+
if [ -f \"\$slot_file\" ]; then \
80+
slot=\$(cat \"\$slot_file\"); \
81+
docker compose -p ${app}-\${slot} -f deploy/docker-compose.yml logs -f --tail 100; \
82+
else \
83+
docker compose -p ${app} -f deploy/docker-compose.yml logs -f --tail 100; \
84+
fi"
85+
}
86+
87+
cmd_health() {
88+
local target="${1:-all}"
89+
90+
if [ "$target" = "all" ]; then
91+
require_config
92+
echo -e "${BOLD}Health Check — All Apps${NC}"
93+
echo ""
94+
ssh_cmd 'for dir in /opt/apps/*/; do
95+
app=$(basename "$dir")
96+
[[ "$app" == *-pr-* ]] && continue
97+
env_file="${dir}deploy/.env"
98+
if [ -f "$env_file" ]; then
99+
domain=$(grep "^APP_DOMAIN=" "$env_file" 2>/dev/null | cut -d= -f2 || echo "")
100+
if [ -n "$domain" ]; then
101+
if curl -sf "https://${domain}/health" >/dev/null 2>&1; then
102+
printf " %-20s %s\n" "$app" "healthy"
103+
else
104+
printf " %-20s %s\n" "$app" "UNHEALTHY"
105+
fi
106+
else
107+
printf " %-20s %s\n" "$app" "no domain configured"
108+
fi
109+
fi
110+
done'
111+
else
112+
require_config
113+
ssh_cmd "cd /opt/apps/${target} && \
114+
domain=\$(grep '^APP_DOMAIN=' deploy/.env 2>/dev/null | cut -d= -f2 || echo ''); \
115+
if [ -n \"\$domain\" ]; then \
116+
curl -sf \"https://\${domain}/health\" && echo; \
117+
else \
118+
echo 'No APP_DOMAIN in deploy/.env'; \
119+
fi"
120+
fi
121+
}
122+
123+
cmd_create() {
124+
local app="${1:-}"
125+
if [ -z "$app" ]; then
126+
error "Usage: towlion create <app-name>"
127+
exit 1
128+
fi
129+
130+
if ! command -v gh &>/dev/null; then
131+
error "GitHub CLI (gh) is required. Install: https://cli.github.com"
132+
exit 1
133+
fi
134+
135+
info "Creating repo towlion/${app} from template..."
136+
gh repo create "towlion/${app}" --template towlion/app-template --public --clone
137+
138+
require_config
139+
info "Provisioning credentials on server..."
140+
ssh_cmd "sudo bash /opt/platform/infrastructure/create-app-credentials.sh ${app}"
141+
142+
info "Cloning repo on server..."
143+
ssh_cmd "cd /opt/apps && git clone https://github.com/towlion/${app}.git"
144+
145+
info "Creating deploy/.env from template..."
146+
ssh_cmd "cd /opt/apps/${app} && cp deploy/env.template deploy/.env"
147+
148+
echo ""
149+
info "App created: towlion/${app}"
150+
info "Next steps:"
151+
echo " 1. Edit deploy/.env on the server: ssh ${SERVER_USER}@${SERVER_HOST}"
152+
echo " 2. Set GitHub secrets: SERVER_HOST, SERVER_USER, SERVER_SSH_KEY, APP_DOMAIN"
153+
echo " 3. Push to main to trigger first deploy"
154+
}
155+
156+
cmd_deploy() {
157+
local app="${1:-}"
158+
if [ -z "$app" ]; then
159+
error "Usage: towlion deploy <app-name>"
160+
exit 1
161+
fi
162+
163+
if ! command -v gh &>/dev/null; then
164+
error "GitHub CLI (gh) is required."
165+
exit 1
166+
fi
167+
168+
info "Triggering deploy workflow for towlion/${app}..."
169+
gh workflow run deploy.yml --repo "towlion/${app}"
170+
info "Deploy triggered. Watch with: gh run list --repo towlion/${app}"
171+
}
172+
173+
cmd_restart() {
174+
require_config
175+
local app="${1:-}"
176+
if [ -z "$app" ]; then
177+
error "Usage: towlion restart <app-name>"
178+
exit 1
179+
fi
180+
181+
info "Restarting ${app}..."
182+
ssh_cmd "cd /opt/apps/${app} && \
183+
slot_file=.deploy-slot; \
184+
if [ -f \"\$slot_file\" ]; then \
185+
slot=\$(cat \"\$slot_file\"); \
186+
docker compose -p ${app}-\${slot} -f deploy/docker-compose.yml restart; \
187+
else \
188+
docker compose -p ${app} -f deploy/docker-compose.yml restart; \
189+
fi"
190+
info "Restart complete"
191+
}
192+
193+
cmd_backup() {
194+
require_config
195+
local target="${1:-all}"
196+
197+
if [ "$target" = "all" ]; then
198+
info "Running backup for all databases..."
199+
ssh_cmd "bash /opt/platform/infrastructure/backup-postgres.sh"
200+
else
201+
info "Running backup for ${target}..."
202+
local db_name
203+
db_name=$(echo "$target" | tr '-' '_')_db
204+
ssh_cmd "docker compose -f /opt/platform/docker-compose.yml exec -T postgres \
205+
pg_dump -U postgres -Fc ${db_name} > /data/backups/postgres/${db_name}_\$(date +%Y%m%d_%H%M%S).dump"
206+
info "Backup complete"
207+
fi
208+
}
209+
210+
cmd_rotate() {
211+
require_config
212+
local app="${1:-}"
213+
if [ -z "$app" ]; then
214+
error "Usage: towlion rotate <app-name> [--type db|s3|all]"
215+
exit 1
216+
fi
217+
shift
218+
local extra_args="$*"
219+
220+
info "Rotating credentials for ${app}..."
221+
ssh_cmd "bash /opt/platform/infrastructure/rotate-credentials.sh ${app} ${extra_args}"
222+
}
223+
224+
cmd_ssh() {
225+
require_config
226+
info "Connecting to ${SERVER_USER}@${SERVER_HOST}..."
227+
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new "${SERVER_USER}@${SERVER_HOST}"
228+
}
229+
230+
cmd_tunnel() {
231+
require_config
232+
local port="${1:-}"
233+
if [ -z "$port" ]; then
234+
error "Usage: towlion tunnel <port>"
235+
echo " Example: towlion tunnel 5432 (PostgreSQL)"
236+
echo " Example: towlion tunnel 3000 (Grafana)"
237+
exit 1
238+
fi
239+
240+
info "Opening SSH tunnel: localhost:${port}${SERVER_HOST}:${port}"
241+
info "Press Ctrl+C to close"
242+
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new \
243+
-L "${port}:localhost:${port}" -N "${SERVER_USER}@${SERVER_HOST}"
244+
}
245+
246+
cmd_verify() {
247+
require_config
248+
info "Running server verification..."
249+
ssh_cmd "bash /opt/platform/infrastructure/verify-server.sh"
250+
}
251+
252+
cmd_alerts() {
253+
require_config
254+
info "Checking alerts..."
255+
ssh_cmd ". /opt/platform/.env && bash /opt/platform/infrastructure/check-alerts.sh"
256+
}
257+
258+
cmd_help() {
259+
echo -e "${BOLD}towlion${NC} v${VERSION} — Towlion platform CLI"
260+
echo ""
261+
echo -e "${BOLD}Usage:${NC} towlion <command> [options]"
262+
echo ""
263+
echo -e "${BOLD}Commands:${NC}"
264+
echo " status List all apps with container state and health"
265+
echo " logs <app> Tail app logs"
266+
echo " health [app|all] Check /health endpoints"
267+
echo " create <app> Create repo from template + provision credentials"
268+
echo " deploy <app> Trigger deploy workflow via GitHub Actions"
269+
echo " restart <app> Restart app containers"
270+
echo " backup [app|all] Run backup now"
271+
echo " rotate <app> Rotate credentials (--type db|s3|all)"
272+
echo " ssh SSH to server"
273+
echo " tunnel <port> SSH tunnel (e.g., towlion tunnel 5432)"
274+
echo " verify Run verify-server.sh"
275+
echo " alerts Run check-alerts.sh"
276+
echo " help Show this help"
277+
echo " version Show version"
278+
echo ""
279+
echo -e "${BOLD}Config:${NC} ${CONFIG_FILE}"
280+
echo " SERVER_HOST=<ip> Server IP address"
281+
echo " SERVER_USER=deploy SSH username (default: deploy)"
282+
echo " SSH_KEY_PATH=~/.ssh/id_rsa SSH key path"
283+
}
284+
285+
cmd_version() {
286+
echo "towlion v${VERSION}"
287+
}
288+
289+
# --- Main ---
290+
291+
load_config
292+
293+
COMMAND="${1:-help}"
294+
shift 2>/dev/null || true
295+
296+
case "$COMMAND" in
297+
status) cmd_status "$@" ;;
298+
logs) cmd_logs "$@" ;;
299+
health) cmd_health "$@" ;;
300+
create) cmd_create "$@" ;;
301+
deploy) cmd_deploy "$@" ;;
302+
restart) cmd_restart "$@" ;;
303+
backup) cmd_backup "$@" ;;
304+
rotate) cmd_rotate "$@" ;;
305+
ssh) cmd_ssh "$@" ;;
306+
tunnel) cmd_tunnel "$@" ;;
307+
verify) cmd_verify "$@" ;;
308+
alerts) cmd_alerts "$@" ;;
309+
help) cmd_help ;;
310+
version) cmd_version ;;
311+
*)
312+
error "Unknown command: $COMMAND"
313+
echo ""
314+
cmd_help
315+
exit 1
316+
;;
317+
esac

0 commit comments

Comments
 (0)