diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d108ffcfc9..1cf0640c62 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -120,9 +120,50 @@ jobs: uses: docker/build-push-action@v7 with: context: . + # Pin the self-hosted image to the production stage explicitly: + # the Dockerfile now declares a later `cloud` stage, and without a + # target the default would silently become that stage. + target: production platforms: linux/amd64,linux/arm64 push: true cache-from: type=gha cache-to: type=gha,mode=max tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + # The cloud variant carries built bundled plugins for managed + # deployments (see the `cloud` stage in the Dockerfile). Published + # under the same tag set with a `-cloud` suffix (sha--cloud, + # latest-cloud, -cloud). Reuses the layer cache from the + # production build, so this mostly adds the plugin-build layers. + - name: Docker meta (cloud) + id: meta-cloud + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + flavor: | + suffix=-cloud,onlatest=true + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + labels: | + io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} + io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }} + + - name: Build and push (cloud) + uses: docker/build-push-action@v7 + with: + context: . + target: cloud + # Space-separated sandbox-provider directory names to build into + # the variant; add here when managed deployments need another. + build-args: | + CLOUD_BUNDLED_PLUGINS=daytona + platforms: linux/amd64,linux/arm64 + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta-cloud.outputs.tags }} + labels: ${{ steps.meta-cloud.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index f07931cab9..e6a3cba9d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,3 +90,38 @@ EXPOSE 3100 ENTRYPOINT ["docker-entrypoint.sh"] CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"] + +# Cloud image variant (build with `--target cloud`): the production image +# plus built bundled sandbox-provider plugins. Managed instances receive a +# `plugins.autoInstall` key list through PAPERCLIP_MANAGED_CONFIG and +# install those plugins from the bundled catalog at boot +# (server/src/services/bundled-plugins.ts), which requires each plugin's +# dist/ to exist in the image — the default image ships only their source, +# so auto-install logs "bundle not present" and skips. The plugins are +# built in this separate target so the default (self-hosted) image stays +# lean; CI pins the default build to `--target production`, which is +# byte-identical to before this stage existed. +# +# The sandbox providers are intentionally excluded from the pnpm workspace +# (see pnpm-workspace.yaml), so each installs standalone exactly as its +# README prescribes. Installing in a `build`-based stage (not `production`) +# keeps devDependencies available for tsc: `production` sets +# NODE_ENV=production, which would make pnpm skip them. +# +# CLOUD_BUNDLED_PLUGINS is the space-separated list of sandbox-provider +# directory names to build into the variant. Only what managed deployments +# actually auto-install belongs here — every entry adds its node_modules +# to the image. Growing the list is a one-line workflow change. +FROM build AS cloud-plugins +ARG CLOUD_BUNDLED_PLUGINS="daytona" +RUN set -eu; \ + for name in $CLOUD_BUNDLED_PLUGINS; do \ + dir="packages/plugins/sandbox-providers/$name"; \ + test -d "$dir" || { echo "ERROR: unknown sandbox provider '$name'" >&2; exit 1; }; \ + pnpm -C "$dir" install --ignore-workspace --no-lockfile; \ + pnpm -C "$dir" build; \ + test -f "$dir/dist/manifest.js" || { echo "ERROR: $dir is missing dist/manifest.js after build" >&2; exit 1; }; \ + done + +FROM production AS cloud +COPY --chown=node:node --from=cloud-plugins /app/packages/plugins/sandbox-providers /app/packages/plugins/sandbox-providers diff --git a/server/src/__tests__/cloud-image-bundled-plugins.test.ts b/server/src/__tests__/cloud-image-bundled-plugins.test.ts new file mode 100644 index 0000000000..ad944fd0cd --- /dev/null +++ b/server/src/__tests__/cloud-image-bundled-plugins.test.ts @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { BUNDLED_PLUGIN_CATALOG } from "../services/bundled-plugins.js"; + +/** + * Drift guard for the cloud image variant (Dockerfile `cloud` target). + * + * The cloud image builds the sandbox-provider plugins named in the + * CLOUD_BUNDLED_PLUGINS build arg so managed instances can auto-install + * them from the bundled catalog at boot. That contract spans three places + * that nothing else ties together: the Dockerfile ARG default, the docker + * workflow's build-arg, and BUNDLED_PLUGIN_CATALOG. A rename or removal in + * any one of them would otherwise surface only when the image build fails + * on master — or worse, as a silent "bundle not present" skip at instance + * boot. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); +const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8"); + +function parseList(source: string, pattern: RegExp, label: string): string[] { + const match = source.match(pattern); + expect(match, `${label} must declare CLOUD_BUNDLED_PLUGINS`).toBeTruthy(); + const names = (match?.[1] ?? "").trim().split(/\s+/).filter(Boolean); + expect(names.length, `${label} CLOUD_BUNDLED_PLUGINS must not be empty`).toBeGreaterThan(0); + return names; +} + +const dockerfileDefault = parseList( + dockerfile, + /^ARG CLOUD_BUNDLED_PLUGINS="([^"]*)"/m, + "Dockerfile", +); +const workflowArg = parseList( + workflow, + /^\s*CLOUD_BUNDLED_PLUGINS=(.*)$/m, + "docker workflow", +); + +describe("cloud image bundled plugins", () => { + it("keeps the Dockerfile default and the workflow build-arg in sync", () => { + expect(workflowArg).toEqual(dockerfileDefault); + }); + + it.each([...new Set([...dockerfileDefault, ...workflowArg])])( + "plugin %s is buildable and resolvable by the auto-installer", + (name) => { + const dir = path.join(repoRoot, "packages", "plugins", "sandbox-providers", name); + expect(existsSync(dir), `${dir} must exist`).toBe(true); + expect( + existsSync(path.join(dir, "src", "manifest.ts")), + `${name} must have src/manifest.ts so the build produces dist/manifest.js`, + ).toBe(true); + const packageJson = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as { + scripts?: Record; + }; + expect(packageJson.scripts?.build, `${name} must have a build script`).toBeTruthy(); + + // The auto-installer resolves catalog keys to relative paths; a plugin + // baked into the image but absent from the catalog (or vice versa) + // can never be auto-installed. + const catalogEntry = BUNDLED_PLUGIN_CATALOG.find( + (entry) => entry.relativePath === `sandbox-providers/${name}`, + ); + expect(catalogEntry, `${name} must be listed in BUNDLED_PLUGIN_CATALOG`).toBeTruthy(); + }, + ); + + it("pins the default image build to the production target", () => { + // The Dockerfile's final stage is `cloud`; without an explicit target + // the workflow's main build would silently publish the cloud variant + // to the self-hosted tags. + expect(workflow).toMatch(/^\s*target: production$/m); + }); +});