diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e4c64f6..cc5697e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,14 @@ "Bash(ls *)", "WebFetch(*)", "Bash(mkdir -p \"c:/Users/runya/Documents/repositories/cooking/public/recipes/mexican/fajita-vegetables/assets\")", - "Bash(cp \"c:/Users/runya/Documents/repositories/cooking/public/recipes/mexican/birria/assets/not-found.svg\" \"c:/Users/runya/Documents/repositories/cooking/public/recipes/mexican/fajita-vegetables/assets/not-found.svg\")" + "Bash(cp \"c:/Users/runya/Documents/repositories/cooking/public/recipes/mexican/birria/assets/not-found.svg\" \"c:/Users/runya/Documents/repositories/cooking/public/recipes/mexican/fajita-vegetables/assets/not-found.svg\")", + "Bash(ls -la && echo \"=== next.config ===\" && ls next.config* 2>/dev/null; echo \"=== applications infra ===\" && ls -la /c/Users/runya/Documents/repositories/applications/ | grep -iE 'docker|compose|nginx|next.config')", + "Read(//c/Users/runya/Documents/repositories/applications//**)", + "Bash(ls /c/Users/runya/Documents/repositories/cooking/app)", + "Read(//c/Users/runya/Documents/repositories/cooking/app/**)", + "Bash(find /c/Users/runya/Documents/repositories/cooking/app/api -type f)", + "Read(//c/Users/runya/Documents/repositories/cooking/app/api/**)", + "Bash(npx tsc *)" ] } } diff --git a/Dockerfile b/Dockerfile index 88aff64..207d229 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,11 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci +# Lint + type-check target, used by CI (docker build --target ci). +FROM deps AS ci +COPY . . +RUN npm run lint && npx tsc --noEmit + FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules @@ -25,4 +30,9 @@ COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 +# Liveness probe against the dedicated /healthz route. Use 127.0.0.1 (not +# localhost) so busybox wget hits the IPv4 address the server binds to. +HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1 + CMD ["node", "server.js"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..e232c94 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,102 @@ +pipeline { + agent any + + environment { + COMPOSE_FILE = 'docker-compose.yml' + SERVICE = 'recipes' + APP_PORT = '3000' + } + + options { + timestamps() + disableConcurrentBuilds() + timeout(time: 30, unit: 'MINUTES') + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Preflight') { + steps { + sh ''' + set -eu + docker network inspect traefik >/dev/null 2>&1 || { echo "missing docker network 'traefik'" >&2; exit 1; } + docker compose -f "$COMPOSE_FILE" config -q + ''' + } + } + + stage('Lint & Type-check') { + steps { + sh 'docker build --target ci -t cooking-ci:$BUILD_NUMBER .' + } + } + + stage('Teardown') { + steps { + sh 'docker compose -f "$COMPOSE_FILE" down --remove-orphans' + } + } + + stage('Build & Deploy') { + steps { + sh 'docker compose -f "$COMPOSE_FILE" up -d --build' + } + } + + stage('Health Check') { + steps { + sh ''' + set -eu + cid="$(docker compose -f "$COMPOSE_FILE" ps -q "$SERVICE")" + [ -n "$cid" ] || { echo "$SERVICE container not found" >&2; exit 1; } + + # The image defines a HEALTHCHECK (wget against 127.0.0.1/healthz); wait + # for Docker to report healthy, failing fast on terminal states. + deadline=$(( $(date +%s) + 90 )) + while :; do + status="$(docker inspect -f '{{.State.Status}}' "$cid")" + health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid")" + [ "$status" = "running" ] && [ "$health" = "healthy" ] && break + [ "$health" = "unhealthy" ] && { echo "$SERVICE reported unhealthy" >&2; exit 1; } + case "$status" in + exited|dead) echo "$SERVICE container $status before becoming healthy" >&2; exit 1 ;; + esac + [ "$(date +%s)" -ge "$deadline" ] && { echo "timed out waiting for healthy (status=$status, health=$health)" >&2; exit 1; } + sleep 2 + done + echo "$SERVICE healthy" + ''' + } + } + + stage('Smoke Test') { + steps { + sh ''' + set -eu + cid="$(docker compose -f "$COMPOSE_FILE" ps -q "$SERVICE")" + [ -n "$cid" ] || { echo "$SERVICE container not found" >&2; exit 1; } + + # /healthz proves the server is up; this proves the build actually deployed: + # GET / must return 200 and serve the rendered homepage, not an error page. + if ! body="$(docker exec "$cid" wget -q -O - "http://127.0.0.1:$APP_PORT/")"; then + echo "GET / did not return a successful response" >&2; exit 1 + fi + echo "$body" | grep -q 'PWS Recipes' || { echo "GET / response missing expected homepage markup" >&2; exit 1; } + echo "smoke test passed" + ''' + } + } + } + + post { + failure { + sh 'docker compose -f "$COMPOSE_FILE" ps || true' + sh 'docker compose -f "$COMPOSE_FILE" logs --tail=200 || true' + } + } +} diff --git a/app/healthz/route.ts b/app/healthz/route.ts new file mode 100644 index 0000000..a9873dd --- /dev/null +++ b/app/healthz/route.ts @@ -0,0 +1,8 @@ +export const dynamic = "force-static"; + +export function GET() { + return new Response("ok\n", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); +}