diff --git a/.dockerignore b/.dockerignore index 75375b7..2101bc9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,23 @@ +.git +.github +.vscode +.idea +.claude +node_modules +build /repositories db_backups -build -node_modules -.github \ No newline at end of file +coverage +.nyc_output +*.log +.env +.env.* +tests +test +docs +*.md +claude-files +scripts +.dockerignore +Dockerfile* +docker-compose*.yml diff --git a/Dockerfile b/Dockerfile index dac7176..02a75a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,40 @@ -# syntax=docker/dockerfile:1 -FROM node:21-slim AS builder +# syntax=docker/dockerfile:1.7 +# Stage 1: install all deps (with cache mount so npm cache survives across builds) +FROM node:21-slim AS deps WORKDIR /app - COPY package.json package-lock.json ./ -RUN npm ci +RUN --mount=type=cache,target=/root/.npm \ + npm ci --prefer-offline --no-audit --fund=false -COPY tsconfig.json gulpfile.js ./ +# Stage 2: build TypeScript + gulp assets. Source changes only invalidate from here. +FROM node:21-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json package-lock.json tsconfig.json gulpfile.js ./ COPY public ./public COPY src ./src -RUN npm run build && npm prune --omit=dev && npm cache clean --force +RUN npm run build +# Stage 3: prod-only deps. Independent of source so it caches well. +FROM node:21-slim AS prod-deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --omit=dev --prefer-offline --no-audit --fund=false && \ + npm cache clean --force + +# Stage 4: minimal runtime FROM node:21-alpine AS runtime - ENV NODE_ENV=production ENV PORT=5000 EXPOSE 5000 - WORKDIR /app -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/build ./build -COPY --from=builder /app/public ./public -COPY --from=builder /app/package.json ./package.json +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/build ./build +COPY --from=build /app/public ./public +COPY package.json ./package.json COPY healthcheck.js ./healthcheck.js CMD ["node", "./build/server/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 9449e47..cdb0fd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: anonymous_github: build: . @@ -21,13 +19,17 @@ services: - CMD - node - healthcheck.js - interval: 10s - timeout: 10s - retries: 5 + interval: 5s + timeout: 5s + retries: 3 + start_period: 2s depends_on: - - mongodb - - redis - - streamer + mongodb: + condition: service_healthy + redis: + condition: service_healthy + streamer: + condition: service_healthy streamer: build: . @@ -50,9 +52,10 @@ services: - CMD - node - healthcheck.js - interval: 10s - timeout: 10s - retries: 5 + interval: 5s + timeout: 5s + retries: 3 + start_period: 2s redis: image: "redis:alpine" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..e7ecf68 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Fast local image build with BuildKit enabled. +set -euo pipefail +cd "$(dirname "$0")/.." + +export DOCKER_BUILDKIT=1 +export COMPOSE_DOCKER_CLI_BUILD=1 + +docker compose build "$@" anonymous_github diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..ff1e9f2 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Zero-downtime redeploy: +# build → blue/green streamer swap → recreate API. +# Skips everything if the rebuilt image hasn't changed (FORCE=1 to override). +set -euo pipefail +cd "$(dirname "$0")/.." + +export DOCKER_BUILDKIT=1 +export COMPOSE_DOCKER_CLI_BUILD=1 + +IMAGE="${IMAGE:-tdurieux/anonymous_github:v2}" +REPLICAS="${STREAMER_REPLICAS:-4}" +TIMEOUT="${HEALTH_TIMEOUT:-90}" +FORCE="${FORCE:-0}" + +log() { printf '==> %s\n' "$*"; } +warn() { printf '!! %s\n' "$*" >&2; } + +image_id() { docker image inspect --format '{{.Id}}' "$1" 2>/dev/null || true; } + +wait_healthy() { + local name="$1" waited=0 status + while (( waited < TIMEOUT )); do + status="$(docker inspect -f \ + '{{if .State.Health}}{{.State.Health.Status}}{{else if .State.Running}}running{{else}}stopped{{end}}' \ + "$name" 2>/dev/null || echo missing)" + [[ $status == healthy || $status == running ]] && return 0 + sleep 2; waited=$((waited + 2)) + done + warn "$name not healthy after ${TIMEOUT}s (status=$status)" + return 1 +} + +list_streamers() { + docker compose ps --status running --format '{{.Name}}' streamer 2>/dev/null +} + +# 1. Build +log "Building image" +before="$(image_id "$IMAGE")" +docker compose build anonymous_github +after="$(image_id "$IMAGE")" + +# 2. Cold start +mapfile -t olds < <(list_streamers) +if [[ ${#olds[@]} -eq 0 ]]; then + log "Cold start — bringing the whole stack up" + docker compose up -d --remove-orphans + docker compose ps + exit 0 +fi + +# 3. No-op if image unchanged +if [[ -n $before && $before == "$after" && $FORCE != 1 ]]; then + log "Image unchanged — nothing to deploy (FORCE=1 to override)" + docker compose ps + exit 0 +fi + +# 4. Blue/green streamer swap +log "Swapping streamer (${#olds[@]} old → ${REPLICAS} new)" +docker compose up -d --no-deps --no-recreate \ + --scale "streamer=$((${#olds[@]} + REPLICAS))" streamer >/dev/null + +# Anything not in $olds is new. +declare -A was_old=() +for o in "${olds[@]}"; do was_old[$o]=1; done +news=() +while IFS= read -r n; do + [[ -n $n && -z ${was_old[$n]:-} ]] && news+=("$n") +done < <(list_streamers) + +# Wait for all new replicas to be healthy in parallel. +pids=() +for n in "${news[@]}"; do wait_healthy "$n" & pids+=($!); done +fail=0 +for p in "${pids[@]}"; do wait "$p" || fail=1; done +if [[ $fail == 1 ]]; then + warn "new replicas unhealthy — keeping olds in place" + exit 1 +fi + +# Drop olds and reconcile scale. +docker rm -f "${olds[@]}" >/dev/null +docker compose up -d --no-deps --no-recreate --scale "streamer=${REPLICAS}" streamer >/dev/null + +# 5. Recreate API. --no-deps: we already manage every dependency above, +# skipping compose's own depends_on health re-poll (~5–10s). +log "Recreating API" +docker compose up -d --no-deps --remove-orphans anonymous_github +api="$(docker compose ps -q anonymous_github | head -1)" +[[ -n $api ]] && wait_healthy "$api" || true + +docker compose ps diff --git a/src/server/index.ts b/src/server/index.ts index 1a7c1a7..ff43b0a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -255,9 +255,11 @@ export default async function start() { repositoryStatusCheck(); await connect(); - await recoverStuckPreparing(); app.listen(config.PORT); logger.info("server started", { port: config.PORT }); + recoverStuckPreparing().catch((err) => + logger.error("recoverStuckPreparing failed", { err }) + ); } start();