diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2808f2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Keep the build context small + deterministic. node_modules, build output, and +# the migration venv are all recreated inside the image, never copied from host. +**/node_modules +**/dist +**/.next +**/.turbo +apps/web/.next +packages/database/generated +migration/.venv +migration/**/__pycache__ +**/*.log +.git +.idea +.env +.env.* +!.env.example diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..ba2bc9b --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,91 @@ +# Build + push the API and web container images to the git.mancinas.io registry. +# +# Two images from this one repo: +# git.mancinas.io/rmancinas/jorgecuadros-api +# git.mancinas.io/rmancinas/jorgecuadros-web +# +# Comprehensive versioning (docker/metadata-action). Every build pushes a set +# of tags so an image is addressable at several granularities: +# - vX.Y.Z / vX.Y when the trigger is a git tag vX.Y.Z (releases) +# - the branch that was pushed (e.g. master, feat-foo) +# - sha- immutable per-commit id, always present +# - latest only on the default branch (master) +# The same version string + commit + build date are baked into the image as +# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running +# container can report exactly what is deployed. +# +# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images. + +name: Build and Push Images + +on: + push: + branches: [master] + tags: ["v*"] + paths: + - "apps/**" + - "packages/**" + - "docker/**" + - "package.json" + - "pnpm-lock.yaml" + - ".gitea/workflows/build.yml" + workflow_dispatch: + +env: + REGISTRY: git.mancinas.io + +jobs: + build: + name: Build ${{ matrix.image }} + runs-on: docker + container: + image: docker:27-dind + options: --privileged + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - image: jorgecuadros-api + dockerfile: docker/api.Dockerfile + - image: jorgecuadros-web + dockerfile: docker/web.Dockerfile + steps: + - name: Install Node.js for actions + run: apk add --no-cache nodejs npm + + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=sha,format=short,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - uses: docker/build-push-action@v5 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + build-args: | + APP_VERSION=${{ steps.meta.outputs.version }} + GIT_SHA=${{ github.sha }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index 2d98664..77f14ed 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -1,24 +1,69 @@ FROM node:20-alpine AS base WORKDIR /repo +# Pin pnpm 9 to match pnpm-lock.yaml (lockfileVersion 9.0). pnpm 9 runs +# dependency build scripts automatically (the v10 build-allowlist gating does +# not apply), so argon2's native addon + prisma engines build without extra +# approval config. +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate FROM base AS deps -COPY package.json package-lock.json* ./ +# argon2's native addon has no musl prebuild -> compiles from source here. +RUN apk add --no-cache python3 make g++ +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json COPY packages/database/package.json packages/database/package.json -RUN npm install --workspace=packages/database --workspace=apps/api --no-audit --no-fund +# node-linker=hoisted flattens the store into a single npm-style /repo/node_modules +# so the runtime stage can copy one tree (pnpm's default symlinked layout would +# break across COPY stages). +RUN pnpm install --frozen-lockfile --config.node-linker=hoisted FROM deps AS build COPY packages/database packages/database COPY apps/api apps/api -RUN npm run generate -w packages/database -RUN npm run build -w apps/api +RUN pnpm --filter @jorgecuadros/database generate +RUN pnpm --filter @jorgecuadros/api build FROM node:20-alpine AS runtime WORKDIR /repo ENV NODE_ENV=production + +# DB-ops toolchain baked in so the "Operaciones" admin panel can run backups +# (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools) +# from inside the API container. Build deps are installed in a throwaway virtual +# package so pandas/pyarrow build on musl, then dropped from the final layer. +RUN apk add --no-cache python3 mdbtools mysql-client \ + && apk add --no-cache --virtual .pybuild python3-dev build-base \ + && rm -rf /var/cache/apk/* + COPY --from=build /repo/node_modules node_modules COPY --from=build /repo/packages/database packages/database COPY --from=build /repo/apps/api/dist apps/api/dist COPY --from=build /repo/apps/api/package.json apps/api/package.json + +# Migration scripts + their own Python venv (ops.service.ts prefers this venv). +COPY migration migration +RUN python3 -m venv migration/.venv \ + && migration/.venv/bin/pip install --no-cache-dir -r migration/requirements.txt \ + && apk del .pybuild + +# Ingest (uploaded Access files) and backups live on mounted volumes. +ENV MIGRATION_DIR=/repo/migration \ + INGEST_DIR=/data/ingest \ + BACKUP_DIR=/data/backups \ + MIGRATION_ENV=dev +RUN mkdir -p /data/ingest /data/backups + +# Build/version metadata baked in at image build time (see .gitea/workflows/build.yml). +# APP_VERSION is the metadata-action primary tag (semver tag, branch, or sha); +# GIT_SHA/BUILD_DATE pin the exact commit + build instant. Exposed as ENV so a +# running container can self-report what is deployed (e.g. a /version endpoint). +ARG APP_VERSION=dev +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown +ENV APP_VERSION=$APP_VERSION \ + GIT_SHA=$GIT_SHA \ + BUILD_DATE=$BUILD_DATE + EXPOSE 3001 CMD ["node", "apps/api/dist/main.js"] diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index e06a2ad..5fea7bf 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -1,20 +1,34 @@ FROM node:20-alpine AS base WORKDIR /repo +# Pin pnpm 9 to match pnpm-lock.yaml (lockfileVersion 9.0). +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate FROM base AS deps -COPY package.json package-lock.json* ./ +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY apps/api/package.json apps/api/package.json COPY apps/web/package.json apps/web/package.json -RUN npm install --workspace=apps/web --no-audit --no-fund +COPY packages/database/package.json packages/database/package.json +# node-linker=hoisted -> single flat /repo/node_modules copied into runtime. +RUN pnpm install --frozen-lockfile --config.node-linker=hoisted FROM deps AS build COPY apps/web apps/web -RUN npm run build -w apps/web +RUN pnpm --filter @jorgecuadros/web build FROM node:20-alpine AS runtime WORKDIR /repo ENV NODE_ENV=production COPY --from=build /repo/node_modules node_modules COPY --from=build /repo/apps/web apps/web + +# Build/version metadata baked in at image build time (see .gitea/workflows/build.yml). +ARG APP_VERSION=dev +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown +ENV APP_VERSION=$APP_VERSION \ + GIT_SHA=$GIT_SHA \ + BUILD_DATE=$BUILD_DATE + EXPOSE 3000 WORKDIR /repo/apps/web CMD ["npx", "next", "start"] diff --git a/package.json b/package.json index 00acf37..a6c8604 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "jorgecuadros-platform", + "version": "0.1.0", "private": true, "workspaces": [ "apps/*",