diff --git a/.ciagent/release/deploy.sh b/.ciagent/release/deploy.sh index 6a5bde1..2d29b67 100755 --- a/.ciagent/release/deploy.sh +++ b/.ciagent/release/deploy.sh @@ -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 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 \ No newline at end of file +die "frontend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5 \ No newline at end of file