diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..98db9ba --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,158 @@ +# =============================================================== +# 🐳 Docker Release - build, sign, and publish tagged images +# =============================================================== +# - fires on version tag pushes (vX.Y.Z, matching the repo's manual +# tagging convention — see git tag history) +# - builds the single combined image (Dockerfile) for linux/amd64 + +# linux/arm64, pushes it to GHCR +# - signs the pushed tags with Cosign (keyless OIDC) and attaches an SBOM +# - cuts a GitHub Release for the tag with auto-generated notes, the +# pull command, and the SBOM as a release asset +# --------------------------------------------------------------- + +name: Docker Release + +on: + push: + # Strict semver only (vX.Y.Z, vX.Y.Z-pre) — matches the type=semver + # patterns below. A looser "v*" would let non-semver tags (v1, v1.2, + # v2.0.0.1) through and produce empty derived tags. + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + workflow_dispatch: + inputs: + nodejs_image: + description: "Override NODEJS_IMAGE build arg (e.g. internal mirror) — use if registry.access.redhat.com is unreachable. Leave blank for the Dockerfile default. Dispatch this run from the tag you want to (re)release." + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-sign-release: + name: Build, Sign & Release + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # create the GitHub Release + packages: write # push to GHCR + id-token: write # Cosign keyless (OIDC) signing + + steps: + - name: ⬇️ Checkout source + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - name: 🔡 Set image name lowercase + run: | + IMAGE_NAME_LC=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + echo "IMAGE_NAME_LC=${IMAGE_NAME_LC}" >> "${GITHUB_ENV}" + + - name: 🧰 Set up QEMU (arm64 emulation) + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: 🧰 Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: 🔐 Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Extract image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }} + # Skip the "latest" tag for prerelease refs (e.g. v1.0.0-RC-3) so + # they never clobber the stable tag. + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + + - name: ✅ Validate derived tags + run: | + set -euo pipefail + if [ -z "${{ steps.meta.outputs.tags }}" ]; then + echo "::error::No image tags derived from ref '${{ github.ref_name }}'. Expected strict semver (vX.Y.Z or vX.Y.Z-prerelease)." + exit 1 + fi + + - name: 🏗️ Build & push image + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: true + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + # Lets a maintainer re-dispatch this workflow (from the tag) with + # an internal mirror if registry.access.redhat.com is unreachable + # — see the workflow_dispatch input above. + build-args: | + ${{ github.event.inputs.nodejs_image && format('NODEJS_IMAGE={0}', github.event.inputs.nodejs_image) || '' }} + + - name: 🔏 Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: 🔏 Sign published tags (keyless OIDC) + env: + COSIGN_EXPERIMENTAL: "1" + run: | + set -euo pipefail + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}" + DIGEST="${{ steps.build.outputs.digest }}" + echo "Signing ${IMAGE}@${DIGEST}" + cosign sign --yes "${IMAGE}@${DIGEST}" + + - name: 📋 Generate SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}@${{ steps.build.outputs.digest }} + output-file: sbom.spdx.json + upload-artifact: false + + - name: 📦 Create GitHub Release + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + tag_name: ${{ github.ref_name }} + generate_release_notes: true + prerelease: ${{ contains(github.ref_name, '-') }} + files: sbom.spdx.json + body: | + ## Container image + + ``` + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ github.ref_name }} + ``` + + Signed with [Cosign](https://docs.sigstore.dev/cosign/overview/) (keyless OIDC) — verify with: + + ``` + cosign verify ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}@${{ steps.build.outputs.digest }} \ + --certificate-identity-regexp 'https://github.com/${{ github.repository }}/.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + ``` + + Built for `linux/amd64` and `linux/arm64`. SBOM (SPDX) attached below. + See [DOCKER.md](../../blob/main/DOCKER.md#published-image) for compose/Helm usage. diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 0000000..7669ee4 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1 @@ +.husky/_/post-checkout diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 0000000..2db9861 --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1 @@ +.husky/_/post-commit diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 0000000..392a143 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1 @@ +.husky/_/post-merge diff --git a/DOCKER.md b/DOCKER.md index 4179c07..02b132d 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -5,6 +5,10 @@ built UI (root, Vite/React SPA) as static files and proxies `/api/*`, so the whole client stack — UI + BFF — is a single container. Redis is a separate service, wired in via `docker-compose.yml`. +The image builds on Red Hat UBI9 Node.js 22 +(`registry.access.redhat.com/ubi9/nodejs-22`, public/no-auth) rather than +`node:alpine` — enterprise-supportable, glibc-based, non-root by default. + `.env`/`.env.example` are shared with native (non-Docker) dev — see the root README's Getting Started section. `docker-compose.yml` and `server`'s native `npm run dev`/`start` both read the same repo-root @@ -14,6 +18,37 @@ The upstream ContextForge/mcpgateway API is **not** part of this repo or this compose file — it's expected to already be running somewhere you point `CONTEXTFORGE_URL` at. +## Published image + +Tagged releases (`vX.Y.Z`, matching the repo's git tags) are built and +pushed to GHCR by `.github/workflows/docker-release.yml`: + +```bash +docker pull ghcr.io/contextforge-org/contextforge-web-ui:vX.Y.Z +``` + +Also available: the `vX.Y` (minor) tag, and `latest` (stable releases +only — prerelease/RC tags don't move it). Built for `linux/amd64` and +`linux/arm64`. See the repo's **Releases** and **Packages** tabs for the +full list of published tags, signatures, and SBOMs. + +To point `docker-compose.yml` at the published image instead of building +locally, override the `app` service's `build:` key with `image:`: + +```yaml +# docker-compose.override.yml +services: + app: + image: ghcr.io/contextforge-org/contextforge-web-ui:vX.Y.Z +``` + +```bash +docker compose -f docker-compose.yml -f docker-compose.override.yml up +``` + +For Helm/Kubernetes, point your chart's `image.repository` / +`image.tag` (or equivalent values) at the same coordinates. + ## Quick start ```bash @@ -36,11 +71,11 @@ this compose file's own `redis` service (`docker-compose.yml` defaults Full reference: `.env.example` (each var has an inline comment). Summary, grouped the same way: -| Group | Vars | Notes | -|---|---|---| -| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. | -| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. | -| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. | +| Group | Vars | Notes | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. | +| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. | +| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. | The image itself (`Dockerfile`) sets **none** of these — it ships respecting `config.ts`'s own defaults untouched. All configuration comes @@ -137,5 +172,6 @@ docker compose -f docker-compose.yml -f docker-compose.override.yml up --build - **Multi-arch builds** (e.g. building on Apple Silicon for an amd64 target): `docker buildx build --platform linux/amd64,linux/arm64 -t contextforge-web-ui .` — the UI stage's native dependency (`lightningcss`, via - `@tailwindcss/vite`) ships prebuilt musl binaries for both architectures, - so no Dockerfile changes should be needed. + `@tailwindcss/vite`) ships prebuilt glibc binaries for both architectures + (the base image is glibc-based UBI, not musl-based Alpine), so no + Dockerfile changes should be needed. diff --git a/Dockerfile b/Dockerfile index 7661614..e513908 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,47 +6,58 @@ # owns those defaults (and fails closed on insecure combinations by design). # Supply the right values at `docker run -e` / compose time instead. +# Red Hat UBI9 Node.js 22 — public, unauthenticated registry (no `docker +# login` needed to pull), published for linux/amd64, linux/arm64, +# linux/s390x and linux/ppc64le. Runs as non-root (UID 1001, group 0) by +# default, WORKDIR /opt/app-root/src pre-owned for that user — no manual +# addgroup/adduser/chown needed, unlike the node:*-alpine images this +# replaced. Pinned by digest for reproducibility; refresh periodically +# (e.g. via Renovate/Dependabot) or override with +# --build-arg NODEJS_IMAGE=... to point at an internal mirror (air-gapped/ +# FIPS environments). +ARG NODEJS_IMAGE=registry.access.redhat.com/ubi9/nodejs-22@sha256:d1f88101a85776886e459995eacdd7ebaa97c6e0c80b35c5a07a8a7b938ea9a3 + # ---- UI dependencies ---- -FROM node:22-alpine AS ui-deps -WORKDIR /ui -COPY package.json package-lock.json ./ +FROM ${NODEJS_IMAGE} AS ui-deps +WORKDIR /opt/app-root/src +COPY --chown=1001:0 package.json package-lock.json ./ RUN npm ci --no-audit --no-fund # ---- UI build ---- # npm run build = "npm run generate && tsc -b && vite build". `generate` # runs orval against the committed openapi.json (no network call). vite's -# outDir is "server/public", so output lands at /ui/server/public here. +# outDir is "server/public", so output lands at +# /opt/app-root/src/server/public here. FROM ui-deps AS ui-build -WORKDIR /ui -COPY openapi.json orval.config.ts index.html vite.config.ts build-constants.ts ./ -COPY tsconfig.json tsconfig.app.json tsconfig.node.json ./ -COPY public ./public -COPY src ./src +WORKDIR /opt/app-root/src +COPY --chown=1001:0 openapi.json orval.config.ts index.html vite.config.ts build-constants.ts ./ +COPY --chown=1001:0 tsconfig.json tsconfig.app.json tsconfig.node.json ./ +COPY --chown=1001:0 public ./public +COPY --chown=1001:0 src ./src RUN npm run build # ---- BFF dependencies ---- # Full (non-prod) install here — tsc is a devDependency needed to build. -FROM node:22-alpine AS bff-deps -WORKDIR /app -COPY server/package.json server/package-lock.json ./ +FROM ${NODEJS_IMAGE} AS bff-deps +WORKDIR /opt/app-root/src +COPY --chown=1001:0 server/package.json server/package-lock.json ./ RUN npm ci --no-audit --no-fund # ---- BFF build ---- FROM bff-deps AS bff-build -WORKDIR /app -COPY server/tsconfig.json ./ -COPY server/src ./src +WORKDIR /opt/app-root/src +COPY --chown=1001:0 server/tsconfig.json ./ +COPY --chown=1001:0 server/src ./src RUN npm run build # ---- Runtime ---- -FROM node:22-alpine AS runtime -WORKDIR /app -COPY server/package.json server/package-lock.json ./ +FROM ${NODEJS_IMAGE} AS runtime +WORKDIR /opt/app-root/src +COPY --chown=1001:0 server/package.json server/package-lock.json ./ RUN npm ci --omit=dev --no-audit --no-fund -COPY --from=bff-build /app/dist ./dist -COPY --from=ui-build /ui/server/public ./public -RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app -USER app +COPY --from=bff-build --chown=1001:0 /opt/app-root/src/dist ./dist +COPY --from=ui-build --chown=1001:0 /opt/app-root/src/server/public ./public +USER 1001 EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" diff --git a/README.md b/README.md index fa61707..a88acb1 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ The web interface for ContextForge, the open source AI gateway that federates tools, agents, and APIs into one endpoint. The backing service is a separate process in a separate repository -([IBM/mcp-context-forge](https://github.com/IBM/mcp-context-forge)). +([IBM/mcp-context-forge](https://github.com/IBM/mcp-context-forge)). This repository holds the BFF and client that sit in front of it. The table below also documents the API for naming reference, though it lives in a separate repo: -| Component | Lives in | Role | -| --------- | -------- | ---- | -| **ContextForge API** | separate repo | FastAPI service that owns auth and all business data | -| **BFF** | `server/` | Fastify app holding the session/CSRF boundary in front of the API | -| **Client** | `src/` | React SPA, served as static files by the BFF | +| Component | Lives in | Role | +| -------------------- | ------------- | ----------------------------------------------------------------- | +| **ContextForge API** | separate repo | FastAPI service that owns auth and all business data | +| **BFF** | `server/` | Fastify app holding the session/CSRF boundary in front of the API | +| **Client** | `src/` | React SPA, served as static files by the BFF | Throughout this README, "the API", "the BFF", and "the client" refer to those three. The browser only ever talks to the BFF, never directly to the API.