From 53de042a43e6a9a7bf45bbc916c5c7ba2831e3ae Mon Sep 17 00:00:00 2001 From: alazarlemma02 Date: Fri, 10 Jul 2026 09:53:13 +0300 Subject: [PATCH 1/3] docker: prepare plugin volume mountpoint Create src/plugin_packages in the development and production runtime image stages so a fresh Docker named volume is initialized with Etherpad user ownership instead of root-only ownership. Fixes #8026 --- Dockerfile | 2 + .../backend/specs/dockerfilePluginVolume.ts | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/tests/backend/specs/dockerfilePluginVolume.ts diff --git a/Dockerfile b/Dockerfile index be7a201ca77..961747522cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -171,6 +171,7 @@ ARG ETHERPAD_GITHUB_PLUGINS= COPY --chown=etherpad:etherpad ./src/ ./src/ COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc +RUN mkdir -p ./src/plugin_packages COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/ @@ -203,6 +204,7 @@ RUN printf 'packages:\n - src\n - bin\nonlyBuiltDependencies:\n - esbuild\nig COPY --chown=etherpad:etherpad ./src ./src COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc +RUN mkdir -p ./src/plugin_packages COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/ diff --git a/src/tests/backend/specs/dockerfilePluginVolume.ts b/src/tests/backend/specs/dockerfilePluginVolume.ts new file mode 100644 index 00000000000..1026bbf6baa --- /dev/null +++ b/src/tests/backend/specs/dockerfilePluginVolume.ts @@ -0,0 +1,52 @@ +'use strict'; + +// Regression test for ether/etherpad#8026. +// +// docker-compose.yml mounts a named volume over src/plugin_packages while the +// app runs as UID 5001. Docker initializes a fresh named volume from the image's +// mountpoint metadata, so the runtime image must provide that directory owned by +// the etherpad user. If the directory is absent, Docker creates it as root:root +// and plugin installs fail when install.lock is created. + +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'); + +const getStage = (dockerfile: string, stageName: string): string => { + const stageStart = new RegExp(`^FROM\\s+\\S+\\s+AS\\s+${stageName}\\b`, 'm').exec(dockerfile); + assert.ok(stageStart, `Dockerfile must define a ${stageName} stage`); + + const rest = dockerfile.slice(stageStart.index); + const nextStage = /^FROM\s+/m.exec(rest.slice(stageStart[0].length)); + if (nextStage == null) return rest; + return rest.slice(0, stageStart[0].length + nextStage.index); +}; + +describe(__filename, function () { + describe('Docker plugin package volume mountpoint (issue #8026)', function () { + let dockerfile: string; + + before(function () { + dockerfile = readRepoFile('Dockerfile'); + }); + + for (const stageName of ['development', 'production']) { + it(`creates src/plugin_packages in the ${stageName} runtime stage`, function () { + const stage = getStage(dockerfile, stageName); + const srcCopy = stage.indexOf('COPY --chown=etherpad:etherpad ./src'); + const mkdir = stage.indexOf('RUN mkdir -p ./src/plugin_packages'); + + assert.notStrictEqual(srcCopy, -1, + `Dockerfile ${stageName} stage must copy ./src before preparing plugin_packages`); + assert.notStrictEqual(mkdir, -1, + `Dockerfile ${stageName} stage must create ./src/plugin_packages so a fresh ` + + 'Docker named volume is initialized writable by the etherpad user'); + assert.ok(mkdir > srcCopy, + `Dockerfile ${stageName} stage must create ./src/plugin_packages after copying ./src`); + }); + } + }); +}); From e4c74f25ec3a8981629c8eb69b4b30b18ea7c934 Mon Sep 17 00:00:00 2001 From: alazarlemma02 Date: Fri, 10 Jul 2026 11:36:46 +0300 Subject: [PATCH 2/3] tests: align docker plugin volume regression test with project style Replace CommonJS imports with Node.js module imports and reformat the test to match the project's coding style. This change is purely stylistic and does not alter the regression test's behavior. --- .../backend/specs/dockerfilePluginVolume.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/tests/backend/specs/dockerfilePluginVolume.ts b/src/tests/backend/specs/dockerfilePluginVolume.ts index 1026bbf6baa..0215497686a 100644 --- a/src/tests/backend/specs/dockerfilePluginVolume.ts +++ b/src/tests/backend/specs/dockerfilePluginVolume.ts @@ -8,9 +8,9 @@ // the etherpad user. If the directory is absent, Docker creates it as root:root // and plugin installs fail when install.lock is created. -const assert = require('assert').strict; -import fs from 'fs'; -import path from 'path'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; const repoRoot = path.join(__dirname, '../../../../'); const readRepoFile = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); @@ -21,7 +21,9 @@ const getStage = (dockerfile: string, stageName: string): string => { const rest = dockerfile.slice(stageStart.index); const nextStage = /^FROM\s+/m.exec(rest.slice(stageStart[0].length)); + if (nextStage == null) return rest; + return rest.slice(0, stageStart[0].length + nextStage.index); }; @@ -39,13 +41,21 @@ describe(__filename, function () { const srcCopy = stage.indexOf('COPY --chown=etherpad:etherpad ./src'); const mkdir = stage.indexOf('RUN mkdir -p ./src/plugin_packages'); - assert.notStrictEqual(srcCopy, -1, - `Dockerfile ${stageName} stage must copy ./src before preparing plugin_packages`); - assert.notStrictEqual(mkdir, -1, - `Dockerfile ${stageName} stage must create ./src/plugin_packages so a fresh ` + - 'Docker named volume is initialized writable by the etherpad user'); - assert.ok(mkdir > srcCopy, - `Dockerfile ${stageName} stage must create ./src/plugin_packages after copying ./src`); + assert.notStrictEqual( + srcCopy, + -1, + `Dockerfile ${stageName} stage must copy ./src before preparing plugin_packages`, + ); + assert.notStrictEqual( + mkdir, + -1, + `Dockerfile ${stageName} stage must create ./src/plugin_packages so a fresh ` + + 'Docker named volume is initialized writable by the etherpad user', + ); + assert.ok( + mkdir > srcCopy, + `Dockerfile ${stageName} stage must create ./src/plugin_packages after copying ./src`, + ); }); } }); From 9345f1e0d0ebb652f4cd0c96ba326a3d0e347d39 Mon Sep 17 00:00:00 2001 From: alazarlemma02 Date: Fri, 10 Jul 2026 12:00:12 +0300 Subject: [PATCH 3/3] tests: make docker plugin volume test less brittle Match Dockerfile instructions with regexes instead of exact substrings so the regression test validates the required behavior without rejecting harmless formatting or equivalent command changes. --- .../backend/specs/dockerfilePluginVolume.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/tests/backend/specs/dockerfilePluginVolume.ts b/src/tests/backend/specs/dockerfilePluginVolume.ts index 0215497686a..4226d370ae0 100644 --- a/src/tests/backend/specs/dockerfilePluginVolume.ts +++ b/src/tests/backend/specs/dockerfilePluginVolume.ts @@ -27,6 +27,16 @@ const getStage = (dockerfile: string, stageName: string): string => { return rest.slice(0, stageStart[0].length + nextStage.index); }; +const findInstruction = ( + stage: string, + pattern: RegExp, + description: string, +): RegExpExecArray => { + const match = pattern.exec(stage); + assert.ok(match, description); + return match; +}; + describe(__filename, function () { describe('Docker plugin package volume mountpoint (issue #8026)', function () { let dockerfile: string; @@ -38,22 +48,20 @@ describe(__filename, function () { for (const stageName of ['development', 'production']) { it(`creates src/plugin_packages in the ${stageName} runtime stage`, function () { const stage = getStage(dockerfile, stageName); - const srcCopy = stage.indexOf('COPY --chown=etherpad:etherpad ./src'); - const mkdir = stage.indexOf('RUN mkdir -p ./src/plugin_packages'); - - assert.notStrictEqual( - srcCopy, - -1, + const srcCopy = findInstruction( + stage, + /^COPY\s+--chown=etherpad:etherpad\s+\.\/src\/?\s+\.\/src\/?\s*$/m, `Dockerfile ${stageName} stage must copy ./src before preparing plugin_packages`, ); - assert.notStrictEqual( - mkdir, - -1, + const pluginPackagesDir = findInstruction( + stage, + /^RUN\s+.*\b(?:mkdir\s+-p|install\s+-d)(?:\s+\S+)*\s+\.\/src\/plugin_packages\b.*$/m, `Dockerfile ${stageName} stage must create ./src/plugin_packages so a fresh ` + 'Docker named volume is initialized writable by the etherpad user', ); + assert.ok( - mkdir > srcCopy, + pluginPackagesDir.index > srcCopy.index, `Dockerfile ${stageName} stage must create ./src/plugin_packages after copying ./src`, ); });