diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml index e920dbfa71..14fe69f87f 100644 --- a/.github/workflows/docker-cloud.yml +++ b/.github/workflows/docker-cloud.yml @@ -205,6 +205,8 @@ jobs: # variant installs from server/package.json's declared version; # add another name there when a managed tenant needs it. build-args: | + USER_UID=1001 + USER_GID=1001 CLOUD_BUNDLED_PLUGINS=daytona CLOUD_BUNDLED_SERVER_DEPS=@sentry/node PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }} @@ -251,6 +253,26 @@ jobs: fi echo "The pushed image resolves the declared @sentry/node version." + # Managed hosts run node as 1001:1001. Bake that identity into the image + # so usermod does not walk the mounted home on every container start. + # Check before the entrypoint can repair a wrongly built identity. + - name: Verify cloud runtime user + env: + IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} + run: | + set -euo pipefail + docker run --rm --entrypoint sh "$IMAGE" -ec ' + test "$(id -u node)" = 1001 + test "$(id -g node)" = 1001 + test "$USER_UID" = 1001 + test "$USER_GID" = 1001 + ' + docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec ' + test "$(id -u)" = 1001 + test "$(id -g)" = 1001 + test -w "$PAPERCLIP_HOME" + ' + # Verify the independently published cloud image without waiting for # the self-hosted manifest job. The Sentry check already pulled it. - name: Verify cloud PID 1 reaps orphaned processes diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md index 6522f03e05..e5c45c1586 100644 --- a/doc/cloud-build-readiness.md +++ b/doc/cloud-build-readiness.md @@ -15,6 +15,16 @@ The `Cloud readiness` workflow starts for every master push. Its versioned Registry metadata must match the full commit, and the database package must pin the matching shared package. +The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`, +matching the managed runtime. This avoids a startup user remap, which can walk +the mounted home and delay health checks. Before publishing the full-SHA tag, +the workflow checks the baked identity without running the entrypoint, then +checks the normal entrypoint's effective user and writable home. Volume ownership +repair still runs when needed. The Dockerfile defaults remain `1000:1000` for +self-hosted builds, and runtime identity overrides remain supported. The first +build with the new identity must rebuild layers that depend on the base image; +later builds can reuse those layers. + Verification and image building run concurrently, outside the full npm release's concurrency group. Different commits have independent groups. Source verification is initially duplicated with the normal npm release: this spends existing hosted diff --git a/scripts/preview-artifacts.test.mjs b/scripts/preview-artifacts.test.mjs index 0417365b1b..15ca6978f2 100644 --- a/scripts/preview-artifacts.test.mjs +++ b/scripts/preview-artifacts.test.mjs @@ -197,6 +197,24 @@ test("cloud builds start per commit and preserve tag promotion dependencies", () assert.ok(reaping < cloud.indexOf(" - name: Publish verified full-SHA cloud tag")); }); +test("cloud builds bake the managed runtime identity and verify it before publication", () => { + const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8"); + const build = workflow.split(" - name: Build and push (cloud)")[1].split(" - name:")[0]; + assert.match(build, /build-args: \|\n\s+USER_UID=1001\n\s+USER_GID=1001\n/); + const verify = workflow.indexOf(" - name: Verify cloud runtime user"); + assert.ok(verify > workflow.indexOf(" - name: Verify the pushed image resolves the declared Sentry version")); + assert.ok(verify < workflow.indexOf(" - name: Publish verified full-SHA cloud tag")); + const step = workflow.slice(verify).split("\n - name:")[0]; + assert.match(step, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/); + assert.doesNotMatch(step, /continue-on-error:|if:/); + assert.ok(step.indexOf('--entrypoint sh "$IMAGE"') < step.indexOf('-e USER_UID=1001 -e USER_GID=1001')); + for (const flag of ["u", "g"]) { + assert.ok(step.includes(`test "$(id -${flag} node)" = 1001`)); + assert.ok(step.includes(`test "$(id -${flag})" = 1001`)); + } + assert.ok(step.includes('test -w "$PAPERCLIP_HOME"')); +}); + test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => { const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8"); const step = workflow.split(" - name: Select cloud cache ancestry")[1].split(" - name: Setup pnpm")[0]; diff --git a/server/src/__tests__/docker-entrypoint.test.ts b/server/src/__tests__/docker-entrypoint.test.ts index 102eca1b8b..03d0e4acb9 100644 --- a/server/src/__tests__/docker-entrypoint.test.ts +++ b/server/src/__tests__/docker-entrypoint.test.ts @@ -98,6 +98,18 @@ describe("docker-entrypoint.sh", () => { expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); }); + it.each([false, true])("skips remapping a cloud identity while preserving volume repair (mismatch: %s)", async (homeMismatch) => { + installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, homeMismatch }); + + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls).not.toContain("usermod"); + expect(calls).not.toContain("groupmod"); + expect(calls.includes(`chown -R node:node ${stubDir}`)).toBe(homeMismatch); + expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); + }); + it("chowns a root-owned home before gosu even with the default UID/GID (fresh volume mount)", async () => { // A freshly mounted volume arrives root-owned and shadows the image's // build-time chown; with no remap requested the old entrypoint dropped