Skip to content

Commit b9bf50c

Browse files
ImTotemclaude
andcommitted
feat(infra): automate full first-deploy pipeline
- Add alembic migration step to deploy.sh (creates PG tables) - Add idempotent seed step (Sheets→PG only when tables empty) - Add n8n init script (workflow import, credential setup, activation) - Add network alias `api` for blue-green routing from n8n - Add env var validation (12 required vars checked before deploy) - Mount workflows + service account into n8n container - Include alembic files in Docker image - Whitelist workflows/*.json in .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 330e4cd commit b9bf50c

File tree

8 files changed

+628
-18
lines changed

8 files changed

+628
-18
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ $RECYCLE.BIN/
511511
!package.json
512512
!tsconfig.json
513513
!pyrightconfig.json
514+
!workflows/*.json
514515

515516
# Generated nginx conf (envsubst output from .template)
516517
infra/nginx/bcsd-api.conf

infra/docker/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.
1717
COPY --from=deps /usr/local/bin /usr/local/bin
1818

1919
COPY pyproject.toml .
20+
COPY alembic.ini .
21+
COPY alembic/ alembic/
2022
COPY src/ src/
2123
RUN pip install --no-cache-dir --no-deps .
2224

infra/docker/docker-compose.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ services:
1313
command: ["uvicorn", "bcsd_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "${GRACEFUL_TIMEOUT}"]
1414
stop_grace_period: ${GRACEFUL_TIMEOUT}s
1515
networks:
16-
- bcsd
16+
bcsd:
17+
aliases:
18+
- api
1719

1820
api-green:
1921
<<: *api
@@ -33,8 +35,12 @@ services:
3335
N8N_BASIC_AUTH_USER: "${N8N_AUTH_USER}"
3436
N8N_BASIC_AUTH_PASSWORD: "${N8N_AUTH_PASSWORD}"
3537
N8N_ENCRYPTION_KEY: "${N8N_ENCRYPTION_KEY}"
38+
SYNC_TOKEN: "${SYNC_TOKEN}"
39+
GOOGLE_SHEETS_ID: "${GOOGLE_SHEETS_ID}"
3640
volumes:
3741
- n8n-data:/home/node/.n8n
42+
- ../../workflows:/workflows:ro
43+
- ../../${GOOGLE_SERVICE_ACCOUNT_FILE}:/credentials/sa.json:ro
3844
networks:
3945
- bcsd
4046

infra/scripts/deploy.sh

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ HEALTH_PATH="/openapi.json"
1111
MAX_RETRIES=10
1212
ENVSUBST_VARS='${API_BLUE_PORT} ${API_GREEN_PORT} ${N8N_PORT} ${FRONTEND_PORT} ${DOMAIN} ${N8N_DOMAIN} ${FRONTEND_DOMAIN}'
1313

14+
REQUIRED_VARS=(
15+
POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB
16+
GOOGLE_SERVICE_ACCOUNT_FILE GOOGLE_SHEETS_ID
17+
SYNC_TOKEN JWT_SECRET GOOGLE_CLIENT_ID
18+
API_BLUE_PORT API_GREEN_PORT N8N_PORT
19+
)
20+
1421
current_slot() {
1522
grep proxy_pass "$NGINX_CONF" 2>/dev/null | grep -q "api_blue" && echo "blue" || echo "green"
1623
}
@@ -34,33 +41,38 @@ health_check() {
3441
return 1
3542
}
3643

37-
check_credentials() {
44+
check_env() {
3845
if [ ! -f .env ]; then
39-
echo "FAIL: .env not found (CI/CD should have uploaded it)"
46+
echo "FAIL: .env not found"
4047
exit 1
4148
fi
42-
local sa_file
43-
sa_file=$(grep -m1 '^GOOGLE_SERVICE_ACCOUNT_FILE=' .env | cut -d= -f2-)
44-
if [ -z "$sa_file" ]; then
45-
echo "FAIL: GOOGLE_SERVICE_ACCOUNT_FILE not set in .env"
49+
set -a; source .env; set +a
50+
local missing=()
51+
for var in "${REQUIRED_VARS[@]}"; do
52+
if [ -z "${!var:-}" ]; then
53+
missing+=("$var")
54+
fi
55+
done
56+
if [ ${#missing[@]} -gt 0 ]; then
57+
echo "FAIL: Missing env vars: ${missing[*]}"
4658
exit 1
4759
fi
48-
if [ ! -f "$sa_file" ]; then
49-
echo "FAIL: $sa_file not found (CI/CD should have uploaded it)"
60+
if [ ! -f "$GOOGLE_SERVICE_ACCOUNT_FILE" ]; then
61+
echo "FAIL: $GOOGLE_SERVICE_ACCOUNT_FILE not found"
5062
exit 1
5163
fi
5264
}
5365

5466
render_nginx() {
55-
set -a; source .env; set +a
5667
envsubst "$ENVSUBST_VARS" < "$NGINX_TEMPLATE" > "$NGINX_CONF"
5768
}
5869

5970
echo "=== BCSD API Blue-Green Deploy ==="
6071

61-
check_credentials
72+
echo "0. Checking environment..."
73+
check_env
6274

63-
echo "0. Ensuring DB services..."
75+
echo "1. Ensuring DB services..."
6476
$COMPOSE_DB up -d
6577

6678
render_nginx
@@ -70,13 +82,19 @@ NEXT=$(next_slot)
7082

7183
echo "Current: $CURRENT → Deploying: $NEXT"
7284

73-
echo "1. Building $NEXT..."
85+
echo "2. Building $NEXT..."
7486
$COMPOSE build "api-${NEXT}"
7587

76-
echo "2. Starting $NEXT..."
88+
echo "3. Running DB migrations..."
89+
$COMPOSE run --rm --no-deps "api-${NEXT}" alembic upgrade head
90+
91+
echo "4. Seeding PG from Sheets (if empty)..."
92+
$COMPOSE run --rm --no-deps "api-${NEXT}" python -m bcsd_api.sync.seed
93+
94+
echo "5. Starting $NEXT..."
7795
$COMPOSE up -d --remove-orphans "api-${NEXT}"
7896

79-
echo "3. Health check on api-${NEXT}..."
97+
echo "6. Health check on api-${NEXT}..."
8098
if ! health_check "$NEXT"; then
8199
echo "FAIL: $NEXT did not become healthy"
82100
echo "--- Container logs ---"
@@ -86,14 +104,17 @@ if ! health_check "$NEXT"; then
86104
exit 1
87105
fi
88106

89-
echo "4. Switching nginx → $NEXT"
107+
echo "7. Switching nginx → $NEXT"
90108
sed -i "s/proxy_pass http:\/\/api_${CURRENT}/proxy_pass http:\/\/api_${NEXT}/g" "$NGINX_CONF"
91109
sudo mkdir -p /var/www/certbot
92110
sudo cp "$NGINX_CONF" "$NGINX_AVAILABLE"
93111
sudo ln -sf "$NGINX_AVAILABLE" "$NGINX_ENABLED"
94112
sudo nginx -t && sudo nginx -s reload
95113

96-
echo "5. Stopping old ($CURRENT)..."
114+
echo "8. Stopping old ($CURRENT)..."
97115
$COMPOSE stop "api-${CURRENT}"
98116

117+
echo "9. Initializing n8n..."
118+
bash infra/scripts/init_n8n.sh
119+
99120
echo "=== Deploy complete: $NEXT is live ==="

infra/scripts/init_n8n.sh

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# n8n workflow import + Google Sheets credential setup
5+
# Idempotent: skips if workflows already exist
6+
7+
COMPOSE="sudo docker compose -p bcsd-app --env-file .env -f infra/docker/docker-compose.yml"
8+
N8N_CONTAINER="bcsd-app-n8n-1"
9+
MAX_RETRIES=15
10+
11+
wait_n8n() {
12+
local retries=0
13+
while [ $retries -lt $MAX_RETRIES ]; do
14+
if $COMPOSE exec -T n8n wget -qO- http://localhost:5678/healthz > /dev/null 2>&1; then
15+
return 0
16+
fi
17+
retries=$((retries + 1))
18+
echo " n8n not ready, retry ${retries}/${MAX_RETRIES}..."
19+
sleep 2
20+
done
21+
return 1
22+
}
23+
24+
workflow_exists() {
25+
local name="$1"
26+
local count
27+
count=$($COMPOSE exec -T n8n n8n list:workflow 2>/dev/null | grep -c "$name" || true)
28+
[ "$count" -gt 0 ]
29+
}
30+
31+
import_workflow() {
32+
local file="$1"
33+
local name="$2"
34+
if workflow_exists "$name"; then
35+
echo " Workflow '$name' already exists — skipping"
36+
return 0
37+
fi
38+
echo " Importing '$name'..."
39+
$COMPOSE exec -T n8n n8n import:workflow --input="$file"
40+
}
41+
42+
activate_workflow() {
43+
local name="$1"
44+
local wf_id
45+
wf_id=$($COMPOSE exec -T n8n n8n list:workflow 2>/dev/null | grep "$name" | awk '{print $1}')
46+
if [ -z "$wf_id" ]; then
47+
echo " WARNING: Could not find workflow '$name' to activate"
48+
return 1
49+
fi
50+
$COMPOSE exec -T n8n n8n update:workflow --id="$wf_id" --active=true
51+
echo " Activated '$name' (id: $wf_id)"
52+
}
53+
54+
setup_credential() {
55+
local sa_path="/credentials/sa.json"
56+
local existing
57+
existing=$($COMPOSE exec -T n8n n8n list:credential 2>/dev/null | grep -c "Google Sheets SA" || true)
58+
if [ "$existing" -gt 0 ]; then
59+
echo " Google Sheets credential already exists — skipping"
60+
return 0
61+
fi
62+
echo " Creating Google Sheets service account credential..."
63+
$COMPOSE exec -T n8n n8n import:credentials --input=/dev/stdin <<CRED_EOF
64+
[{
65+
"name": "Google Sheets SA",
66+
"type": "googleApi",
67+
"data": {
68+
"email": "$(grep client_email "$GOOGLE_SERVICE_ACCOUNT_FILE" | cut -d'"' -f4)",
69+
"privateKey": "$(grep private_key "$GOOGLE_SERVICE_ACCOUNT_FILE" | cut -d'"' -f4)",
70+
"impersonateUser": ""
71+
}
72+
}]
73+
CRED_EOF
74+
}
75+
76+
echo "=== n8n Init ==="
77+
78+
echo "1. Starting n8n..."
79+
$COMPOSE up -d n8n
80+
81+
echo "2. Waiting for n8n..."
82+
if ! wait_n8n; then
83+
echo "FAIL: n8n did not become healthy"
84+
$COMPOSE logs --tail=20 n8n
85+
exit 1
86+
fi
87+
88+
echo "3. Importing workflows..."
89+
import_workflow "/workflows/pg_sheets_sync.json" "PG → Sheets Sync (5min)"
90+
import_workflow "/workflows/link_auto_expire.json" "Link Auto-Expiration (hourly)"
91+
92+
echo "4. Setting up Google Sheets credential..."
93+
set -a; source .env; set +a
94+
setup_credential
95+
96+
echo "5. Activating workflows..."
97+
activate_workflow "PG → Sheets Sync"
98+
activate_workflow "Link Auto-Expiration"
99+
100+
echo "=== n8n Init complete ==="

src/bcsd_api/sync/seed.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from sqlalchemy import text
34
from sqlalchemy.dialects.postgresql import insert
45

56
from bcsd_api.config import Settings
@@ -52,6 +53,11 @@ def _seed_table(conn, sheets: SheetsClient, name: str) -> int:
5253
return len(cleaned)
5354

5455

56+
def _is_empty(conn) -> bool:
57+
row = conn.execute(text("SELECT count(*) FROM members"))
58+
return row.scalar() == 0
59+
60+
5561
def run_seed() -> None:
5662
settings = Settings()
5763
sheets = SheetsClient(
@@ -68,6 +74,19 @@ def run_seed() -> None:
6874
logger.info("Seed complete")
6975

7076

77+
def run_seed_if_empty() -> None:
78+
settings = Settings()
79+
engine = create_engine(settings.database_url)
80+
with engine.connect() as conn:
81+
if not _is_empty(conn):
82+
logger.info("Tables not empty — skipping seed")
83+
engine.dispose()
84+
return
85+
engine.dispose()
86+
logger.info("Tables empty — running seed")
87+
run_seed()
88+
89+
7190
if __name__ == "__main__":
7291
logging.basicConfig(level=logging.INFO)
73-
run_seed()
92+
run_seed_if_empty()

0 commit comments

Comments
 (0)