diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d88adf56072..f8976411f35 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -177,6 +177,14 @@ jobs: docker rm -f test-offline >/dev/null 2>&1 || true [ "$ok" = "1" ] || exit 1 + - name: Regression — read-only root and persistent migration state (#8084) + working-directory: etherpad + env: + ETHERPAD_TEST_IMAGE: ${{ env.TEST_TAG }} + run: | + env -u ETHERPAD_TEST_IMAGE node --test src/tests/container/readonly-cleanup.test.mjs + node --test src/tests/container/readonly-root.mjs + build-test-local-plugin: # Regression coverage for #7687: the Docker image's # `bin/installLocalPlugins.sh` step runs as the `etherpad` user and diff --git a/doc/docker.md b/doc/docker.md index 3e99c77cbed..439f4e0c57f 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -99,6 +99,59 @@ docker run --detach --publish :9001 /etherpad And point your browser to `http://:` +### Read-only root filesystem (core-only deployment) + +The image can run as UID 5001 with a read-only root filesystem. Etherpad still +needs writable runtime directories: `/tmp`, `/opt/etherpad-lite/var` (including +the SQLite database and plugin migration state), and +`/opt/etherpad-lite/src/plugin_packages`. For a core-only deployment: + +```bash +docker run --detach --name etherpad --read-only --user 5001:0 \ + --cap-drop ALL --security-opt no-new-privileges \ + --tmpfs /tmp:rw,nosuid,nodev,size=96m,mode=1777 \ + --mount type=volume,source=etherpad-var,target=/opt/etherpad-lite/var \ + --tmpfs /opt/etherpad-lite/src/plugin_packages:rw,nosuid,nodev,size=48m,uid=5001,gid=0 \ + --env DB_TYPE=sqlite --env DB_FILENAME=var/etherpad.sq3 \ + --env PRIVACY_PLUGIN_CATALOG=false --env PRIVACY_UPDATE_CHECK=false \ + --env UPDATES_TIER=off \ + --publish 127.0.0.1:9001:9001 etherpad/etherpad:3.3.7 +``` + +This example disables online update/catalog checks and exposes the service only +on the host loopback interface. Keep the `etherpad-var` volume to preserve data. +Existing bind mounts must be writable by UID 5001; Docker initializes a new named +volume from the image's directory ownership. + +This is not a guarantee for third-party plugins, legacy plugin migrations, or +runtime plugin installation: those can require additional writable paths. The +plugin directory above is temporary and intentionally empty; use a separate +tested deployment plan if plugins are needed. Read-only settings also cannot be +saved through the admin settings editor. + +For issue [#8084](https://github.com/ether/etherpad/issues/8084), the official +3.3.3 image fails on first boot while `pnpm ls` opens its package-store SQLite +index for writing. The same core-only layout boots and restarts with the official +3.3.7 image. Do not make the entire root filesystem writable just to work around +the older startup failure; first test an updated image against your own data and +plugins. These two versions do not establish the first fixed release. + +To test an already-built image (requires Node.js and Docker, no dependency install): + +```bash +ETHERPAD_TEST_IMAGE=etherpad/etherpad:3.3.7 node --test src/tests/container/readonly-root.mjs +``` + +The test creates an isolated core-only instance without external networking or +published ports, verifies HTTP health and the home page, then restarts it using +the same data volume and checks that migration state is retained. It removes only +its own container and temporary volume. It does not test collaborative editing +or third-party plugin behavior. +It reports the shutdown exit code separately: the tested 3.3.7 image's cleanup +watchdog exits with code 1 on both writable and read-only roots, even though HTTP +and database shutdown complete. A passing restart test is not a clean-shutdown +guarantee. + ## Options available by default The `settings.json.docker` available by default allows to control almost every setting via environment variables. diff --git a/package.json b/package.json index 6bff33ad617..07a0b7ae719 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test": "pnpm --filter ep_etherpad-lite run test", "test-utils": "pnpm --filter ep_etherpad-lite run test-utils", "test-container": "pnpm --filter ep_etherpad-lite run test-container", + "test-container-readonly": "node --test src/tests/container/readonly-root.mjs", "dev": "pnpm --filter ep_etherpad-lite run dev", "prod": "pnpm --filter ep_etherpad-lite run prod", "ts-check": "pnpm --filter ep_etherpad-lite run ts-check", diff --git a/src/tests/container/readonly-cleanup.test.mjs b/src/tests/container/readonly-cleanup.test.mjs new file mode 100644 index 00000000000..527e9b1d101 --- /dev/null +++ b/src/tests/container/readonly-cleanup.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {cleanupDockerResources} from './readonly-root.mjs'; + +test('cleanup removes an already-stopped container without a stop command', () => { + const calls = []; + const errors = cleanupDockerResources((...args) => calls.push(args), + {container: 'test-container', volume: 'test-volume'}, assert.fail); + assert.deepEqual(calls, [['rm', '--force', 'test-container'], ['volume', 'rm', 'test-volume']]); + assert.deepEqual(errors, []); +}); + +test('cleanup attempts volume removal after container removal fails', () => { + const calls = []; + const messages = []; + const failure = new Error('container removal failed'); + const errors = cleanupDockerResources((...args) => { + calls.push(args); + if (args[0] === 'rm') throw failure; + }, {container: 'test-container', volume: 'test-volume'}, (message) => messages.push(message)); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1], ['volume', 'rm', 'test-volume']); + assert.deepEqual(errors, [failure]); + assert.match(messages[0], /container removal failed/); +}); + +test('cleanup reports both failures without throwing over the original failure', () => { + const original = new Error('original startup failure'); + const messages = []; + let caught; + try { + try { + throw original; + } finally { + const errors = cleanupDockerResources(() => { throw new Error('cleanup failed'); }, + {container: 'test-container', volume: 'test-volume'}, (message) => messages.push(message)); + assert.equal(errors.length, 2); + } + } catch (error) { + caught = error; + } + assert.equal(caught, original); + assert.equal(messages.length, 2); +}); + +test('cleanup only attempts resources actually created', () => { + const calls = []; + cleanupDockerResources((...args) => calls.push(args), {container: null, volume: 'test-volume'}, assert.fail); + assert.deepEqual(calls, [['volume', 'rm', 'test-volume']]); +}); diff --git a/src/tests/container/readonly-root.mjs b/src/tests/container/readonly-root.mjs new file mode 100644 index 00000000000..12c4b821631 --- /dev/null +++ b/src/tests/container/readonly-root.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import {setTimeout as delay} from 'node:timers/promises'; +import test from 'node:test'; + +// This host-side test needs Docker and an already-built image. It never pulls an +// image, publishes a port, or connects Etherpad to an external network. +const image = process.env.ETHERPAD_TEST_IMAGE; +const docker = (...args) => { + const result = spawnSync('docker', args, {encoding: 'utf8', timeout: 30000}); + assert.ifError(result.error); + assert.equal(result.status, 0, + `docker ${args.join(' ')} failed:\n${result.stdout}\n${result.stderr}`); + return result.stdout.trim(); +}; + +export const cleanupDockerResources = (run, {container, volume}, diagnostic) => { + const errors = []; + const attempt = (...args) => { + try { + run(...args); + } catch (error) { + errors.push(error); + diagnostic(`Cleanup: ${error.message}`); + } + }; + if (container) { + // An already-stopped container needs no stop. Force removal also handles a + // failed stop, and volume removal is still attempted if removal fails. + attempt('rm', '--force', container); + } + if (volume) attempt('volume', 'rm', volume); + return errors; +}; + +test('read-only root: first boot and restart preserve plugin migration state', { + skip: image ? false : 'Set ETHERPAD_TEST_IMAGE to a locally available Etherpad image', + timeout: 180000, +}, async (t) => { + const name = `etherpad-readonly-test-${randomUUID()}`; + const volume = `${name}-var`; + let volumeCreated = false; + let containerCreated = false; + let testFailed = false; + const state = () => JSON.parse(docker('inspect', '--format', '{{json .State}}', name)); + const logs = () => docker('logs', name); + const waitForHealth = async () => { + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + const current = state(); + if (!current.Running) assert.fail(`Etherpad exited before healthy:\n${logs()}`); + if (current.Health?.Status === 'healthy') return; + await delay(1000); + } + assert.fail(`Etherpad did not become healthy within 60s:\n${logs()}`); + }; + const readPluginState = () => docker('exec', name, 'node', '-e', + 'process.stdout.write(require("node:fs").readFileSync(' + + '"/opt/etherpad-lite/var/installed_plugins.json", "utf8"))'); + const assertHttp = () => { + const response = JSON.parse(docker('exec', name, 'node', '-e', + '(async () => { const health = await fetch("http://127.0.0.1:9001/health");' + + 'const home = await fetch("http://127.0.0.1:9001/");' + + 'console.log(JSON.stringify({healthStatus: health.status, health: await health.json(),' + + 'homeStatus: home.status, homeType: home.headers.get("content-type")}));' + + '})().catch(e => { console.error(e); process.exit(1); })')); + assert.equal(response.healthStatus, 200); + assert.equal(response.health.status, 'pass'); + assert.equal(response.homeStatus, 200); + assert.match(response.homeType, /text\/html/); + }; + try { + const imageInfo = JSON.parse(docker('image', 'inspect', image))[0]; + t.diagnostic(JSON.stringify({image, imageId: imageInfo.Id, + revision: imageInfo.Config.Labels?.['org.opencontainers.image.revision']})); + // A fresh named volume inherits the image's var/ ownership. It is retained + // across the restart, unlike a tmpfs, then removed in finally. + docker('volume', 'create', volume); + volumeCreated = true; + docker('create', '--name', name, '--read-only', '--network', 'none', + '--user', '5001:0', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--memory', '768m', '--cpus', '1', '--pids-limit', '160', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=96m,mode=1777', + '--mount', `type=volume,source=${volume},target=/opt/etherpad-lite/var`, + '--tmpfs', '/opt/etherpad-lite/src/plugin_packages:rw,nosuid,nodev,size=48m,uid=5001,gid=0', + '--health-cmd', 'wget --quiet --spider http://127.0.0.1:9001/health || exit 1', + '--health-interval', '2s', '--health-timeout', '2s', '--health-retries', '30', + '-e', 'DB_TYPE=sqlite', '-e', 'DB_FILENAME=var/etherpad.sq3', + '-e', 'NODE_ENV=production', '-e', 'ETHERPAD_PRODUCTION=true', + '-e', 'PRIVACY_PLUGIN_CATALOG=false', '-e', 'PRIVACY_UPDATE_CHECK=false', + '-e', 'UPDATES_TIER=off', image); + containerCreated = true; + const container = JSON.parse(docker('inspect', name))[0]; + assert.equal(container.HostConfig.ReadonlyRootfs, true); + assert.equal(container.HostConfig.NetworkMode, 'none'); + assert.equal(container.Config.User, '5001:0'); + docker('start', name); + await waitForHealth(); + assertHttp(); + const firstState = readPluginState(); + const plugins = JSON.parse(firstState).plugins; + assert.ok(plugins.some((p) => p.name === 'ep_etherpad-lite')); + const firstMigrationCount = (logs().match(/start migration of plugins in node_modules/g) || []).length; + assert.equal(firstMigrationCount, 1, 'fresh var volume should run migration once'); + t.diagnostic('First boot: HTTP health and home OK; migration state created.'); + + docker('stop', '--time', '15', name); + const stopped = state(); + assert.equal(stopped.Running, false); + assert.equal(stopped.OOMKilled, false); + assert.notEqual(stopped.ExitCode, 137, 'Docker must not have to kill the process'); + // Check restart/persistence here, not unrelated shutdown behavior. The 3.3.7 + // image exits 1 after its own cleanup watchdog on both writable and read-only + // roots; report it instead of claiming a clean shutdown or hiding the result. + t.diagnostic(`Stop exit code: ${stopped.ExitCode} (0 is clean; nonzero needs separate investigation).`); + docker('start', name); + await waitForHealth(); + assertHttp(); + assert.equal(readPluginState(), firstState, 'restart must retain the migration state'); + assert.equal((logs().match(/start migration of plugins in node_modules/g) || []).length, + firstMigrationCount, 'restart must not migrate again'); + t.diagnostic('Restart: HTTP health and home OK; migration not repeated.'); + } catch (error) { + testFailed = true; + throw error; + } finally { + // Names are generated for this test only. Never remove existing user data. + const cleanupErrors = cleanupDockerResources(docker, { + container: containerCreated ? name : null, + volume: volumeCreated ? volume : null, + }, (message) => t.diagnostic(message)); + if (!testFailed && cleanupErrors.length) { + throw new AggregateError(cleanupErrors, 'Docker test resource cleanup failed'); + } + } +});