etherpad-lite/snap/README.md
John McLear 02e37e0112
feat(packaging): publish Etherpad as a Snap (#7558)
* feat(packaging): publish Etherpad as a Snap

Adds first-class Snap packaging so Ubuntu / snapd users can install via
`sudo snap install etherpad-lite`.

- snap/snapcraft.yaml — core24, strict confinement, builds with pnpm
  against a pinned Node.js 22 runtime. Version is auto-derived from
  src/package.json so `snap info` tracks upstream release numbering.
- snap/local/bin/etherpad-service — launch wrapper that seeds
  $SNAP_COMMON/etc/settings.json on first run (rewriting the default
  dirty-DB path to a writable $SNAP_COMMON location) and execs Etherpad
  via `node --import tsx/esm`.
- snap/local/bin/etherpad-healthcheck-wrapper — HTTP probe for external
  supervisors, falling back to Node if curl isn't staged.
- snap/local/bin/etherpad-cli — thin passthrough to Etherpad's bin/
  scripts (importSqlFile, checkPad, etc.).
- snap/hooks/configure — exposes `snap set etherpad-lite port=<n>` and
  `ip=<addr>` with validation, restarts the service when running.
- snap/README.md — build / install / configure / publish instructions.
- .github/workflows/snap-publish.yml — builds on every v* tag, uploads
  a short-lived artifact, publishes to `edge`, and then promotes to
  `stable` through a manually-approved GitHub Environment. Requires a
  one-time `snapcraft register etherpad-lite` plus provisioning of the
  `SNAPCRAFT_STORE_CREDENTIALS` repo secret (instructions inline).

Pad data (dirty DB, logs) lives in /var/snap/etherpad-lite/common/ and
survives snap refreshes. The read-only $SNAP squashfs is never written
to at runtime.

Refs #7529

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snap): pass --settings flag, env-subst ip/port, 2-space indent

Addresses Qodo review feedback on #7558:

1. Settings file ignored: Etherpad's Settings loader reads `argv.settings`,
   not the `EP_SETTINGS` env var. Without `--settings`, the launcher's
   seeded $SNAP_COMMON/etc/settings.json is never loaded; Etherpad falls
   back to <install-root>/settings.json, which lives on the read-only
   squashfs — so the default dirty-DB path ends up unwritable and the
   daemon fails to persist pads. Fix: pass `--settings "${SETTINGS}"` to
   node; drop the EP_SETTINGS export.

2. `snap set` overrides were no-ops: the seeded settings.json carries the
   template's literal `"ip": "0.0.0.0"` / `"port": 9001` values, which
   override the env-based defaults Etherpad exposes via ${…}
   substitution. Users following the README saw the listener stay put
   after `snap set etherpad-lite port=…`. Fix: after copying the
   template on first run, rewrite the top-level `ip` and `port` lines
   to `"${IP:0.0.0.0}"` / `"${PORT:9001}"`. Use `0,/…/` anchors so the
   `dbSettings.port` entry further down stays literal.

3. Indentation: reflow the new shell scripts from 4-space to 2-space to
   match the repo style rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snap): default seeded settings to sqlite, not dirty

settings.json.template's own comment says dirty is for testing only.
A Snap install is the "not testing" case — shipping it by default
means every `sudo snap install etherpad-lite` starts on a DB the
project explicitly recommends against.

Rewrite the postinstall sed to switch dbType: "dirty" → "sqlite" and
point filename at $SNAP_COMMON/var/etherpad.db. sqlite is already
shipped in-tree via ueberdb2 → rusty-store-kv (prebuilt napi-rs
binary, no build deps), so this works under strict confinement with
zero snap.yaml changes.

Only affects first-run seeding; existing $SNAP_COMMON/etc/settings.json
is never touched on refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snap): rename to "etherpad", glob tag filter, harden cli

