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..4226d370ae0 --- /dev/null +++ b/src/tests/backend/specs/dockerfilePluginVolume.ts @@ -0,0 +1,70 @@ +'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. + +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'); + +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); +}; + +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; + + 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 = findInstruction( + stage, + /^COPY\s+--chown=etherpad:etherpad\s+\.\/src\/?\s+\.\/src\/?\s*$/m, + `Dockerfile ${stageName} stage must copy ./src before preparing plugin_packages`, + ); + 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( + pluginPackagesDir.index > srcCopy.index, + `Dockerfile ${stageName} stage must create ./src/plugin_packages after copying ./src`, + ); + }); + } + }); +});