Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions doc/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,59 @@ docker run --detach --publish <DESIRED_PORT>:9001 <YOUR_USERNAME>/etherpad

And point your browser to `http://<YOUR_IP>:<DESIRED_PORT>`

### 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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions src/tests/container/readonly-cleanup.test.mjs
Original file line number Diff line number Diff line change
@@ -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']]);
});
137 changes: 137 additions & 0 deletions src/tests/container/readonly-root.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
}
});
Loading