- Snap is registered as `etherpad` (the project's only name) — drops the
  legacy `etherpad-lite` from the name, app, paths, install dir, configure
  hook, README and workflow artifact. The daemon app shares the snap name,
  so `snap install etherpad` exposes a bare `etherpad` command; the bin/
  passthrough is now `etherpad.cli`.
- snap-publish.yml: GitHub Actions tag filters use globs, not regex. The
  prior `v?[0-9]+.[0-9]+.[0-9]+` pattern would never match a real release
  tag (Qodo review). Replace with two glob entries covering `vX.Y.Z` and
  `X.Y.Z`.
- etherpad-cli: reject path-traversal in the `<bin-script>` arg (anything
  containing `/`, `..`, or empty) and add a default `*)` case so files
  with unsupported extensions fail loud instead of silently exiting 0
  (Qodo review).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snap): unbreak build — refresh corepack, drop pnpm prune

Two issues hit on the first real `snapcraft pack` of this recipe:

- `corepack prepare pnpm@10.33.0 --activate` failed with
  `Cannot find matching keyid` because Node 22.12's bundled corepack
  ships a stale signing-key list and rejects newer pnpm releases
  (nodejs/corepack#612). Refresh corepack itself via npm before
  preparing pnpm.
- `pnpm prune --prod` is interactive on workspace projects: it asks
  "The modules directories will be removed and reinstalled from
  scratch. Proceed? (Y/n)" and deadlocks on stdin under sudo + tee.
  Replace it with the explicit "wipe node_modules + prod reinstall"
  pattern, which is non-interactive, faster (pnpm resolves the prod
  graph from its CAS cache), and byte-identical in result.

Verified locally: `snapcraft pack --destructive-mode` produces
`etherpad_2.6.1_amd64.snap` end-to-end in ~3 min.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snap): unbreak runtime — tsx resolution, var/ writability, env

Three runtime crashes surfaced when actually installing the built snap
under strict confinement. Fixed each, plus a smoke-test script.

- `tsx` is in the `src` workspace's node_modules under pnpm hoisting,
  not at the snap install root. The wrapper now `cd "${APP_DIR}/src"`
  and uses bare `--import tsx` (matching `bin/cleanRun.sh`); the prior
  `--import tsx/esm` triggered ERR_REQUIRE_CYCLE on Etherpad's mixed
  CJS/ESM source tree.
- Etherpad's plugin installer writes `var/installed_plugins.json` via
  __dirname-relative paths, which resolve to absolute paths inside the
  read-only snap squashfs (EROFS). snap layouts can't intercept paths
  inside `$SNAP`, so replace the shipped `var/` dir with a symlink to
  `/var/snap/etherpad/common/etherpad-app-var/` (auto-created by the
  wrapper on first run). Persistent state survives `snap refresh`.
- Drop the unused `EP_SETTINGS` and `EP_DATA_DIR` env vars from the
  app's `environment:` block. Etherpad's settings loader doesn't read
  them — it reads `argv.settings`, which the wrapper already passes via
  `--settings`. They were producing `[WARN] settings - Unknown Setting`
  noise on every start.

Add `snap/tests/smoke.sh`: rebuild + install + configure test port 9003
+ assert listener + curl /health + tail logs. Local verified output:
  HTTP 200, body {"status":"pass","releaseId":"2.6.1"}, server logs
  `Etherpad is running` on `http://0.0.0.0:9003/`.

