Phase 4 slice 2: two-service release deploy (backend + frontend) on the v0.3 line #33
+82
-19
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# ResearchX release deploy (ciagent release system)
|
||||
#
|
||||
# Builds the frontend at a given version tag on the Docker host and
|
||||
# runs it as a container. Idempotent: re-deploying a version replaces
|
||||
# the running container, keeping the previous one as rollback.
|
||||
# Builds the frontend and the backend at a given version tag on the
|
||||
# Docker host and runs them as two containers. Idempotent: re-deploying
|
||||
# a version replaces the running containers, keeping the previous ones
|
||||
# as rollback.
|
||||
#
|
||||
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
|
||||
#
|
||||
@@ -21,8 +22,14 @@ IMAGE_NAME="researchx"
|
||||
CONTAINER_NAME="researchx"
|
||||
CONTAINER_PREV="researchx-prev"
|
||||
HOST_PORT="3000"
|
||||
IMAGE_BACKEND="researchx-backend"
|
||||
CONTAINER_BACKEND="researchx-backend"
|
||||
CONTAINER_BACKEND_PREV="researchx-backend-prev"
|
||||
HOST_PORT_BACKEND="8000"
|
||||
BACKEND_DATA_DIR="/var/lib/researchx"
|
||||
REMOTE_BASE="/opt/researchx"
|
||||
HEALTH_PATH="/ask"
|
||||
BACKEND_HEALTH_PATH="/healthz"
|
||||
HEALTH_RETRIES=15
|
||||
HEALTH_INTERVAL=2
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -38,6 +45,7 @@ VERSION="$1"
|
||||
|
||||
# ---- dependency checks --------------------------------------------------
|
||||
command -v ssh >/dev/null || die "ssh not found" 2
|
||||
command -v uv >/dev/null || die "uv not found (backend gates need it: pip install uv)" 2
|
||||
|
||||
# ---- secrets (env file ONLY) -------------------------------------------
|
||||
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
|
||||
@@ -156,37 +164,72 @@ if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
|
||||
WORKTREE=$(mktemp -d)
|
||||
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
|
||||
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
|
||||
BUILD_DIR="$WORKTREE/frontend"
|
||||
BUILD_ROOT="$WORKTREE"
|
||||
else
|
||||
BUILD_DIR="$REPO_ROOT/frontend"
|
||||
BUILD_ROOT="$REPO_ROOT"
|
||||
fi
|
||||
BUILD_DIR="$BUILD_ROOT/frontend"
|
||||
BACKEND_DIR="$BUILD_ROOT/backend"
|
||||
|
||||
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
|
||||
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
|
||||
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
|
||||
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
|
||||
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
|
||||
log "gates passed: typecheck, lint, test, build"
|
||||
log "frontend gates passed: typecheck, lint, test, build"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Backend gates: the repo's own commands, same as the CI backend job
|
||||
# (.gitea/workflows/ci.yml), run from the committed lockfile.
|
||||
(cd "$BACKEND_DIR" && uv sync --frozen >/dev/null 2>&1) || die "backend dependency sync failed at $VERSION" 3
|
||||
(cd "$BACKEND_DIR" && uv run ruff check src tests >/dev/null) || die "backend ruff failed at $VERSION" 3
|
||||
(cd "$BACKEND_DIR" && uv run mypy src >/dev/null) || die "backend mypy failed at $VERSION" 3
|
||||
(cd "$BACKEND_DIR" && uv run pytest >/dev/null) || die "backend pytest failed at $VERSION" 3
|
||||
log "backend gates passed: ruff, mypy, pytest"
|
||||
|
||||
# ---- remote host prep ----------------------------------------------------
|
||||
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
|
||||
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
|
||||
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
|
||||
|
||||
# ---- sync source ---------------------------------------------------------
|
||||
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
|
||||
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|
||||
|| die "source sync failed" 4
|
||||
RELEASE_DIR="$REMOTE_BASE/releases/$BASE_VERSION"
|
||||
log "syncing source to host ($RELEASE_DIR) via $SYNC_TRANSPORT"
|
||||
sync_source "$BUILD_DIR" "$RELEASE_DIR/frontend" || die "frontend source sync failed" 4
|
||||
sync_source "$BACKEND_DIR" "$RELEASE_DIR/backend" || die "backend source sync failed" 4
|
||||
|
||||
# ---- remote build --------------------------------------------------------
|
||||
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
|
||||
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
|
||||
log "building docker images $IMAGE_BACKEND:$VERSION and $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
|
||||
SSH "cd '$RELEASE_DIR/backend' && \
|
||||
docker build -t '$IMAGE_BACKEND:$VERSION' -t '$IMAGE_BACKEND:latest' ." \
|
||||
|| die "backend docker build failed on host" 4
|
||||
SSH "cd '$RELEASE_DIR/frontend' && \
|
||||
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|
||||
|| die "docker build failed on host" 4
|
||||
|| die "frontend docker build failed on host" 4
|
||||
|
||||
# ---- swap container ------------------------------------------------------
|
||||
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
|
||||
log "swapping backend container (previous kept as $CONTAINER_BACKEND_PREV for rollback)"
|
||||
SSH "
|
||||
docker rm -f '$CONTAINER_BACKEND_PREV' 2>/dev/null || true
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_BACKEND'; then
|
||||
docker rename '$CONTAINER_BACKEND' '$CONTAINER_BACKEND_PREV'
|
||||
docker stop '$CONTAINER_BACKEND_PREV' >/dev/null 2>&1 || true
|
||||
fi
|
||||
mkdir -p '$BACKEND_DATA_DIR'
|
||||
# The container runs as the image's non-root user and a bind mount
|
||||
# SHADOWS the image's own (correctly owned) /var/lib/researchx, so a
|
||||
# root-owned host dir makes every write to the data root (users.json,
|
||||
# sessions.json, keys/) fail with EACCES while /healthz still answers
|
||||
# 200. Take the owner from the image itself.
|
||||
chown -R \$(docker run --rm --entrypoint stat '$IMAGE_BACKEND:$VERSION' -c '%u:%g' /var/lib/researchx) '$BACKEND_DATA_DIR'
|
||||
docker run -d --name '$CONTAINER_BACKEND' --restart unless-stopped \
|
||||
-p '$HOST_PORT_BACKEND:8000' \
|
||||
-v '$BACKEND_DATA_DIR:$BACKEND_DATA_DIR' \
|
||||
-e RESEARCHX_DATA_DIR='$BACKEND_DATA_DIR' \
|
||||
'$IMAGE_BACKEND:$VERSION'
|
||||
" || die "backend docker run failed" 5
|
||||
|
||||
log "swapping frontend container (previous kept as $CONTAINER_PREV for rollback)"
|
||||
SSH "
|
||||
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
|
||||
@@ -195,19 +238,39 @@ SSH "
|
||||
fi
|
||||
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
|
||||
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
|
||||
" || die "docker run failed" 5
|
||||
" || die "frontend docker run failed" 5
|
||||
|
||||
# ---- health check ---------------------------------------------------------
|
||||
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
|
||||
log "health-checking backend http://$DEPLOY_HOST:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (retries: $HEALTH_RETRIES)"
|
||||
i=0
|
||||
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
|
||||
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH'"; then
|
||||
log "backend healthy after $((i + 1)) check(s)"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
[ "$i" -lt "$HEALTH_RETRIES" ] || die "backend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
|
||||
|
||||
# The health check above is a GET: it proves the app answers, not that it
|
||||
# can persist. Probe the write path the auth/key stores use, so a
|
||||
# mis-owned data root fails the deploy instead of the first signup.
|
||||
log "checking the backend can write its data root"
|
||||
SSH "docker exec '$CONTAINER_BACKEND' sh -c 'touch \"\$RESEARCHX_DATA_DIR/.write-probe\" && rm -f \"\$RESEARCHX_DATA_DIR/.write-probe\"'" \
|
||||
|| die "backend data root is not writable by the container user" 5
|
||||
|
||||
log "health-checking frontend http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
|
||||
i=0
|
||||
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
|
||||
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
|
||||
log "healthy after $((i + 1)) check(s)"
|
||||
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
|
||||
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
|
||||
log "frontend healthy after $((i + 1)) check(s)"
|
||||
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (containers $CONTAINER_NAME, $CONTAINER_BACKEND)"
|
||||
log "backend: http://${DEPLOY_HOST%%:*}:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (container $CONTAINER_BACKEND)"
|
||||
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME $CONTAINER_BACKEND; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker rename $CONTAINER_BACKEND_PREV $CONTAINER_BACKEND; docker start $CONTAINER_NAME $CONTAINER_BACKEND'"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
|
||||
die "frontend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
|
||||
Reference in New Issue
Block a user