diff --git a/.dockerignore b/.dockerignore index a2c728d..984983c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,5 +16,9 @@ app/dist # Ops assets not needed inside the image ansible -scripts/create-collections.sh +ansible-sample +Dockerfile-sample +deploy-sample.sh +Makefile README.md +scripts/create-collections.sh diff --git a/.env.example b/.env.example deleted file mode 100644 index 3e80ac3..0000000 --- a/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# Copy to .env and fill in real values. .env is git-ignored. -REGISTRY=registry.tanshu.com -REGISTRY_USER=ta-registry -REGISTRY_PASSWORD=changeme - -# Initial PocketBase admin (applied on first container start; idempotent) -PB_SUPERUSER_EMAIL=admin@greatbear.in -PB_SUPERUSER_PASSWORD=changeme diff --git a/.gitignore b/.gitignore index 64f3fcd..79d2c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ ansible-sample/ *.log .idea/ .vscode/ +Dockerfile-sample +deploy-sample.sh diff --git a/Dockerfile b/Dockerfile index 4736e73..d844790 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,88 @@ # syntax=docker/dockerfile:1 -# The Great Bear — single image running TanStack Start (site) + PocketBase (CMS/API) -ARG NODE_VERSION=22 -ARG PB_VERSION=0.40.2 +# ------------------------------------------------------------------------------ +# Stage 1: Install frontend dependencies (Native execution on host architecture) +# ------------------------------------------------------------------------------ +FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps +WORKDIR /work -# ---------- Stage 1: build the TanStack Start app ---------- -FROM node:${NODE_VERSION}-alpine AS build -WORKDIR /app +COPY app/package.json app/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci -COPY app/package.json app/package-lock.json ./ -RUN npm ci --no-audit --no-fund +# ------------------------------------------------------------------------------ +# Stage 2: Build the TanStack Start frontend (Native execution on host architecture) +# ------------------------------------------------------------------------------ +FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder +WORKDIR /work +COPY --from=deps /work/node_modules ./node_modules COPY app/ ./ + +ENV NODE_ENV=production RUN npm run build -# ---------- Stage 2: runtime ---------- -FROM node:${NODE_VERSION}-alpine AS runtime -ARG PB_VERSION +# ------------------------------------------------------------------------------ +# Stage 3: Fetch PocketBase binary for target architecture (Native host downloader) +# ------------------------------------------------------------------------------ +FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader ARG TARGETARCH +ARG PB_VERSION=0.40.2 -ENV NODE_ENV=production \ - PORT=3000 \ - PB_URL=http://127.0.0.1:8090 +RUN apk add --no-cache curl unzip ca-certificates \ + && case "${TARGETARCH}" in \ + amd64) PB_ARCH="amd64" ;; \ + arm64) PB_ARCH="arm64" ;; \ + arm) PB_ARCH="armv7" ;; \ + *) echo "Unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/pb.zip "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \ + && unzip /tmp/pb.zip -d /tmp/pb \ + && chmod +x /tmp/pb/pocketbase \ + && rm -f /tmp/pb.zip + +# ------------------------------------------------------------------------------ +# Stage 4: Production runner (Target architecture) +# ------------------------------------------------------------------------------ +FROM node:lts-trixie-slim AS runner +LABEL maintainer="Amritanshu " + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app -RUN apk add --no-cache curl unzip +# Copy PocketBase binary and migrations +COPY --from=pb-downloader /tmp/pb/pocketbase /app/pocketbase +COPY pb/migrations /app/pb_migrations +RUN mkdir -p /app/pb_public -# Site server build output -COPY --from=build /app/.output ./.output +# Copy compiled frontend application +COPY --from=builder /work/.output /app/.output -# Content seed (shared with the frontend fallback) + process supervisor -COPY app/src/data/content.json ./scripts/content.json -COPY scripts/seed.mjs scripts/run-all.mjs ./scripts/ +# Copy content seed + entrypoint script +COPY app/src/data/content.json /app/scripts/content.json +COPY scripts/seed.mjs /app/scripts/seed.mjs +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh -# PocketBase schema migrations -COPY pb/migrations ./pb_migrations -RUN mkdir -p pb_public +# Environment variables +ENV NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 \ + PB_DATA_DIR=/app/pb_data -# PocketBase binary (static Go binary, runs fine on Alpine) -RUN case "${TARGETARCH:-amd64}" in \ - arm64) PB_ARCH=arm64 ;; \ - *) PB_ARCH=amd64 ;; \ - esac \ - && curl -fsSL -o /tmp/pb.zip \ - "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \ - && unzip -o /tmp/pb.zip pocketbase -d /app \ - && chmod +x /app/pocketbase \ - && rm /tmp/pb.zip \ - && mkdir -p /app/pb_data - -# Runs as root so the Ansible-managed bind mount (/var/lib/greatbear/pb_data) -# is writable regardless of host-side ownership. +# Persistent data volume for PocketBase SQLite database and file storage +VOLUME ["/app/pb_data"] +# Port 3000: TanStack Start Web Application +# Port 8090: PocketBase REST API & Admin UI EXPOSE 3000 8090 -HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \ - CMD curl -fsS http://127.0.0.1:8090/api/health -o /dev/null || exit 1 +# Health check for both PocketBase and frontend server +HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8090/api/health > /dev/null && curl -fsS http://127.0.0.1:3000 > /dev/null || exit 1 -CMD ["node", "scripts/run-all.mjs"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8998a6b --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: build-production +build-production: ## Build the production docker image and push to private registry + @docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag registry.tanshu.com/tanshu/greatbear:latest \ + $(if $(filter-out latest,$(TAG)),--tag registry.tanshu.com/tanshu/greatbear:$(TAG)) \ + --push \ + --pull \ + git@git.tanshu.com:tanshu/greatbear.git + +.PHONY: build-check +build-check: ## Multi-arch build without push (compile check) + @docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag greatbear:test \ + --pull \ + --progress=plain \ + git@git.tanshu.com:tanshu/greatbear.git + +.PHONY: build-check-local +build-check-local: ## Build from the committed local tree (git archive) and load into docker + @git archive --format=tar HEAD | docker buildx build \ + --platform linux/amd64 \ + --tag greatbear:test \ + --pull \ + --progress=plain \ + --load \ + - diff --git a/README.md b/README.md index 7648912..cd494d2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Stitch "Industrial Hospitality" design. | Backend | [PocketBase](https://pocketbase.io) v0.40 — CMS content, form intake, admin UI | | Runtime | Single Docker image running both services | | Proxy | Caddy (automatic HTTPS) on the target host | -| Deploys | `deploy.sh` (build + push) → Ansible playbook (pull + run) | +| Deploys | `deploy.sh [tag]` (Makefile buildx push) → Ansible playbook | ## Pages @@ -33,12 +33,13 @@ app/ TanStack Start application public/images/ Design assets (downloaded from the Stitch export) pb/migrations/ PocketBase schema (snapshot migration, auto-applied) scripts/ - run-all.mjs Container supervisor: PocketBase + site server seed.mjs Idempotent content seeder (create-if-missing by uid) create-collections.sh Dev helper used to build the schema migration ansible/ Deployment playbook (roles: network, greatbear, caddy) -Dockerfile Multi-stage image build -deploy.sh Build & push image to the private registry +Makefile Multi-arch buildx targets (build-production / build-check) +Dockerfile Multi-stage image build (deps → builder → pb-downloader → runner) +docker-entrypoint.sh Container supervisor: PocketBase + site server +deploy.sh Build & push image, then run the Ansible deploy ``` ## Local development @@ -86,16 +87,25 @@ docker run --rm -p 3000:3000 -p 8090:8090 \ ## Deploying -### 1. Build and push the image +The image is always built from the **committed state of the git remote** — commit and push +before deploying. Registry credentials: `docker login registry.tanshu.com` (once per machine). ```bash -cp .env.example .env # then fill in registry credentials -./deploy.sh +./deploy.sh # build + push :latest, then deploy that tag via Ansible +./deploy.sh v1.2.3 # same, with an explicit version tag ``` -Tags pushed: `registry.tanshu.com/tanshu/greatbear:latest` and `:`. +Or run the pieces yourself: -### 2. Deploy with Ansible +```bash +make build-production TAG=v1.2.3 # multi-arch (amd64+arm64) buildx build & push +make build-check # multi-arch compile check (no push) +make build-check-local # build the committed local tree (git archive) and load it + +cd ansible && ansible-playbook playbook.yml -e "tag=v1.2.3" +``` + +### Ansible deployment Requirements on the controller: `ansible` + `community.docker` collection (see `ansible/requirements.yml`). Requirements on the host: Docker with a Caddy **container** named @@ -107,10 +117,11 @@ ansible-galaxy install -r requirements.yml ansible-playbook playbook.yml # targets inventory host `monoco` — edit playbook.yml if needed ``` -All deployment variables live in `ansible/vars/default.yml`. The playbook: +All deployment variables live in `ansible/vars/default.yml` (registry, tag, domains, +PocketBase admin credentials). The playbook: 1. Ensures the `greatbear_net` Docker network exists with the Caddy container attached. -2. Pulls `registry.tanshu.com/tanshu/greatbear:latest`, uploads `/var/lib/greatbear/.env`, +2. Pulls `registry.tanshu.com/tanshu/greatbear:{tag}`, uploads `/var/lib/greatbear/.env`, and (re)creates the `greatbear` container on that network with `/var/lib/greatbear/pb_data` bind-mounted to `/app/pb_data`, then waits for its healthcheck. 3. Inserts a managed snippet into the shared Caddyfile and reloads Caddy via @@ -129,7 +140,8 @@ automatically on first request. - URL: `https://admin.greatbear.in/_/` - First container start bootstraps the superuser from `PB_SUPERUSER_EMAIL` / - `PB_SUPERUSER_PASSWORD` (set in `ansible/group_vars/greatbear/vars.yml`, idempotent). + `PB_SUPERUSER_PASSWORD` (templated from `pb_admin_email` / `pb_admin_password` in + `ansible/vars/default.yml`, idempotent). Content collections: `brews`, `menu_categories`, `menu_items`, `experiences`, `testimonials`, `stats`, `pairings`, `settings`. Reservation/contact submissions land in `reservations` @@ -143,7 +155,8 @@ are never overwritten by redeploys. Schema changes belong in `pb/migrations/`. | Variable | Default | Purpose | | ----------------------- | ------------------------ | -------------------------------------- | | `PORT` | `3000` | Site server port | +| `HOST` | `0.0.0.0` | Site server bind address | | `PB_URL` | `http://127.0.0.1:8090` | PocketBase URL used by the site server | -| `PB_DATA_DIR` | `/data` | PocketBase data directory (volume) | +| `PB_DATA_DIR` | `/app/pb_data` | PocketBase data directory (volume) | | `PB_SUPERUSER_EMAIL` | — | Initial admin (bootstrapped at start) | | `PB_SUPERUSER_PASSWORD` | — | Initial admin password | diff --git a/deploy.sh b/deploy.sh index 689acaa..9c70ad1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,40 +1,19 @@ #!/usr/bin/env bash -# -# Build the Great Bear image and push it to the private registry. -# -# Reads registry credentials from the environment or from ./.env -# (REGISTRY_USER, REGISTRY_PASSWORD). Optional overrides: REGISTRY, -# IMAGE_NAME, PLATFORM. -# set -euo pipefail -cd "$(dirname "$0")" -# shellcheck disable=SC1091 -[ -f .env ] && set -a && . ./.env && set +a +parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" || exit ; pwd -P ) +cd "$parent_path" || exit -REGISTRY="${REGISTRY:-registry.tanshu.com}" -IMAGE_NAME="${IMAGE_NAME:-tanshu/greatbear}" -REGISTRY_USER="${REGISTRY_USER:?REGISTRY_USER not set (define it in .env or the environment)}" -REGISTRY_PASSWORD="${REGISTRY_PASSWORD:?REGISTRY_PASSWORD not set (define it in .env or the environment)}" -PLATFORM="${PLATFORM:-linux/amd64}" +echo "========================================================" +echo " Building & Uploading Docker Image" +echo "========================================================" +current_version="${1:-latest}" +make build-production TAG="$current_version" -IMAGE="$REGISTRY/$IMAGE_NAME" -VERSION="$(git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d%H%M%S)" -echo "==> Building $IMAGE:$VERSION (and :latest) for $PLATFORM" -docker build --platform "$PLATFORM" \ - -t "$IMAGE:latest" \ - -t "$IMAGE:$VERSION" \ - . - -echo "==> Logging in to $REGISTRY" -printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin - -echo "==> Pushing $IMAGE:latest" -docker push "$IMAGE:latest" -echo "==> Pushing $IMAGE:$VERSION" -docker push "$IMAGE:$VERSION" - -echo -echo "Done. Deploy with:" -echo " cd ansible && ansible-playbook playbook.yml" +echo "" +echo "========================================================" +echo " Executing Ansible Deployment" +echo "========================================================" +cd "$parent_path/ansible" || exit +ansible-playbook playbook.yml -e "tag=$current_version" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..3ba1899 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# Container entrypoint: runs PocketBase and the TanStack Start site server +# side by side. +# 1. starts PocketBase (data in $PB_DATA_DIR, migrations from ./pb_migrations) +# 2. waits for its health endpoint +# 3. bootstraps the superuser account (idempotent upsert) +# 4. seeds initial content (idempotent, create-if-missing) +# 5. starts the site server +# +# If either process dies the container exits non-zero so Docker restarts it. +set -e + +PB_DATA="${PB_DATA_DIR:-/app/pb_data}" +PB_HTTP="${PB_HTTP:-0.0.0.0:8090}" + +pb_pid="" +site_pid="" + +cleanup() { + [ -n "$site_pid" ] && kill "$site_pid" 2>/dev/null || true + [ -n "$pb_pid" ] && kill "$pb_pid" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup INT TERM + +echo "[entrypoint] starting PocketBase…" +./pocketbase serve \ + --http="$PB_HTTP" \ + --dir="$PB_DATA" \ + --migrationsDir=./pb_migrations \ + --publicDir=./pb_public & +pb_pid=$! + +echo "[entrypoint] waiting for PocketBase health…" +for _ in $(seq 1 120); do + if curl -fsS http://127.0.0.1:8090/api/health > /dev/null 2>&1; then + break + fi + sleep 0.5 +done + +if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then + ./pocketbase superuser upsert "$PB_SUPERUSER_EMAIL" "$PB_SUPERUSER_PASSWORD" \ + --dir="$PB_DATA" --migrationsDir=./pb_migrations || true + echo "[entrypoint] superuser ensured" + node scripts/seed.mjs || echo "[entrypoint] seed failed (continuing)" +else + echo "[entrypoint] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping superuser + seed" +fi + +echo "[entrypoint] starting site server on :${PORT}…" +node .output/server/index.mjs & +site_pid=$! + +set +e +wait -n "$pb_pid" "$site_pid" +exit_code=$? +echo "[entrypoint] a child process exited with code $exit_code — shutting down" +cleanup +exit "$exit_code" diff --git a/scripts/run-all.mjs b/scripts/run-all.mjs deleted file mode 100755 index e8874ff..0000000 --- a/scripts/run-all.mjs +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env node -/** - * Container entrypoint supervisor. - * - * Runs PocketBase and the TanStack Start node server side by side: - * 1. starts PocketBase (data in /data, migrations from ./pb_migrations) - * 2. waits for its health endpoint - * 3. bootstraps the superuser account (idempotent upsert) - * 4. seeds initial content (idempotent, create-if-missing) - * 5. starts the site server on PORT (default 3000) - * - * If either process dies the container exits non-zero so Docker restarts it. - */ -import { spawn, spawnSync } from 'node:child_process' - -const PORT = process.env.PORT ?? '3000' -const PB_HTTP = process.env.PB_HTTP ?? '0.0.0.0:8090' -// PB_DATA_DIR is the canonical variable (set in the deploy .env); PB_DATA kept -// as a legacy alias, /data as the image default. -const PB_DATA = process.env.PB_DATA_DIR ?? process.env.PB_DATA ?? '/data' - -let stopping = false -const children = [] - -function start(name, command, args, extraEnv = {}) { - const child = spawn(command, args, { - stdio: 'inherit', - env: { ...process.env, ...extraEnv }, - }) - child.name = name - children.push(child) - child.on('exit', (code) => { - if (stopping) return - console.error(`[supervisor] ${name} exited with code ${code} — shutting down`) - shutdown(code ?? 1) - }) - return child -} - -function run(name, command, args, extraEnv = {}) { - const result = spawnSync(command, args, { - stdio: 'inherit', - env: { ...process.env, ...extraEnv }, - }) - if (result.status !== 0) { - console.warn(`[supervisor] ${name} finished with status ${result.status}`) - } - return result.status ?? 0 -} - -function shutdown(exitCode = 0) { - if (stopping) return - stopping = true - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') - } - setTimeout(() => process.exit(exitCode), 2000) -} - -process.on('SIGINT', () => shutdown(0)) -process.on('SIGTERM', () => shutdown(0)) - -async function waitForHealth(url, attempts = 120) { - for (let i = 0; i < attempts; i++) { - try { - const res = await fetch(url) - if (res.ok) return - } catch { - // not up yet - } - await new Promise((resolve) => setTimeout(resolve, 500)) - } - throw new Error(`health check at ${url} timed out`) -} - -async function main() { - console.log('[supervisor] starting PocketBase…') - start('pocketbase', './pocketbase', [ - 'serve', - `--http=${PB_HTTP}`, - `--dir=${PB_DATA}`, - '--migrationsDir=./pb_migrations', - '--publicDir=./pb_public', - ]) - - await waitForHealth('http://127.0.0.1:8090/api/health') - console.log('[supervisor] PocketBase is healthy') - - if (process.env.PB_SUPERUSER_EMAIL && process.env.PB_SUPERUSER_PASSWORD) { - run('superuser upsert', './pocketbase', [ - 'superuser', - 'upsert', - process.env.PB_SUPERUSER_EMAIL, - process.env.PB_SUPERUSER_PASSWORD, - `--dir=${PB_DATA}`, - '--migrationsDir=./pb_migrations', - ]) - console.log('[supervisor] superuser ensured') - - run('seed', 'node', ['scripts/seed.mjs']) - } else { - console.warn('[supervisor] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping superuser + seed') - } - - console.log(`[supervisor] starting site server on :${PORT}…`) - start('site', 'node', ['.output/server/index.mjs'], { - PORT, - PB_URL: process.env.PB_URL ?? 'http://127.0.0.1:8090', - NODE_ENV: 'production', - }) -} - -main().catch((error) => { - console.error(`[supervisor] fatal: ${error.message}`) - shutdown(1) -})