-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdeploy-dev.sh
More file actions
executable file
Β·610 lines (556 loc) Β· 25.5 KB
/
Copy pathdeploy-dev.sh
File metadata and controls
executable file
Β·610 lines (556 loc) Β· 25.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
#!/bin/bash
set -e
# ============================================================================
# OpenMentor Local Development Deployment Script
# ============================================================================
# Same CLI and flow as ./deploy.sh, but the target is the LOCAL docker daemon
# instead of the production VM: images are built with local dev-<sha> tags
# (no registry push), the tags are written to the local infra/.env, and the
# dev compose stack (docker-compose.yml + docker-compose.dev.yml) converges
# exactly like production does β only services whose image tag changed are
# recreated.
#
# ./deploy-dev.sh [targets...] [options]
#
# Targets (default: frontend backend):
# frontend build ../web as openmentor-frontend:dev-<sha>, roll frontend
# backend build ../api as openmentor-backend:dev-<sha>, roll
# migrate + backend + worker (one image, three services; migrate
# runs before backend/worker via depends_on)
# infra converge compose-level changes (`up -d --remove-orphans`) β
# the "sync" is a no-op locally (the files are already here) β
# and restart services whose bind-mounted config changed
# all frontend backend infra
#
# Options:
# --tag TAG use TAG for built images instead of dev-<git sha>
# --yes, -y skip the confirmation prompt
# --dry-run print the deployment plan and exit without doing anything
# -h, --help show help
#
# Bind-mount trap (infra target): compose does not react to changes in
# bind-mounted config files. The only file-config service is alloy
# (./alloy/config.alloy); locally we restart it when the file is newer than
# the running container (alloy only runs with `--profile observability`).
#
# The stack mirrors production: traefik (HTTP-only on :80) / frontend /
# backend / worker / migrate / postgres (dev creds, host :5433). alloy and
# cadvisor are opt-in via `--profile observability`; postgres-backup never
# runs in dev. See docker-compose.dev.yml for the full parity notes.
# ============================================================================
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
usage() {
echo "Usage: $0 [targets...] [options]"
echo ""
echo "Targets (default: frontend backend):"
echo " frontend Build + deploy the frontend image locally"
echo " backend Build + deploy the backend image locally (backend/worker/migrate)"
echo " infra Converge compose-level changes of the local stack"
echo " all frontend backend infra"
echo ""
echo "Options:"
echo " --tag TAG Use TAG for built images instead of dev-<git sha>"
echo " --yes, -y Skip the confirmation prompt"
echo " --dry-run Print the deployment plan and exit"
echo " -h, --help Show this help message"
echo ""
echo "Services not being deployed keep their current image tags (from .env)."
}
# --------------------------------------------------------------------------
# Parse command line arguments (same CLI as deploy.sh, minus --staging)
# --------------------------------------------------------------------------
DEPLOY_FRONTEND=false
DEPLOY_BACKEND=false
DEPLOY_INFRA=false
TARGETS_GIVEN=false
TAG_OVERRIDE=""
SKIP_CONFIRM=false
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case $1 in
frontend)
DEPLOY_FRONTEND=true; TARGETS_GIVEN=true; shift ;;
backend)
DEPLOY_BACKEND=true; TARGETS_GIVEN=true; shift ;;
infra)
DEPLOY_INFRA=true; TARGETS_GIVEN=true; shift ;;
all)
DEPLOY_FRONTEND=true; DEPLOY_BACKEND=true; DEPLOY_INFRA=true
TARGETS_GIVEN=true; shift ;;
--tag)
if [ -z "$2" ]; then
echo -e "${RED}β --tag requires a value${NC}"; exit 1
fi
TAG_OVERRIDE="$2"; shift 2 ;;
--yes|-y)
SKIP_CONFIRM=true; shift ;;
--dry-run)
DRY_RUN=true; shift ;;
-h|--help)
usage; exit 0 ;;
*)
echo -e "${RED}Unknown target/option: $1${NC}"
echo "Use --help for usage information"
exit 1 ;;
esac
done
# Default targets: frontend backend
if [ "$TARGETS_GIVEN" = false ]; then
DEPLOY_FRONTEND=true
DEPLOY_BACKEND=true
fi
# --------------------------------------------------------------------------
# Configuration
# --------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$SCRIPT_DIR/../web"
BACKEND_DIR="$SCRIPT_DIR/../api"
ENV_FILE="$SCRIPT_DIR/.env"
COMPOSE=(docker compose -f "$SCRIPT_DIR/docker-compose.yml" -f "$SCRIPT_DIR/docker-compose.dev.yml")
# Read a KEY=value from the local .env (empty string when absent)
env_get() {
grep "^$1=" "$ENV_FILE" 2>/dev/null | head -n1 | cut -d'=' -f2-
}
# Set KEY=value in the local .env (replace existing line or append)
env_set() {
local key="$1" value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
# BSD/GNU sed compatible in-place edit
sed -i.sedbak "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" && rm -f "$ENV_FILE.sedbak"
else
printf '%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
}
echo -e "${GREEN}π OpenMentor Local Development Deployment${NC}"
echo "============================================"
echo ""
# --------------------------------------------------------------------------
# Pre-flight checks (the local equivalent of credential validation)
# --------------------------------------------------------------------------
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}β Docker is not running!${NC}"
echo "Please start Docker Desktop and try again."
exit 1
fi
# Check that the monorepo's frontend and backend directories are present
if [ ! -d "$FRONTEND_DIR" ]; then
echo -e "${RED}β Frontend directory not found at $FRONTEND_DIR${NC}"
echo "Run this script from infra/ of a full monorepo checkout:"
echo " git clone https://github.com/openmentor-io/openmentor.git && cd openmentor/infra"
exit 1
fi
if [ ! -d "$BACKEND_DIR" ]; then
echo -e "${RED}β Backend directory not found at $BACKEND_DIR${NC}"
echo "Run this script from infra/ of a full monorepo checkout:"
echo " git clone https://github.com/openmentor-io/openmentor.git && cd openmentor/infra"
exit 1
fi
# Create .env with dev defaults if absent (NEVER commit it β gitignored).
# Placeholders are fine for a booting stack; fill in S3/SES/PostHog values
# for full functionality.
if [ ! -f "$ENV_FILE" ]; then
echo -e "${YELLOW}β οΈ .env not found β creating it from .env.example with dev defaults${NC}"
cp "$SCRIPT_DIR/.env.example" "$ENV_FILE"
env_set "DOMAIN" "localhost"
env_set "APP_ENV" "development"
# Keeps a dev stack's metrics out of the production deployment's alert
# instances if it is ever pointed at the shared Grafana Cloud tenant
env_set "DEPLOYMENT_NAME" "local"
env_set "LOG_LEVEL" "debug"
env_set "NEXT_PUBLIC_APP_ENV" "development"
env_set "JWT_SECRET" "$(openssl rand -hex 32)"
env_set "WORKER_AUTH_TOKEN" "$(openssl rand -hex 32)"
echo -e "${GREEN} β’ .env created (dev defaults + generated JWT/worker secrets)${NC}"
echo -e "${YELLOW} β’ Fill in S3/SES/PostHog values in .env for full functionality${NC}"
fi
# Load app version
if [ -f "$SCRIPT_DIR/version" ]; then
source "$SCRIPT_DIR/version"
else
APP_VERSION="dev"
fi
# --------------------------------------------------------------------------
# Image tags: dev-<monorepo short sha> (real, unique tags β convergence works
# exactly like production; see DOCKER_TAG_POLICY.md). --tag overrides.
# --------------------------------------------------------------------------
GIT_SHA=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || date +%Y%m%d-%H%M%S)
if [ "$DEPLOY_FRONTEND" = true ]; then
FRONTEND_GIT_TAG="${TAG_OVERRIDE:-dev-$GIT_SHA}"
fi
if [ "$DEPLOY_BACKEND" = true ]; then
BACKEND_GIT_TAG="${TAG_OVERRIDE:-dev-$GIT_SHA}"
fi
# --------------------------------------------------------------------------
# Deployment plan
# --------------------------------------------------------------------------
echo "Target: local docker (dev compose stack)"
echo "App version: $APP_VERSION"
echo ""
echo "Deployment plan:"
if [ "$DEPLOY_FRONTEND" = true ]; then
echo -e " β’ frontend: BUILD + DEPLOY (${BLUE}openmentor-frontend:$FRONTEND_GIT_TAG${NC})"
else
echo -e " β’ frontend: ${YELLOW}keep current tag${NC}"
fi
if [ "$DEPLOY_BACKEND" = true ]; then
echo -e " β’ backend: BUILD + DEPLOY (${BLUE}openmentor-backend:$BACKEND_GIT_TAG${NC}) β backend + worker + migrate"
else
echo -e " β’ backend: ${YELLOW}keep current tag${NC}"
fi
if [ "$DEPLOY_INFRA" = true ]; then
echo -e " β’ infra: converge compose (up -d --remove-orphans) + restart bind-mount-config services"
else
echo -e " β’ infra: ${YELLOW}skip${NC}"
fi
echo ""
if [ "$DRY_RUN" = true ]; then
echo -e "${YELLOW}--dry-run: stopping here, nothing was executed.${NC}"
exit 0
fi
if [ "$SKIP_CONFIRM" = false ]; then
read -p "$(echo -e ${YELLOW}Do you want to proceed with deployment? \(yes/no\):${NC} )" -r
echo
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
echo -e "${YELLOW}Deployment cancelled${NC}"
exit 0
fi
fi
# --------------------------------------------------------------------------
# Step 1/7: Build frontend image
# --------------------------------------------------------------------------
if [ "$DEPLOY_FRONTEND" = true ]; then
echo -e "${BLUE}ποΈ Step 1/7: Building frontend image...${NC}"
cd "$FRONTEND_DIR"
# Load NEXT_PUBLIC_* build args from .env (same mechanism as deploy.sh
# uses with .env.production). Preserve computed tags across the source.
SAVED_FRONTEND_TAG="$FRONTEND_GIT_TAG"
SAVED_BACKEND_TAG="$BACKEND_GIT_TAG"
# shellcheck source=/dev/null # a developer's runtime .env, not a repo file
source "$ENV_FILE" 2>/dev/null || true
FRONTEND_GIT_TAG="$SAVED_FRONTEND_TAG"
BACKEND_GIT_TAG="$SAVED_BACKEND_TAG"
NEXT_PUBLIC_GO_API_URL="${NEXT_PUBLIC_GO_API_URL:-http://backend:8081}"
NEXT_PUBLIC_TURNSTILE_SITE_KEY="${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"
NEXT_PUBLIC_S3_STORAGE_ENDPOINT="${NEXT_PUBLIC_S3_STORAGE_ENDPOINT:-s3.eu-central-1.amazonaws.com}"
NEXT_PUBLIC_S3_STORAGE_BUCKET="${NEXT_PUBLIC_S3_STORAGE_BUCKET:-mentor-images}"
NEXT_PUBLIC_CDN_ENDPOINT="${NEXT_PUBLIC_CDN_ENDPOINT:-}"
NEXT_PUBLIC_O11Y_SERVICE_NAMESPACE="${NEXT_PUBLIC_O11Y_SERVICE_NAMESPACE:-openmentor-frontend}"
NEXT_PUBLIC_O11Y_FE_SERVICE_VERSION="${NEXT_PUBLIC_O11Y_FE_SERVICE_VERSION:-$APP_VERSION}"
NEXT_PUBLIC_FARO_APP_NAME="${NEXT_PUBLIC_FARO_APP_NAME:-openmentor-frontend}"
NEXT_PUBLIC_FARO_COLLECTOR_URL="${NEXT_PUBLIC_FARO_COLLECTOR_URL}"
NEXT_PUBLIC_FARO_SAMPLE_RATE="${NEXT_PUBLIC_FARO_SAMPLE_RATE:-0.5}"
NEXT_PUBLIC_APP_ENV="${NEXT_PUBLIC_APP_ENV:-development}"
NEXT_PUBLIC_ANALYTICS_PROVIDER="${NEXT_PUBLIC_ANALYTICS_PROVIDER:-posthog}"
NEXT_PUBLIC_ANALYTICS_EVENT_VERSION="${NEXT_PUBLIC_ANALYTICS_EVENT_VERSION:-v1}"
NEXT_PUBLIC_POSTHOG_KEY="${NEXT_PUBLIC_POSTHOG_KEY}"
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}"
echo "Building with configuration:"
echo " β’ API URL: $NEXT_PUBLIC_GO_API_URL"
echo " β’ Environment: $NEXT_PUBLIC_APP_ENV"
echo " β’ Analytics provider: $NEXT_PUBLIC_ANALYTICS_PROVIDER"
docker build \
--build-arg NEXT_PUBLIC_GO_API_URL="$NEXT_PUBLIC_GO_API_URL" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY="$NEXT_PUBLIC_TURNSTILE_SITE_KEY" \
--build-arg NEXT_PUBLIC_S3_STORAGE_ENDPOINT="$NEXT_PUBLIC_S3_STORAGE_ENDPOINT" \
--build-arg NEXT_PUBLIC_S3_STORAGE_BUCKET="$NEXT_PUBLIC_S3_STORAGE_BUCKET" \
--build-arg NEXT_PUBLIC_CDN_ENDPOINT="$NEXT_PUBLIC_CDN_ENDPOINT" \
--build-arg NEXT_PUBLIC_O11Y_SERVICE_NAMESPACE="$NEXT_PUBLIC_O11Y_SERVICE_NAMESPACE" \
--build-arg NEXT_PUBLIC_O11Y_FE_SERVICE_VERSION="$NEXT_PUBLIC_O11Y_FE_SERVICE_VERSION" \
--build-arg NEXT_PUBLIC_FARO_APP_NAME="$NEXT_PUBLIC_FARO_APP_NAME" \
--build-arg NEXT_PUBLIC_FARO_COLLECTOR_URL="$NEXT_PUBLIC_FARO_COLLECTOR_URL" \
--build-arg NEXT_PUBLIC_FARO_SAMPLE_RATE="$NEXT_PUBLIC_FARO_SAMPLE_RATE" \
--build-arg NEXT_PUBLIC_POSTHOG_KEY="$NEXT_PUBLIC_POSTHOG_KEY" \
--build-arg NEXT_PUBLIC_POSTHOG_HOST="$NEXT_PUBLIC_POSTHOG_HOST" \
--build-arg NEXT_PUBLIC_APP_ENV="$NEXT_PUBLIC_APP_ENV" \
--build-arg NEXT_PUBLIC_ANALYTICS_PROVIDER="$NEXT_PUBLIC_ANALYTICS_PROVIDER" \
--build-arg NEXT_PUBLIC_ANALYTICS_EVENT_VERSION="$NEXT_PUBLIC_ANALYTICS_EVENT_VERSION" \
-t "openmentor-frontend:$FRONTEND_GIT_TAG" \
.
if [ $? -ne 0 ]; then
echo -e "${RED}β Failed to build frontend image${NC}"
exit 1
fi
echo -e "${GREEN}β
Frontend image built${NC}"
else
echo -e "${YELLOW}βοΈ Step 1/7: Skipping frontend build${NC}"
fi
echo ""
# --------------------------------------------------------------------------
# Step 2/7: Build backend image
# --------------------------------------------------------------------------
if [ "$DEPLOY_BACKEND" = true ]; then
echo -e "${BLUE}ποΈ Step 2/7: Building backend image...${NC}"
cd "$BACKEND_DIR"
docker build \
--target runner \
-t "openmentor-backend:$BACKEND_GIT_TAG" \
.
if [ $? -ne 0 ]; then
echo -e "${RED}β Failed to build backend image${NC}"
exit 1
fi
echo -e "${GREEN}β
Backend image built${NC}"
else
echo -e "${YELLOW}βοΈ Step 2/7: Skipping backend build${NC}"
fi
echo ""
# --------------------------------------------------------------------------
# Step 3/7: Resolve current tags for services not being deployed
# --------------------------------------------------------------------------
if [ "$DEPLOY_FRONTEND" = false ] || [ "$DEPLOY_BACKEND" = false ]; then
echo -e "${BLUE}π‘ Step 3/7: Resolving current image tags from .env...${NC}"
CURRENT_FRONTEND_TAG=$(env_get "FRONTEND_IMAGE_TAG")
CURRENT_BACKEND_TAG=$(env_get "BACKEND_IMAGE_TAG")
if [ "$DEPLOY_FRONTEND" = false ] && [ -z "$CURRENT_FRONTEND_TAG" ]; then
echo -e "${RED}β FRONTEND_IMAGE_TAG not set in .env β no current frontend to keep${NC}"
echo "Run './deploy-dev.sh all' (or include the frontend target) first."
exit 1
fi
if [ "$DEPLOY_BACKEND" = false ] && [ -z "$CURRENT_BACKEND_TAG" ]; then
echo -e "${RED}β BACKEND_IMAGE_TAG not set in .env β no current backend to keep${NC}"
echo "Run './deploy-dev.sh all' (or include the backend target) first."
exit 1
fi
echo -e "${GREEN}β
Current tags:${NC}"
echo " β’ Frontend: ${CURRENT_FRONTEND_TAG:-<being deployed>}"
echo " β’ Backend: ${CURRENT_BACKEND_TAG:-<being deployed>}"
else
echo -e "${YELLOW}βοΈ Step 3/7: All images rebuilt β current tags not needed${NC}"
fi
echo ""
FRONTEND_IMAGE_TAG="${FRONTEND_GIT_TAG:-$CURRENT_FRONTEND_TAG}"
BACKEND_IMAGE_TAG="${BACKEND_GIT_TAG:-$CURRENT_BACKEND_TAG}"
# --------------------------------------------------------------------------
# Step 4/7: Update local .env with the image tags (backup kept for rollback)
# --------------------------------------------------------------------------
echo -e "${BLUE}π Step 4/7: Updating image tags in .env...${NC}"
cp "$ENV_FILE" "$ENV_FILE.backup"
env_set "FRONTEND_IMAGE_TAG" "$FRONTEND_IMAGE_TAG"
env_set "BACKEND_IMAGE_TAG" "$BACKEND_IMAGE_TAG"
# SECURITY (P10): no .env.runtime any more - services declare explicit
# `environment:` allowlists and .env alone drives compose interpolation.
rm -f "$SCRIPT_DIR/.env.runtime"
echo " β’ FRONTEND_IMAGE_TAG=$FRONTEND_IMAGE_TAG"
echo " β’ BACKEND_IMAGE_TAG=$BACKEND_IMAGE_TAG"
echo -e "${GREEN}β
.env updated (previous version in .env.backup)${NC}"
echo ""
# --------------------------------------------------------------------------
# Step 5/7: Infra convergence flags (local "sync" is a no-op)
# --------------------------------------------------------------------------
UP_FLAGS=""
RESTART_ALLOY=0
if [ "$DEPLOY_INFRA" = true ]; then
echo -e "${BLUE}π Step 5/7: Infra target β compose-level changes will converge...${NC}"
UP_FLAGS="--remove-orphans"
# Bind-mount trap: alloy's config is a bind-mounted file compose won't
# react to. Restart alloy if it is running an older config than on disk
# (alloy only runs when the `observability` profile is enabled).
ALLOY_ID=$("${COMPOSE[@]}" ps -q alloy 2>/dev/null || true)
if [ -n "$ALLOY_ID" ]; then
ALLOY_STARTED=$(docker inspect -f '{{.State.StartedAt}}' "$ALLOY_ID" 2>/dev/null || echo "")
if [ -n "$ALLOY_STARTED" ]; then
ALLOY_STARTED_EPOCH=$(date -d "$ALLOY_STARTED" +%s 2>/dev/null || \
date -j -f "%Y-%m-%dT%H:%M:%S" "${ALLOY_STARTED%%.*}" +%s 2>/dev/null || echo 0)
CONFIG_MTIME=$(stat -f %m "$SCRIPT_DIR/alloy/config.alloy" 2>/dev/null || \
stat -c %Y "$SCRIPT_DIR/alloy/config.alloy" 2>/dev/null || echo 0)
if [ "$CONFIG_MTIME" -gt "$ALLOY_STARTED_EPOCH" ]; then
RESTART_ALLOY=1
echo -e "${YELLOW} β» alloy/config.alloy is newer than the running alloy β will restart it${NC}"
fi
fi
fi
echo -e "${GREEN}β
Infra convergence prepared${NC}"
else
echo -e "${YELLOW}βοΈ Step 5/7: Skipping infra convergence${NC}"
fi
echo ""
# --------------------------------------------------------------------------
# Step 6/7: Deploy (converge the compose stack)
# --------------------------------------------------------------------------
echo -e "${BLUE}π’ Step 6/7: Converging the dev stack...${NC}"
cd "$SCRIPT_DIR"
# Pre-flight: published host ports must be free or already owned by this
# project. A foreign process holding a port (e.g. another compose stack's
# postgres on POSTGRES_DEV_PORT) causes partial startups that are painful
# to diagnose - fail fast with a pointer instead.
PG_PORT="${POSTGRES_DEV_PORT:-5433}"
for port in 80 3000 8081 8090 "$PG_PORT"; do
holder=$(docker ps --filter "publish=$port" --format '{{.Names}}' | head -1)
if [ -n "$holder" ]; then
case "$holder" in
openmentor-*|traefik) ;; # ours - compose will converge it
*)
echo -e "${RED}β Host port $port is held by container '$holder' (not part of this stack).${NC}"
echo " Stop it, or (for postgres) set POSTGRES_DEV_PORT in infra/.env to a free port."
exit 1
;;
esac
fi
done
# The base compose file declares the Postgres data volume as external
# (protects production data from `down -v`); create it idempotently so the
# merged config always resolves. The dev overlay mounts its own
# openmentor-postgres-data-dev volume instead. Postgres image pin bumps are
# safe: the container is recreated, data volumes persist (minor versions
# only β major upgrades follow ../docs/runbooks/postgres-backup-restore.md).
docker volume create openmentor-postgres-data > /dev/null
# Converge: compose recreates ONLY the services whose image tag (or
# definition) changed β same semantics as the production deploy.
"${COMPOSE[@]}" up -d $UP_FLAGS
if [ $? -ne 0 ]; then
echo -e "${RED}β Failed to start services${NC}"
exit 1
fi
# Post-up guard: verify every running project container is actually
# attached to the compose network. Docker can (rarely - seen with a port
# conflict during a delayed image-pull start) bring a container up with no
# network endpoint; its healthcheck still passes because it runs inside
# the container, and everything that needs to reach it then fails DNS.
# Self-heal once with a force-recreate.
for svc in $("${COMPOSE[@]}" ps --services 2>/dev/null); do
cid=$("${COMPOSE[@]}" ps -q "$svc" 2>/dev/null | head -1)
[ -n "$cid" ] || continue
running=$(docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null)
[ "$running" = "true" ] || continue
nets=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid")
if [ -z "${nets// /}" ]; then
echo -e "${YELLOW}β οΈ '$svc' is running but detached from the network - force-recreating...${NC}"
"${COMPOSE[@]}" up -d --force-recreate "$svc"
nets=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$("${COMPOSE[@]}" ps -q "$svc" | head -1)")
if [ -z "${nets// /}" ]; then
echo -e "${RED}β '$svc' still has no network after recreate - aborting.${NC}"
exit 1
fi
echo -e "${GREEN} '$svc' reattached (${nets})${NC}"
fi
done
if [ "$RESTART_ALLOY" = "1" ]; then
echo "β» Restarting alloy (bind-mounted config changed)..."
"${COMPOSE[@]}" restart alloy
fi
echo -e "${GREEN}β
Services converged${NC}"
echo ""
echo -e "${BLUE}π Service Status:${NC}"
"${COMPOSE[@]}" ps
echo ""
# --------------------------------------------------------------------------
# Step 7/7: Health checks (+ automatic rollback to previous tags on failure)
# --------------------------------------------------------------------------
echo -e "${BLUE}π₯ Step 7/7: Verifying deployment...${NC}"
POSTGRES_HEALTHY=0
FRONTEND_HEALTHY=0
BACKEND_HEALTHY=0
WORKER_HEALTHY=0
TRAEFIK_HEALTHY=0
# Postgres first β everything depends on it
for i in {1..30}; do
echo -n " β’ Checking PostgreSQL (attempt $i/30)... "
if docker exec openmentor-postgres-dev pg_isready -U openmentor > /dev/null 2>&1; then
echo -e "${GREEN}β${NC}"
POSTGRES_HEALTHY=1
break
else
echo -e "${YELLOW}waiting...${NC}"
sleep 2
fi
done
# Backend (with retries)
for i in {1..12}; do
echo -n " β’ Checking backend health (attempt $i/12)... "
if curl -f -s http://localhost:8081/api/healthcheck > /dev/null 2>&1; then
echo -e "${GREEN}β${NC}"
BACKEND_HEALTHY=1
break
else
echo -e "${YELLOW}waiting...${NC}"
sleep 5
fi
done
# Worker (with retries)
for i in {1..6}; do
echo -n " β’ Checking worker health (attempt $i/6)... "
if curl -f -s http://localhost:8090/healthz > /dev/null 2>&1; then
echo -e "${GREEN}β${NC}"
WORKER_HEALTHY=1
break
else
echo -e "${YELLOW}waiting...${NC}"
sleep 5
fi
done
# Frontend, direct port (with retries)
for i in {1..12}; do
echo -n " β’ Checking frontend health (attempt $i/12)... "
if curl -f -s http://localhost:3000/api/healthcheck > /dev/null 2>&1; then
echo -e "${GREEN}β${NC}"
FRONTEND_HEALTHY=1
break
else
echo -e "${YELLOW}waiting...${NC}"
sleep 5
fi
done
# Frontend via traefik (HTTP-only localhost routing)
for i in {1..3}; do
echo -n " β’ Checking traefik route http://localhost/ (attempt $i/3)... "
if curl -f -s http://localhost/api/healthcheck > /dev/null 2>&1; then
echo -e "${GREEN}β${NC}"
TRAEFIK_HEALTHY=1
break
else
echo -e "${YELLOW}waiting...${NC}"
sleep 3
fi
done
echo ""
# Automatic rollback to the previous tags if a core service is unhealthy
if [ $POSTGRES_HEALTHY -eq 0 ] || [ $FRONTEND_HEALTHY -eq 0 ] || \
[ $BACKEND_HEALTHY -eq 0 ] || [ $WORKER_HEALTHY -eq 0 ]; then
echo -e "${RED}β Health checks failed${NC}"
if [ -f "$ENV_FILE.backup" ] && ! cmp -s "$ENV_FILE" "$ENV_FILE.backup"; then
echo -e "${YELLOW}π ROLLING BACK to previous image tags (.env.backup)...${NC}"
cp "$ENV_FILE.backup" "$ENV_FILE"
"${COMPOSE[@]}" up -d $UP_FLAGS
echo -e "${YELLOW}Previous .env restored and stack re-converged.${NC}"
fi
echo "Check logs: ${COMPOSE[*]} logs -f"
exit 1
fi
echo -e "${GREEN}ββββββββββββββββββββββββββββββββββββββββ${NC}"
echo -e "${GREEN}β¨ Deployment completed successfully! β¨${NC}"
echo -e "${GREEN}ββββββββββββββββββββββββββββββββββββββββ${NC}"
echo ""
echo "π Deployment Summary:"
echo " β’ Frontend: openmentor-frontend:$FRONTEND_IMAGE_TAG"
echo " β’ Backend: openmentor-backend:$BACKEND_IMAGE_TAG"
echo " β’ App Version: $APP_VERSION"
echo " β’ Environment: development"
if [ $TRAEFIK_HEALTHY -eq 0 ]; then
echo -e " β’ ${YELLOW}β οΈ traefik route http://localhost/ did not answer (direct ports work)${NC}"
fi
echo ""
echo "π Access Services:"
echo " β’ Frontend (traefik): http://localhost/"
echo " β’ Frontend (direct): http://localhost:3000"
echo " β’ Backend: http://localhost:8081/api/healthcheck"
echo " β’ Worker: http://localhost:8090/healthz"
echo ""
echo "ποΈ Database (dev credentials, host port 5433):"
echo -e " ${GREEN}postgresql://openmentor:password@localhost:5433/openmentor?sslmode=disable${NC}"
echo " psql: docker exec -it openmentor-postgres-dev psql -U openmentor"
echo ""
echo "π Useful Commands (compose = docker compose -f docker-compose.yml -f docker-compose.dev.yml):"
echo " β’ View logs: docker compose logs -f [frontend|backend|worker|postgres]"
echo " β’ Service status: docker compose ps"
echo " β’ Stop services: docker compose down"
echo " β’ Reset dev data: docker compose down && docker volume rm openmentor-postgres-data-dev"
echo " β’ Observability: add '--profile observability' to run alloy + cadvisor"
echo " (needs real GCLOUD_* creds and alloy-secrets/ β see docker-compose.dev.yml)"
echo " β’ Redeploy: ./deploy-dev.sh [frontend|backend|infra|all]"
echo ""