.gitignore now excludes destructive-mode build outputs (parts/, stage/,
prime/, .craft/, *.snap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(snap): wrapper unit tests, PR CI build, expanded docs

Coverage in snap/tests/ (47 assertions, ~5s, no snapd/sudo/network):
- test-snapcraft-yaml.sh: required keys, name validity, daemon-app
  matches snap name, no etherpad-lite regression, env-var whitelist.
- test-cli.sh: path-traversal rejection, .ts/.sh dispatch, default-case
  rejection, no-args usage.
- test-configure.sh: port (1-65535) and ip (v4/v6) validation via
  mocked snapctl.
- test-service-bootstrap.sh: first-run seeding from
  settings.json.template, sed rewrite of dbType/filename/ip/port,
  writable-dir creation, snapctl override propagation to node env,
  idempotency on second run, default fallbacks.
- run-all.sh: bash -n syntax check on every wrapper + hook, then
  sources each test file and reports totals. All assertions use port
  9003 (project test convention).

CI in .github/workflows/snap-build.yml:
- Triggers on PR / push-to-develop touching snap/, settings.json.template,
  or the workflow itself.
- Job 1 wrapper-tests: runs run-all.sh.
- Job 2 snap-pack: snapcraft pack --destructive-mode, uploads .snap as
  PR artifact for sideload.
- Stays separate from snap-publish.yml (tag-triggered, store-bound).

snap/README.md fully rewritten:
- User-facing usage, install, configure
- Architecture: file layout, var/-symlink rationale, settings.json
  rewrite rationale, double-pnpm-install rationale, daemon-name-shares-
  snap-name rationale
- Three test layers with exactly when/why to run each
- Dev workflow loop
- Publishing maintainer setup
- Troubleshooting for every failure mode hit during this PR (EROFS,
  tsx not found, ERR_REQUIRE_CYCLE, snap-store-down, pnpm prune hang)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(snap): replace dead snapcraft.io/docs/releasing-to-the-snap-store link

That URL now 404s. Point at the canonical documentation.ubuntu.com
locations instead, broken out into the specific pages a maintainer
actually needs:

- Register a snap (to claim the name)
- snapcraft export-login (to generate the SNAPCRAFT_STORE_CREDENTIALS
  secret)
- Publishing how-to index (root index for everything else)

Same fix in the snap-publish.yml header comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:19:10 +01:00

266 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Etherpad snap
Packages Etherpad as a [Snap](https://snapcraft.io/) for publishing to the
Snap Store.
- [User-facing usage](#user-facing-usage)
- [Architecture](#architecture)
- [Testing](#testing)
- [Development workflow](#development-workflow)
- [Publishing](#publishing)
- [Troubleshooting](#troubleshooting)
## User-facing usage
### Install from the store
```
sudo snap install etherpad
```
The default listen port is **9001**. Pad data lives in
`/var/snap/etherpad/common/` and survives `snap refresh`.
### Configure
The snap seeds `$SNAP_COMMON/etc/settings.json` from the upstream
template on first run. Edit that file directly to customise Etherpad,
then:
```
sudo snap restart etherpad
```
A few values are exposed as snap config so users don't have to edit the
file by hand:
| Key | Default | Notes |
| ------------------------------ | --------- | --------------- |
| `snap set etherpad port=9001` | `9001` | Listen port |
| `snap set etherpad ip=0.0.0.0` | `0.0.0.0` | Bind address |
The configure hook validates these (`port` must be 165535 integer,
`ip` must be a valid v4/v6 address) and restarts the daemon on change.
### Build locally
```
sudo snap install --classic snapcraft
sudo snap install lxd && sudo lxd init --auto
snapcraft # from repo root; uses LXD by default
```
Output: `etherpad_<version>_<arch>.snap`.
### Install a local build
```
sudo snap install --dangerous ./etherpad_*.snap
sudo snap start etherpad
curl http://127.0.0.1:9001/health # → {"status":"pass","releaseId":"X.Y.Z"}
```
Logs: `sudo snap logs etherpad -f`.
## Architecture
### File layout inside the snap
```
$SNAP/ # = /snap/etherpad/current (read-only squashfs)
├── opt/
│ ├── node/bin/node # pinned Node.js 22.12.0
│ └── etherpad/
│ ├── src/ # ep_etherpad-lite workspace package (with node_modules incl. tsx)
│ ├── admin/, ui/, doc/ # other workspace packages (built artefacts)
│ ├── settings.json.template # template, copied to $SNAP_COMMON on first run
│ └── var → /var/snap/etherpad/common/etherpad-app-var/ # symlink (see below)
├── bin/
│ ├── etherpad-service # daemon launch wrapper
│ ├── etherpad-cli # passthrough to bin/ scripts
│ └── etherpad-healthcheck-wrapper # HTTP /health probe
└── meta/snap.yaml
$SNAP_COMMON/ # = /var/snap/etherpad/common (read-write, persists across refreshes)
├── etc/settings.json # seeded from template on first run, never overwritten
├── var/etherpad.db # sqlite database
├── etherpad-app-var/installed_plugins.json # plugin registry, written by Etherpad core
└── logs/ # reserved for future use
```
### Why the `var/` symlink
Etherpad's plugin installer
(`src/static/js/pluginfw/installer.ts`) writes
`installed_plugins.json` via `__dirname`-relative paths, which resolve
to absolute paths inside `$SNAP` — read-only squashfs. Snap layouts
can't intercept paths inside `$SNAP`, so we replace the shipped `var/`
directory with a **symlink** at build time pointing to
`/var/snap/etherpad/common/etherpad-app-var/` (created by the wrapper
on first run). The kernel transparently follows the symlink to writable
storage that survives `snap refresh`.
### Why the seeded `settings.json` is rewritten
The upstream `settings.json.template` defaults to `dbType: "dirty"`
the template itself warns this is dev-only. The launch wrapper rewrites
the seeded copy on first run to:
- `dbType: "sqlite"` with file at `$SNAP_COMMON/var/etherpad.db`
- `ip: "${IP:0.0.0.0}"` — Etherpad's own env-substitution syntax
- `port: "${PORT:9001}"` — same
The wrapper then exports `IP` and `PORT` from the snap config (via
`snapctl get`), so `snap set etherpad port=N` actually moves the
listener.
### Why pnpm runs twice
`pnpm install --frozen-lockfile --prod=false` first (need devDeps to
build admin/ui/docs), then `rm -rf node_modules && pnpm install --prod
--frozen-lockfile --ignore-scripts` after the build. This is faster
than `pnpm prune --prod`, which is interactive on workspace projects
(prompts "Proceed? (Y/n)" to stdin) and deadlocks under the
non-interactive build environment. See
[nodejs/corepack#612](https://github.com/nodejs/corepack/issues/612)
for the corepack-keyring refresh in step 2.
### Why the daemon shares the snap name
`apps.etherpad` matches the snap name `etherpad`, so users invoke the
daemon via `snap install etherpad` → bare `etherpad` command. The CLI
passthrough is exposed as `etherpad.cli` (e.g.
`etherpad.cli importSqlFile something.sql`).
## Testing
Three layers, each independently runnable:
### 1. Wrapper unit tests (~5 s, no snapd/sudo)
```
bash snap/tests/run-all.sh
```
Runs `bash -n` syntax checks on every wrapper + hook, then sources
each `test-*.sh` and reports pass/fail counts. Coverage:
- `test-snapcraft-yaml.sh` — required keys, name validity, daemon-app
matches snap name, no `etherpad-lite` regression, environment vars
whitelist.
- `test-cli.sh` — path-traversal rejection (`../`, subdir, empty),
`.ts` / `.sh` dispatch, default-case rejection, no-args usage.
- `test-configure.sh` — port (165535 integer) and ip (v4/v6) validation
via mocked `snapctl`.
- `test-service-bootstrap.sh` — first-run seeding from
`settings.json.template`, sed rewrite of dbType/filename/ip/port,
writable-dir creation, snapctl override propagation to node env,
idempotency on second run, default fallbacks.
All tests use **port 9003** for any binding (per project convention,
since 9001 is reserved for ad-hoc local Etherpad work).
### 2. CI build verification
`.github/workflows/snap-build.yml` runs on every PR that touches
`snap/`, `settings.json.template`, or the workflow itself. Two jobs:
- `wrapper-tests` — runs `snap/tests/run-all.sh` (~5 s).
- `snap-pack` — runs `snapcraft pack --destructive-mode` and uploads
the resulting `.snap` as an artifact (downloadable from the run
summary so reviewers can sideload).
This is intentionally separate from `snap-publish.yml` (tag-triggered,
LXD-based, pushes to the store).
### 3. End-to-end smoke test (~3 min, requires sudo + snapd)
```
bash snap/tests/smoke.sh
```
Rebuilds via destructive-mode, installs the resulting `.snap`,
configures `port=9003`, restarts, waits for plugin migration to
finish, asserts a listener on 9003, hits `/health`, and tails the
last 20 log lines. Useful when changing the wrappers or the build
recipe before pushing.
## Development workflow
```
# 1. Make a change to snap/snapcraft.yaml or one of the wrappers.
# 2. Fast feedback loop — only the unit tests:
bash snap/tests/run-all.sh
# 3. Full local verification — actually build and install:
bash snap/tests/smoke.sh
# 4. Push. CI will run wrapper-tests + snap-pack on the PR.
git push
```
If `snapcraft pack` complains about the LXD provider,
`--destructive-mode` lets you build directly on the host (used by both
the smoke script and CI). It pollutes the host with build deps and
puts `parts/`, `stage/`, `prime/` in the worktree (gitignored). Wipe
with `sudo rm -rf parts stage prime`.
## Publishing
Maintainers only. See:
- [Register a snap](https://documentation.ubuntu.com/snapcraft/latest/how-to/publishing/register-a-snap/) — claims the name on the store
- [`snapcraft export-login`](https://documentation.ubuntu.com/snapcraft/reference/commands/export-login/) — generates the credential we put in `SNAPCRAFT_STORE_CREDENTIALS`
- [Snapcraft publishing how-to index](https://documentation.ubuntu.com/snapcraft/latest/how-to/publishing/)
One-time setup:
```
snapcraft register etherpad
snapcraft export-login --snaps etherpad \
--channels edge,stable \
--acls package_access,package_push,package_release -
```
Store the printed credential in the repo secret
`SNAPCRAFT_STORE_CREDENTIALS`. Create a GitHub Environment named
`snap-store-stable` with required reviewers so stable promotion is
gated.
`.github/workflows/snap-publish.yml` then handles the rest on every
`vX.Y.Z` (or `X.Y.Z`) tag: build → publish to `edge` → manual approval
gate → publish to `stable`.
## Troubleshooting
**Daemon flapping with `EROFS: read-only file system`** — Etherpad is
trying to write somewhere inside `$SNAP`. Check whether the path is
covered by the `var/` symlink (architecture section above). New write
targets need either an additional symlink at build time
(`snap/snapcraft.yaml` step 4) or a config knob to redirect into
`$SNAP_COMMON`.
**`Cannot find package 'tsx'`** — the wrapper must `cd "${APP_DIR}/src"`
before `node`, since `tsx` lives in the workspace's `node_modules` and
not at the install root under pnpm hoisting.
**`ERR_REQUIRE_CYCLE_MODULE`** — use bare `--import tsx`, not
`--import tsx/esm`. The ESM-only loader trips on Etherpad's mixed
CJS/ESM source.
**`snap install` fails with `unable to contact snap store`** — almost
always a Canonical-side outage. Check
[snapcraft.statuspage.io](https://snapcraft.statuspage.io). For
*local* development you can sidestep the store dependency entirely by
building with `snapcraft pack --destructive-mode` (no LXD container
provisioning, so no in-container `snap install`).
**`pnpm prune --prod` hangs forever** — never use it directly here. It
has an interactive "Proceed? (Y/n)" prompt for workspaces that
deadlocks under sudo/tee. The build recipe uses
`rm -rf node_modules && pnpm install --prod --frozen-lockfile
--ignore-scripts` instead.
**`snap refresh` blew away my data** — it didn't. Pad data is in
`/var/snap/etherpad/common/`, which is preserved across refreshes.
Check `/var/snap/etherpad/common/var/etherpad.db` exists.