diff --git a/.changeset/official-docker-runtime-image.md b/.changeset/official-docker-runtime-image.md new file mode 100644 index 0000000000..3a2aeebbc1 --- /dev/null +++ b/.changeset/official-docker-runtime-image.md @@ -0,0 +1,25 @@ +--- +'@objectstack/cli': minor +--- + +feat(docker): official runtime image `ghcr.io/objectstack-ai/objectstack` + +ObjectStack now ships an official, versioned Docker runtime image instead of +only a copy-me example. The image packages Node 22 + a pinned +`@objectstack/cli` + `os start` (non-root `node` user, `/api/v1/health` +HEALTHCHECK, `OS_ARTIFACT_PATH` / `OS_PORT=8080` preset), published +multi-arch (amd64/arm64) on every release with tags mirroring the CLI +version (`X.Y.Z` / `X.Y` / `X` / `latest`). + +Deploying an app is now: + +```dockerfile +FROM ghcr.io/objectstack-ai/objectstack: +COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json +``` + +or, with no image build at all, `docker run` the official image with the +artifact mounted (or `OS_ARTIFACT_PATH` pointing at an `https://` URL). +`examples/docker` and the Self-Hosted Deployment docs now build on the +official image; the self-built runtime Dockerfile remains documented for +air-gapped registries. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000..88ab78b1cb --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,104 @@ +name: Docker Publish + +# Builds and pushes the official runtime image +# ghcr.io/objectstack-ai/objectstack from docker/Dockerfile. +# +# Two entry points: +# - workflow_call: invoked by release.yml right after a successful +# `changeset publish`, with the just-published @objectstack/cli version. +# (A plain `on: push: tags:` trigger would never fire — the release +# workflow pushes its tags with GITHUB_TOKEN, and GitHub suppresses +# workflow triggers from GITHUB_TOKEN-pushed refs.) +# - workflow_dispatch: manual backfill / rebuild of an already-published +# version, e.g. to pick up node:22-slim base-image CVE patches between +# framework releases. +# +# The image tag always equals the @objectstack/cli version baked inside. + +on: + workflow_call: + inputs: + version: + description: 'Published @objectstack/cli version to package (e.g. 14.8.0)' + required: true + type: string + workflow_dispatch: + inputs: + version: + description: '@objectstack/cli version to package (e.g. 14.8.0) — must already be on npm' + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + name: Build & push ghcr.io/objectstack-ai/objectstack + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMAGE: ghcr.io/objectstack-ai/objectstack + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Validate version input + id: version + env: + VERSION: ${{ inputs.version }} + run: | + if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then + echo "::error::'$VERSION' is not a valid semver version" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU (arm64 emulation) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + # Tag = CLI version. Rolling minor/major/latest tags move with every + # publish; prereleases (x.y.z-rc.1) get only their exact tag. + tags: | + type=semver,pattern={{version}},value=${{ steps.version.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.version }} + type=semver,pattern={{major}},value=${{ steps.version.outputs.version }} + type=raw,value=latest,enable=${{ !contains(steps.version.outputs.version, '-') }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: docker + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + OS_CLI_VERSION=${{ steps.version.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Smoke-test the pushed image (amd64) + # `os --version` proves the CLI resolved, installed, and runs on the + # pushed image; a boot test needs an artifact + DB and belongs to the + # examples/e2e suites, not here. + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + docker pull "$IMAGE:$VERSION" + docker run --rm "$IMAGE:$VERSION" os --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce13fe5cfe..9194130807 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,9 @@ jobs: permissions: contents: write pull-requests: write + outputs: + published: ${{ steps.changesets.outputs.published }} + cli-version: ${{ steps.cli-version.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v7 @@ -94,3 +97,34 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Extract published @objectstack/cli version + id: cli-version + if: steps.changesets.outputs.published == 'true' + # The fixed group bumps every public package in lockstep, so the CLI + # is always in publishedPackages when a publish happened. Passed via + # env (not inline interpolation) to avoid shell-quoting the JSON. + env: + PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} + run: | + version=$(jq -r '.[] | select(.name=="@objectstack/cli") | .version' <<<"$PUBLISHED") + if [ -z "$version" ]; then + echo "::error::publish succeeded but @objectstack/cli is missing from publishedPackages" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + docker: + name: Docker image + needs: release + # Publish the official runtime image (ghcr.io/objectstack-ai/objectstack) + # for every npm release. Called as a reusable workflow so the same build + # can be re-run manually via workflow_dispatch (e.g. base-image CVE + # rebuilds) — see docker-publish.yml. + if: needs.release.outputs.published == 'true' + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker-publish.yml + with: + version: ${{ needs.release.outputs.cli-version }} diff --git a/.github/workflows/scaffold-e2e.yml b/.github/workflows/scaffold-e2e.yml index 743af025f5..1b476310e0 100644 --- a/.github/workflows/scaffold-e2e.yml +++ b/.github/workflows/scaffold-e2e.yml @@ -20,6 +20,7 @@ on: paths: - 'packages/create-objectstack/**' - 'examples/docker/**' + - 'docker/**' - '.github/workflows/scaffold-e2e.yml' schedule: - cron: '23 3 * * *' @@ -114,13 +115,24 @@ jobs: curl -fsS http://localhost:8080/api/v1/ready kill "$SERVER_PID" + - name: Build official runtime image from this checkout + # examples/docker builds FROM ghcr.io/objectstack-ai/objectstack. + # Build that base HERE from docker/Dockerfile instead of pulling, so + # (a) PRs exercise the official runtime Dockerfile itself, and + # (b) the e2e stays hermetic — no dependency on a prior release + # having published the tag (chicken-and-egg on the very first one). + run: | + docker build -t ghcr.io/objectstack-ai/objectstack:latest \ + --build-arg OS_CLI_VERSION=latest \ + "$GITHUB_WORKSPACE/docker" + - name: Docker build and run (examples/docker) run: | cd "$RUNNER_TEMP/e2e-app" cp "$GITHUB_WORKSPACE"/examples/docker/Dockerfile \ "$GITHUB_WORKSPACE"/examples/docker/docker-compose.yml \ "$GITHUB_WORKSPACE"/examples/docker/.dockerignore . - docker build -t e2e-app . + docker build --pull=false -t e2e-app . docker run -d --name e2e -p 18080:8080 \ -e OS_SECRET_KEY="$(openssl rand -hex 32)" \ -e OS_AUTH_SECRET="$(openssl rand -hex 32)" \ diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index c1e83f60fd..c9e0d49010 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -85,11 +85,38 @@ curl -fsS http://localhost:8080/api/v1/health Upgrades are atomic: replace the artifact file and restart the service. Roll back by restoring the previous artifact. -## Option 2 — Docker +## Option 2 — Docker (official image) -The artifact model maps cleanly onto containers: the image contains Node, the -CLI, and one JSON file. The Dockerfile below (plus the compose stack in the -next section and a `.dockerignore`) also ships ready-to-copy in +The artifact model maps cleanly onto containers, and ObjectStack ships an +**official runtime image** for exactly this: +`ghcr.io/objectstack-ai/objectstack` — Node 22 + `@objectstack/cli` + +`os start`, running as a non-root user with a built-in health check and +`OS_ARTIFACT_PATH` / `OS_PORT=8080` preset. Image tags mirror +`@objectstack/cli` versions (`14.8.0`, `14.8`, `14`, `latest`) and the image +is published multi-arch (amd64/arm64) on every framework release — **pin the +exact version in production**, matching the CLI version in your +`package.json`. + +The fastest path needs no image build at all — hand the official image your +compiled artifact: + +```bash +os build # → dist/objectstack.json (or in CI) + +docker run -p 8080:8080 \ + -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ + -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ + -e OS_AUTH_SECRET \ + -e OS_SECRET_KEY \ + ghcr.io/objectstack-ai/objectstack:14.8.0 +``` + +(`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come +straight from release storage instead of a mount.) + +For a self-contained deployable image, extend it. The Dockerfile below (plus +the compose stack in the next section and a `.dockerignore`) ships +ready-to-copy in [`examples/docker`](https://github.com/objectstack-ai/framework/tree/main/examples/docker) — drop the files into your scaffolded project. @@ -102,11 +129,28 @@ RUN npm ci COPY . . RUN npx os build # → dist/objectstack.json -# ── Runtime stage: CLI + artifact only ─────────────────────────────── +# ── Runtime: the official ObjectStack runtime image ────────────────── +FROM ghcr.io/objectstack-ai/objectstack:14.8.0 +COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json +``` + +```bash +docker build -t my-app . +docker run -p 8080:8080 \ + -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ + -e OS_AUTH_SECRET \ + -e OS_SECRET_KEY \ + my-app +``` + +Prefer to build the runtime yourself (air-gapped registry, custom base +image)? The official image is nothing more than: + +```dockerfile title="Dockerfile (self-built runtime, equivalent)" FROM node:22-slim WORKDIR /srv/app -RUN npm install -g @objectstack/cli -COPY --from=build /app/dist/objectstack.json ./objectstack.json +RUN npm install -g @objectstack/cli@14.8.0 +COPY dist/objectstack.json ./objectstack.json ENV NODE_ENV=production \ OS_ARTIFACT_PATH=/srv/app/objectstack.json \ @@ -119,15 +163,6 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \ CMD ["os", "start"] ``` -```bash -docker build -t my-app . -docker run -p 8080:8080 \ - -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ - -e OS_AUTH_SECRET \ - -e OS_SECRET_KEY \ - my-app -``` - **Never bake `OS_AUTH_SECRET` / `OS_SECRET_KEY` into the image.** Pass them at runtime from your orchestrator's secret store. And never rely on the diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000..b1e2739049 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,54 @@ +# ObjectStack official runtime image — ghcr.io/objectstack-ai/objectstack +# +# A generic, app-agnostic production runtime: Node + @objectstack/cli + +# `os start`. It contains NO app — bring your compiled artifact +# (dist/objectstack.json, built by `os build` in CI): +# +# FROM ghcr.io/objectstack-ai/objectstack: +# COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json +# +# or run it without any image build at all: +# +# docker run -p 8080:8080 \ +# -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ +# -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ +# -e OS_AUTH_SECRET -e OS_SECRET_KEY \ +# ghcr.io/objectstack-ai/objectstack: +# +# OS_ARTIFACT_PATH also accepts an https:// URL, so the artifact can be +# fetched from your release storage instead of copied in. +# +# Published by .github/workflows/docker-publish.yml on every framework +# release; the image tag always matches the @objectstack/cli version inside. +# Docs: https://docs.objectstack.ai/docs/deployment/self-hosting + +FROM node:22-slim + +# Pinned by CI to the @objectstack/cli release that triggered the publish. +# `latest` is only the fallback for ad-hoc local builds of this file. +ARG OS_CLI_VERSION=latest +RUN npm install -g @objectstack/cli@${OS_CLI_VERSION} \ + && npm cache clean --force + +LABEL org.opencontainers.image.source="https://github.com/objectstack-ai/framework" \ + org.opencontainers.image.title="ObjectStack" \ + org.opencontainers.image.description="Official ObjectStack runtime: os start + your compiled objectstack.json artifact" \ + org.opencontainers.image.licenses="Apache-2.0" + +WORKDIR /srv/app +RUN chown node:node /srv/app +USER node + +ENV NODE_ENV=production \ + OS_ARTIFACT_PATH=/srv/app/objectstack.json \ + OS_PORT=8080 +EXPOSE 8080 + +# Liveness: /api/v1/health. For orchestrated readiness use /api/v1/ready. +# No backticks/$ in the -e script — this CMD runs under `/bin/sh -c`. +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \ + CMD node -e "fetch('http://localhost:'+(process.env.OS_PORT||8080)+'/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +# OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime — +# never bake them into an image. +CMD ["os", "start"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..72b0994bfb --- /dev/null +++ b/docker/README.md @@ -0,0 +1,65 @@ +# ObjectStack Official Runtime Image + +`ghcr.io/objectstack-ai/objectstack` — the official production runtime for +standalone ObjectStack apps. It packages Node 22 and `@objectstack/cli` +(`os start`) and nothing else: **your compiled artifact is the app**, the +image is the runtime. + +``` +objectstack.config.ts ──(os build, CI)──▶ dist/objectstack.json ──(this image)──▶ running app +``` + +## Tags + +Published by [`docker-publish.yml`](../.github/workflows/docker-publish.yml) +on every framework release. The image tag always equals the +`@objectstack/cli` version inside the image: + +| Tag | Meaning | +|:---|:---| +| `X.Y.Z` | Exact release — **pin this in production** | +| `X.Y`, `X` | Rolling minor / major | +| `latest` | Latest release — quick starts only | + +Multi-arch: `linux/amd64` + `linux/arm64`. + +## Usage + +**Extend it** (the usual path — see [`examples/docker`](../examples/docker)): + +```dockerfile +FROM ghcr.io/objectstack-ai/objectstack:14.8.0 +COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json +``` + +**Or run it directly** with a mounted or remote artifact — no image build: + +```bash +docker run -p 8080:8080 \ + -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ + -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ + -e OS_AUTH_SECRET -e OS_SECRET_KEY \ + ghcr.io/objectstack-ai/objectstack:14.8.0 +``` + +`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come +straight from your release storage. + +## What the image presets + +- `OS_ARTIFACT_PATH=/srv/app/objectstack.json`, `OS_PORT=8080`, + `NODE_ENV=production` +- Runs as the non-root `node` user +- `HEALTHCHECK` on `/api/v1/health` (liveness); use `/api/v1/ready` as the + readiness probe in orchestrators + +**You must inject at runtime:** `OS_DATABASE_URL`, `OS_AUTH_SECRET`, +`OS_SECRET_KEY` — never bake them into an image. Full variable catalog and +reverse-proxy / multi-node guidance: +[Self-Hosted Deployment](https://docs.objectstack.ai/docs/deployment/self-hosting). + +## Local build of this image + +```bash +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=14.8.0 docker/ +``` diff --git a/examples/docker/Dockerfile b/examples/docker/Dockerfile index 8dc6d44ca8..1cf90c3895 100644 --- a/examples/docker/Dockerfile +++ b/examples/docker/Dockerfile @@ -19,21 +19,14 @@ RUN npm ci COPY . . RUN npx os build # → dist/objectstack.json -# ── Runtime stage: CLI + artifact only ─────────────────────────────── -FROM node:22-slim -WORKDIR /srv/app -RUN npm install -g @objectstack/cli -COPY --from=build /app/dist/objectstack.json ./objectstack.json - -ENV NODE_ENV=production \ - OS_ARTIFACT_PATH=/srv/app/objectstack.json \ - OS_PORT=8080 -EXPOSE 8080 - -# Liveness: /api/v1/health. For orchestrated readiness use /api/v1/ready. -HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \ - CMD node -e "fetch('http://localhost:8080/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +# ── Runtime: the official ObjectStack runtime image ────────────────── +# Ships Node + @objectstack/cli with `os start`, a non-root user, the +# /api/v1/health HEALTHCHECK, and OS_ARTIFACT_PATH/OS_PORT preset (port 8080) +# — see docker/README.md in the framework repo. Pin the tag to the +# @objectstack/cli version in your package.json so the runtime matches the +# CLI that built the artifact. +FROM ghcr.io/objectstack-ai/objectstack:latest +COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json # OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime — # never bake them into the image. -CMD ["os", "start"] diff --git a/examples/docker/README.md b/examples/docker/README.md index 465e85d371..da6bfb0dc5 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -27,10 +27,19 @@ How it works, what the required variables mean, reverse-proxy wiring, and the multi-node caveats are documented in [Self-Hosted Deployment](https://docs.objectstack.ai/docs/deployment/self-hosting). -Two properties worth knowing: +Three properties worth knowing: -- The runtime image contains only Node, `@objectstack/cli`, and your compiled - `objectstack.json` — the build stage's TypeScript toolchain never ships. +- The runtime is the **official image** `ghcr.io/objectstack-ai/objectstack` + (see [`docker/README.md`](../../docker/README.md)): Node, `@objectstack/cli`, + non-root user, and health check — your final image adds only the compiled + `objectstack.json`. The build stage's TypeScript toolchain never ships. +- Pin the runtime tag to the `@objectstack/cli` version in your + `package.json` (image tags mirror CLI versions); `latest` is fine for a + first spin, wrong for production. - `OS_SECRET_KEY` must be provided at runtime. On a container's ephemeral filesystem the auto-minted dev key is lost on restart, which makes previously-encrypted secrets undecryptable. + +Don't need an image build at all? The official runtime image can run a +mounted or remote artifact directly — see +[`docker/README.md`](../../docker/README.md).