From b19ad89eb01781ebd582a005ed4c406d02a53bad Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 09:31:00 +0100 Subject: [PATCH] Fix offline/air-gapped Docker boot: stop pnpm self-provisioning the pinned version (#7911) (#7918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docker): don't let pnpm self-provision a pinned version on offline boot (#7911) The official Docker image installs pnpm directly via npm (corepack was dropped for Node 25+). Standalone pnpm still honours the "packageManager" pin in package.json: the image's pnpm intentionally lags that pin (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), so pnpm treats every invocation — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download and run the pinned build. Behind a corporate firewall / in an air-gapped install that download fails: [WARN] plugins - Failed to get pnpm version: Error: Command exited with code 1: pnpm --version which is what #7911 reported. Fix — neutralise the gap instead of closing it (closing it would break the frozen-lockfile build on 11.1.x): - Dockerfile build stage sets `pnpm_config_pm_on_fail=ignore` (the pnpm 11 successor to managePackageManagerVersions), inherited by the development and production runtime stages. pnpm then uses the installed pnpm instead of fetching the pinned one. It does not change which pnpm runs the build-time install, so the frozen-lockfile build is unaffected. - plugins.ts startup probe and the updater's pnpm-on-PATH checks run with the same flag, so the fix also covers non-Docker offline installs and the probe can never fail-loud. Add a backend spec that fails CI if the offline guard is dropped while the image pnpm differs from the package.json pin. Verified with a standalone (non-corepack) pnpm: a "packageManager" mismatch makes `pnpm --version` exit 1 by default (tries to fetch the pinned build), and exit 0 reading the local version with pm_on_fail=ignore. Co-Authored-By: Claude Opus 4.8 (1M context) * test: scope pnpm offline-guard check to the runtime-inherited build stage Address Qodo review: the regression spec matched ENV pnpm_config_pm_on_fail anywhere in the Dockerfile, so it would still pass if the guard were removed from the `build` stage (which the runtime stages inherit) but left in the throwaway `adminbuild` stage — reintroducing the offline failure. Extract the `build` stage block and assert the ENV is present there specifically. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + Dockerfile | 16 ++++ src/node/hooks/express/updateActions.ts | 6 +- src/node/updater/index.ts | 6 +- src/static/js/pluginfw/plugins.ts | 9 ++- src/tests/backend/specs/dockerfilePnpmPin.ts | 80 ++++++++++++++++++++ 6 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/tests/backend/specs/dockerfilePnpmPin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4729a506..e7ecafc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ A defence-in-depth pass across the API, token, export, and deployment surfaces: - **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`. - **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests. - **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`. +- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists. - **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views. ### Internal / contributor-facing diff --git a/Dockerfile b/Dockerfile index 56d65a76f..be7a201ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,11 @@ # docker build --build-arg BUILD_ENV=copy . ARG BUILD_ENV=git +# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm +# 11.1.x enforces the minimum-release-age supply-chain policy during install, +# which the frozen-lockfile Docker build can't satisfy, so the image stays on +# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in +# the build stage below — see ether/etherpad#7911. ARG PnpmVersion=11.0.6 FROM node:24-alpine AS adminbuild @@ -28,6 +33,17 @@ RUN pnpm run build:ui FROM node:24-alpine AS build LABEL maintainer="Etherpad team, https://github.com/ether/etherpad" +# The image's pnpm intentionally lags the "packageManager" pin (see the ARG +# note above). pnpm would otherwise try to self-provision the pinned version on +# invocation — including the informational `pnpm --version` probe Etherpad runs +# at startup — which fails closed with no network and breaks air-gapped boots +# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version +# instead. Inherited by the development and production runtime stages, so it +# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec +# shell. It does not change which pnpm runs the build-time install (still the +# installed 11.0.x), so the frozen-lockfile build is unaffected. +ENV pnpm_config_pm_on_fail=ignore + # Set these arguments when building the image from behind a proxy ARG http_proxy= ARG https_proxy= diff --git a/src/node/hooks/express/updateActions.ts b/src/node/hooks/express/updateActions.ts index 0b4f1a2ae..4f90f0098 100644 --- a/src/node/hooks/express/updateActions.ts +++ b/src/node/hooks/express/updateActions.ts @@ -84,7 +84,11 @@ const buildPreflightDeps = (installMethod: ReturnType new Promise((resolve) => { - const c = spawn('pnpm', ['--version'], {stdio: 'ignore'}); + // pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm + // exit non-zero (it would otherwise try to fetch the pinned build, which + // fails offline) and falsely report pnpm as absent. See #7911. + const c = spawn('pnpm', ['--version'], + {stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}}); c.on('close', (code) => resolve(code === 0)); c.on('error', () => resolve(false)); }), diff --git a/src/node/updater/index.ts b/src/node/updater/index.ts index 9a8a332e9..aa15dfbf9 100644 --- a/src/node/updater/index.ts +++ b/src/node/updater/index.ts @@ -297,7 +297,11 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({ } }, pnpmOnPath: () => new Promise((resolve) => { - const c = spawn('pnpm', ['--version'], {stdio: 'ignore'}); + // pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm + // exit non-zero (it would otherwise try to fetch the pinned build, which + // fails offline) and falsely report pnpm as absent. See #7911. + const c = spawn('pnpm', ['--version'], + {stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}}); c.on('close', (code) => resolve(code === 0)); c.on('error', () => resolve(false)); }), diff --git a/src/static/js/pluginfw/plugins.ts b/src/static/js/pluginfw/plugins.ts index f2960138a..ab5d28896 100644 --- a/src/static/js/pluginfw/plugins.ts +++ b/src/static/js/pluginfw/plugins.ts @@ -20,9 +20,16 @@ const logger = log4js.getLogger('plugins'); // installs go through live-plugin-manager directly), so a missing pnpm // is expected on hardened/packaged installs and shouldn't produce an // ERROR in the logs. +// +// pnpm_config_pm_on_fail=ignore keeps this informational probe from failing +// when the installed pnpm differs from package.json's "packageManager" pin: +// by default pnpm would try to download the pinned version, which throws in +// air-gapped/firewalled installs (ether/etherpad#7911). We only want to read +// the local version here, never to self-provision a different one. (async () => { try { - const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']}); + const version = await runCmd(['pnpm', '--version'], + {stdio: [null, 'string'], env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}}); logger.info(`pnpm --version: ${version}`); } catch (err) { if ((err as any).code === 'ENOENT') { diff --git a/src/tests/backend/specs/dockerfilePnpmPin.ts b/src/tests/backend/specs/dockerfilePnpmPin.ts new file mode 100644 index 000000000..94a15a8c0 --- /dev/null +++ b/src/tests/backend/specs/dockerfilePnpmPin.ts @@ -0,0 +1,80 @@ +'use strict'; + +// Regression tests for ether/etherpad#7911. +// +// The official Docker image installs pnpm directly (corepack was dropped for +// Node 25+). Standalone pnpm still honours the "packageManager" pin in +// package.json: when the pnpm baked into the image differs from that pin, pnpm +// treats every invocation — including the informational `pnpm --version` probe +// Etherpad runs at startup — as a request to self-provision the pinned build. +// With no outbound network (air-gapped / behind a corporate firewall) that +// download fails and pnpm exits non-zero, surfacing as `Failed to get pnpm +// version` and breaking offline boots. +// +// The image deliberately lags the pin (pnpm 11.1.x enforces a minimum-release- +// age policy the frozen-lockfile build can't satisfy), so the guard is not to +// force the versions equal but to neutralise the gap: the Dockerfile must set +// pnpm_config_pm_on_fail=ignore so pnpm uses the installed version instead of +// reaching for the network. This test fails if that guard is dropped while a +// version gap exists. + +const assert = require('assert').strict; +import fs from 'fs'; +import path from 'path'; + +const repoRoot = path.join(__dirname, '../../../../'); +const readRepoFile = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + +describe(__filename, function () { + describe('Docker pnpm offline guard (issue #7911)', function () { + let dockerfile: string; + let imagePnpm: string; + let pinPnpm: string; + let guardPresent: boolean; + + before(function () { + dockerfile = readRepoFile('Dockerfile'); + + const argMatch = dockerfile.match(/^ARG PnpmVersion=(\S+)/m); + assert.ok(argMatch, 'Dockerfile must declare `ARG PnpmVersion=`'); + imagePnpm = argMatch![1]; + + const pkg = JSON.parse(readRepoFile('package.json')); + const pinMatch = String(pkg.packageManager || '').match(/^pnpm@(.+)$/); + assert.ok(pinMatch, `expected packageManager "pnpm@", got "${pkg.packageManager}"`); + pinPnpm = pinMatch![1]; + + // The guard only protects runtime if it lives in the `build` stage, which + // the development/production runtime stages inherit from. The same ENV in + // the throwaway `adminbuild` stage would NOT reach runtime, so scope the + // check to the build stage block (from `FROM ... AS build` to the next + // `FROM`) rather than matching anywhere in the file. + const buildStage = dockerfile.match( + /^FROM\s+\S+\s+AS\s+build\b[\s\S]*?(?=^FROM\s)/m); + assert.ok(buildStage, 'Dockerfile must define a `FROM ... AS build` stage'); + guardPresent = /ENV\s+pnpm_config_pm_on_fail=ignore/.test(buildStage![0]); + }); + + it('neutralises any pnpm version gap so offline boots do not self-provision', function () { + // The actual safety property: a mismatch between the image pnpm and the + // package.json pin is only safe when pm_on_fail=ignore is set. (If they + // are ever realigned, the guard becomes belt-and-suspenders, not required.) + if (imagePnpm !== pinPnpm) { + assert.ok(guardPresent, + `Dockerfile pnpm ${imagePnpm} differs from package.json pnpm@${pinPnpm}, ` + + 'but pnpm_config_pm_on_fail=ignore is not set — pnpm will try to ' + + 'self-provision the pinned version and break offline/air-gapped ' + + 'startup (issue #7911).'); + } + }); + + it('sets pnpm_config_pm_on_fail=ignore in the runtime-inherited build stage', function () { + assert.ok(guardPresent, + 'The `build` stage must set pnpm_config_pm_on_fail=ignore (it is ' + + 'inherited by the development/production runtime stages) so runtime ' + + 'pnpm calls — the startup probe, the updater pnpm check — do not fail ' + + 'closed when offline (issue #7911). Setting it only in `adminbuild` ' + + 'would not reach runtime.'); + }); + }); +});