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>
This commit is contained in:
John McLear 2026-05-02 20:19:10 +08:00 committed by GitHub
parent 983aec5b2f
commit 02e37e0112
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1277 additions and 1 deletions

71
.github/workflows/snap-build.yml vendored Normal file
View File

@ -0,0 +1,71 @@
# Snap build verification — runs on every PR (and on develop) that
# touches snap/ or this workflow. Catches breakage in the wrappers and
# the build recipe before it reaches a release tag.
#
# Two jobs:
# wrapper-tests — runs the bash unit tests under snap/tests/. Fast
# (~5s); no snapd / sudo / network needed. Always runs first.
# snap-pack — builds the actual snap with `snapcraft pack
# --destructive-mode` (no LXD). Faster than the publish workflow
# because it skips container provisioning and store auth. Uploads
# the .snap as an artifact so reviewers can sideload it.
#
# Production publishing lives in `snap-publish.yml` (tag-triggered).
name: Snap build (PR)
on:
pull_request:
paths:
- 'snap/**'
- 'settings.json.template'
- '.github/workflows/snap-build.yml'
push:
branches: [develop]
paths:
- 'snap/**'
- 'settings.json.template'
- '.github/workflows/snap-build.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
wrapper-tests:
name: Wrapper unit tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Run snap/tests/run-all.sh
run: bash snap/tests/run-all.sh
snap-pack:
name: Build snap (destructive-mode)
needs: wrapper-tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install snapcraft
run: sudo snap install --classic snapcraft
- name: Pack snap
run: sudo snapcraft pack --destructive-mode --verbose
- name: Locate built artifact
id: artifact
run: |
set -e
SNAP=$(ls etherpad_*.snap | head -1)
if [ -z "${SNAP}" ]; then
echo "::error::no .snap file produced"
exit 1
fi
echo "snap=${SNAP}" >> "${GITHUB_OUTPUT}"
- name: Upload .snap artifact
uses: actions/upload-artifact@v4
with:
name: etherpad-snap-pr-${{ github.event.pull_request.number || github.run_id }}
path: ${{ steps.artifact.outputs.snap }}
if-no-files-found: error
retention-days: 7

92
.github/workflows/snap-publish.yml vendored Normal file
View File

@ -0,0 +1,92 @@
# Builds and publishes the Etherpad snap on tagged releases.
# Mirrors the trigger pattern from .github/workflows/docker.yml / release.yml
# (semver tags, with or without a leading `v`).
#
# Note: `on.push.tags` uses GitHub's filter-pattern globs, NOT regex — so the
# pattern must be expressed as two glob entries, not a single `v?...` regex.
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
#
# One-time maintainer setup:
# 1. `snapcraft register etherpad` claims the name.
# 2. Generate a store credential:
# snapcraft export-login --snaps etherpad \
# --channels edge,stable \
# --acls package_access,package_push,package_release -
# Store the output as repo secret SNAPCRAFT_STORE_CREDENTIALS.
# 3. Create a GitHub Environment called `snap-store-stable` with required
# reviewers so stable promotion is gated.
#
# Ref: https://documentation.ubuntu.com/snapcraft/latest/how-to/publishing/
name: Snap
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
snap-file: ${{ steps.build.outputs.snap }}
steps:
- name: Check out
uses: actions/checkout@v6
- name: Build snap
id: build
uses: snapcore/action-build@v1
- name: Upload snap artifact
uses: actions/upload-artifact@v4
with:
name: etherpad-snap
path: ${{ steps.build.outputs.snap }}
if-no-files-found: error
retention-days: 7
publish-edge:
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Download snap artifact
uses: actions/download-artifact@v4
with:
name: etherpad-snap
- name: Publish to edge
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ needs.build.outputs.snap-file }}
release: edge
publish-stable:
needs: [build, publish-edge]
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
# Manual gate: promote edge -> stable via GitHub Environments approval.
environment: snap-store-stable
steps:
- name: Download snap artifact
uses: actions/download-artifact@v4
with:
name: etherpad-snap
- name: Publish to stable
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ needs.build.outputs.snap-file }}
release: stable

8
.gitignore vendored
View File

@ -27,8 +27,14 @@ plugin_packages
playwright-report
state.json
/src/static/oidc
# Build artefacts produced by packaging/test-local.sh and the deb-package CI workflow.
/staging/
/dist/
/packaging/etc/
# Snapcraft destructive-mode build outputs.
parts/
stage/
prime/
.craft/
*.snap

266
snap/README.md Normal file
View File

@ -0,0 +1,266 @@
# 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.

24
snap/hooks/configure vendored Executable file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# Validates values set via `snap set etherpad key=value`.
# Supported keys:
# port : integer 1-65535 (default 9001). Ports <1024 require AppArmor override.
# ip : bind address (default 0.0.0.0)
set -euo pipefail
PORT="$(snapctl get port || true)"
if [ -n "${PORT}" ]; then
if ! [[ "${PORT}" =~ ^[0-9]+$ ]] || [ "${PORT}" -lt 1 ] || [ "${PORT}" -gt 65535 ]; then
echo "port must be an integer 1-65535" >&2
exit 1
fi
fi
IP="$(snapctl get ip || true)"
if [ -n "${IP}" ] && ! [[ "${IP}" =~ ^[0-9a-fA-F.:]+$ ]]; then
echo "ip must be a valid IPv4/IPv6 address" >&2
exit 1
fi
if snapctl services etherpad.etherpad 2>/dev/null | grep -q active; then
snapctl restart etherpad.etherpad
fi

35
snap/local/bin/etherpad-cli Executable file
View File

@ -0,0 +1,35 @@
#!/bin/bash
# Thin passthrough to Etherpad's bin/ scripts.
# Usage: etherpad.cli <bin-script> [args...]
set -euo pipefail
APP_DIR="${SNAP}/opt/etherpad"
NODE_BIN="${SNAP}/opt/node/bin/node"
export PATH="${SNAP}/opt/node/bin:${PATH}"
if [ "$#" -eq 0 ]; then
echo "Usage: etherpad.cli <bin-script> [args...]"
echo "Available scripts:"
ls "${APP_DIR}/bin" | grep -E '\.(ts|sh)$' | sed 's/^/ /'
exit 2
fi
SCRIPT_NAME="$1"; shift
# Reject path-traversal attempts: only a bare filename is allowed, since
# the script lookup is anchored at $APP_DIR/bin and must not escape it.
case "${SCRIPT_NAME}" in
*/*|*..*|"")
echo "invalid script name: ${SCRIPT_NAME} (must be a bare filename)" >&2
exit 2 ;;
esac
SCRIPT_PATH="${APP_DIR}/bin/${SCRIPT_NAME}"
[ -f "${SCRIPT_PATH}" ] || { echo "no such script: ${SCRIPT_NAME}" >&2; exit 2; }
case "${SCRIPT_PATH}" in
*.sh) exec "${SCRIPT_PATH}" "$@" ;;
*.ts) exec "${NODE_BIN}" --import tsx/esm "${SCRIPT_PATH}" "$@" ;;
*) echo "unsupported script type: ${SCRIPT_NAME} (expected .sh or .ts)" >&2
exit 2 ;;
esac

View File

@ -0,0 +1,20 @@
#!/bin/bash
# HTTP healthcheck. Returns 0 if /health returns 200.
set -euo pipefail
PORT="$(snapctl get port 2>/dev/null || true)"
: "${PORT:=9001}"
if command -v curl >/dev/null 2>&1; then
exec curl --fail --silent --show-error --max-time 5 \
"http://127.0.0.1:${PORT}/health"
fi
NODE_BIN="${SNAP}/opt/node/bin/node"
exec "${NODE_BIN}" -e '
const http = require("http");
http.get("http://127.0.0.1:'"${PORT}"'/health", r => {
if (r.statusCode === 200) process.exit(0);
console.error("HTTP " + r.statusCode); process.exit(1);
}).on("error", e => { console.error(e.message); process.exit(1); });
'

71
snap/local/bin/etherpad-service Executable file
View File

@ -0,0 +1,71 @@
#!/bin/bash
# Launch wrapper for the Etherpad snap daemon.
#
# 1. On first run, copy settings.json.template -> $SNAP_COMMON/etc/settings.json
# so the admin can edit it outside the read-only squashfs. Patch the
# seeded file so the DB (switched from dev-only dirty to sqlite) / ip /
# port point at writable paths and pick up env-var overrides.
# 2. Create writable data dirs under $SNAP_COMMON.
# 3. Apply `snap set` overrides (port, ip) via env vars — Etherpad's
# settings.json supports ${PORT:9001}-style substitution natively.
# 4. Exec Node with tsx loader to run server.ts, passing the seeded
# settings file via --settings (Etherpad reads `argv.settings`, not
# an env var, so EP_SETTINGS alone would be ignored).
set -euo pipefail
APP_DIR="${SNAP}/opt/etherpad"
NODE_BIN="${SNAP}/opt/node/bin/node"
export PATH="${SNAP}/opt/node/bin:${SNAP}/usr/bin:${SNAP}/bin:${PATH}"
ETC_DIR="${SNAP_COMMON}/etc"
VAR_DIR="${SNAP_COMMON}/var"
LOG_DIR="${SNAP_COMMON}/logs"
# etherpad-app-var/ is the symlink target for ${APP_DIR}/var inside the
# read-only snap mount — see snapcraft.yaml step 4. Etherpad writes its
# plugin registry (installed_plugins.json) here.
APP_VAR_DIR="${SNAP_COMMON}/etherpad-app-var"
mkdir -p "${ETC_DIR}" "${VAR_DIR}" "${LOG_DIR}" "${APP_VAR_DIR}"
SETTINGS="${ETC_DIR}/settings.json"
if [ ! -f "${SETTINGS}" ]; then
echo "[etherpad-snap] bootstrapping ${SETTINGS} from template"
cp "${APP_DIR}/settings.json.template" "${SETTINGS}"
# Switch the template's dev-only dirty default to sqlite and point it
# at $SNAP_COMMON (absolute path, writable under strict confinement).
# sqlite is shipped by ueberdb2 via the prebuilt rusty-store-kv native
# module — no additional build deps required.
sed -i \
-e 's|"dbType": "dirty"|"dbType": "sqlite"|' \
-e 's|"filename": "var/dirty.db"|"filename": "'"${VAR_DIR}"'/etherpad.db"|' \
"${SETTINGS}"
# Rewrite ip/port literals to Etherpad's env-substitution syntax so
# `snap set etherpad port=<n>` / `ip=<addr>` actually take effect.
# Only substitute the first (top-level) occurrence — `dbSettings.port`
# has the same key name lower down and must not be touched.
sed -i \
-e '0,/"ip": "0.0.0.0"/{s|"ip": "0.0.0.0"|"ip": "${IP:0.0.0.0}"|}' \
-e '0,/"port": 9001/{s|"port": 9001|"port": "${PORT:9001}"|}' \
"${SETTINGS}"
fi
PORT_OVERRIDE="$(snapctl get port || true)"
IP_OVERRIDE="$(snapctl get ip || true)"
: "${PORT_OVERRIDE:=9001}"
: "${IP_OVERRIDE:=0.0.0.0}"
export PORT="${PORT_OVERRIDE}"
export IP="${IP_OVERRIDE}"
# `tsx` lives in the `src` workspace's node_modules under pnpm's hoisting
# layout, not at ${APP_DIR}/node_modules. Run from src/ so node's import
# resolution finds it (mirrors the `dev` script in src/package.json).
cd "${APP_DIR}/src"
export NODE_ENV=production
# Pass --settings explicitly; Etherpad's Settings loader reads argv only,
# so exporting EP_SETTINGS is not enough to redirect the config file.
# Use bare `tsx` (not `tsx/esm`) to match bin/cleanRun.sh — Etherpad's
# source mixes CJS+ESM and the ESM-only loader trips ERR_REQUIRE_CYCLE.
exec "${NODE_BIN}" --import tsx node/server.ts \
--settings "${SETTINGS}" \
"$@"

161
snap/snapcraft.yaml Normal file
View File

@ -0,0 +1,161 @@
# snap/snapcraft.yaml — Snap recipe for Etherpad
#
# Design notes:
# - base: core24 chosen because Etherpad requires Node.js >= 20 and
# core24 (Ubuntu 24.04 LTS) ships glibc/OpenSSL versions matching modern
# Node 20/22 binaries. core22 also works but ships older TLS/CA bundles.
# - confinement: strict. Etherpad is a pure Node.js HTTP service. The only
# native Node module (`rusty-store-kv`) ships as a prebuilt napi-rs
# binary, so no node-gyp compile is performed at install time and
# strict confinement works cleanly.
# - We use `dump` + a manual override-build (rather than the npm plugin)
# because this repo is a pnpm workspace and we pin Node.js 22 manually.
name: etherpad
title: Etherpad
summary: Real-time collaborative document editor
description: |
Etherpad is a highly customizable open-source online editor providing
collaborative editing in real-time. This snap bundles Etherpad with a
pinned Node.js 22 runtime. On first launch a default `settings.json`
is copied into `$SNAP_COMMON/etc` where it can be edited. Pad data is
stored in `$SNAP_COMMON/var` and survives snap refreshes.
Default listen port: 9001.
Upstream: https://etherpad.org
Source: https://github.com/ether/etherpad
license: Apache-2.0
website: https://etherpad.org
source-code: https://github.com/ether/etherpad
issues: https://github.com/ether/etherpad/issues
contact: https://etherpad.org/#community
adopt-info: etherpad
grade: stable
confinement: strict
base: core24
compression: lzo
platforms:
amd64:
arm64:
apps:
# Bare app name matches the snap name → users invoke it as plain `etherpad`.
etherpad:
command: bin/etherpad-service
daemon: simple
install-mode: enable
restart-condition: on-failure
plugs:
- network
- network-bind
environment:
HOME: $SNAP_DATA
NODE_ENV: production
# PORT/IP are env-substituted into settings.json on first run and
# overridden by the wrapper from `snap set etherpad port=…` / `ip=…`.
PORT: "9001"
IP: "0.0.0.0"
healthcheck:
command: bin/etherpad-healthcheck-wrapper
plugs:
- network
cli:
command: bin/etherpad-cli
plugs:
- network
- network-bind
parts:
etherpad:
plugin: dump
source: .
source-type: local
build-packages:
- curl
- ca-certificates
- git
- python3
- build-essential
stage-packages:
- ca-certificates
- libstdc++6
- openssl
override-pull: |
craftctl default
VERSION="$(grep -m1 '"version"' src/package.json | sed -E 's/.*"([^"]+)".*/\1/')"
craftctl set version="${VERSION}"
override-build: |
set -eu
# -- 1. Install Node.js 22 from the official tarball.
NODE_VERSION=22.12.0
ARCH="$(dpkg --print-architecture)"
case "${ARCH}" in
amd64) NODE_ARCH=x64 ;;
arm64) NODE_ARCH=arm64 ;;
*) echo "Unsupported arch ${ARCH}"; exit 1 ;;
esac
NODE_TGZ="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TGZ}" \
-o "/tmp/${NODE_TGZ}"
mkdir -p "${CRAFT_PART_INSTALL}/opt/node"
tar -xJf "/tmp/${NODE_TGZ}" -C "${CRAFT_PART_INSTALL}/opt/node" \
--strip-components=1
export PATH="${CRAFT_PART_INSTALL}/opt/node/bin:${PATH}"
# -- 2. Install pnpm via corepack. The corepack version bundled with
# Node 22.12 ships a stale signing-key list and rejects newer pnpm
# releases (nodejs/corepack#612), so refresh corepack itself first.
"${CRAFT_PART_INSTALL}/opt/node/bin/npm" install \
--prefix "${CRAFT_PART_INSTALL}/opt/node" -g corepack@latest
corepack enable --install-directory "${CRAFT_PART_INSTALL}/opt/node/bin"
corepack prepare pnpm@10.33.0 --activate
# -- 3. Copy source into install dir and build.
APP_DIR="${CRAFT_PART_INSTALL}/opt/etherpad"
mkdir -p "${APP_DIR}"
cp -a "${CRAFT_PART_SRC}/." "${APP_DIR}/"
cd "${APP_DIR}"
pnpm install --frozen-lockfile --prod=false
pnpm run build:etherpad
# Strip dev deps to shrink the payload. `pnpm prune --prod` walks
# every workspace symlink and can deadlock on cross-referenced
# packages — wipe node_modules and reinstall prod-only instead;
# pnpm resolves the smaller graph from cache in a fraction of the
# time and the result is byte-identical to a clean prod install.
find . -type d -name node_modules -prune -exec rm -rf {} +
pnpm install --prod --frozen-lockfile --ignore-scripts
rm -rf .git tests/frontend-new/.cache \
src/tests/frontend-new/test-results || true
# -- 4. Etherpad's plugin installer writes installed_plugins.json
# under its own install dir's var/ at runtime
# (src/static/js/pluginfw/installer.ts uses __dirname, which
# resolves to an absolute path inside the read-only snap squashfs).
# snap layouts can't redirect __dirname-relative paths, so replace
# the shipped var/ dir with a symlink into $SNAP_COMMON instead;
# the kernel transparently resolves the symlink to writable storage
# that survives `snap refresh`. The wrapper mkdirs the target.
rm -rf "${APP_DIR}/var"
ln -s /var/snap/etherpad/common/etherpad-app-var "${APP_DIR}/var"
# -- 5. Install wrappers.
install -Dm755 "${CRAFT_PROJECT_DIR}/snap/local/bin/etherpad-service" \
"${CRAFT_PART_INSTALL}/bin/etherpad-service"
install -Dm755 "${CRAFT_PROJECT_DIR}/snap/local/bin/etherpad-healthcheck-wrapper" \
"${CRAFT_PART_INSTALL}/bin/etherpad-healthcheck-wrapper"
install -Dm755 "${CRAFT_PROJECT_DIR}/snap/local/bin/etherpad-cli" \
"${CRAFT_PART_INSTALL}/bin/etherpad-cli"
hooks:
configure:
plugs:
- network

73
snap/tests/lib.sh Executable file
View File

@ -0,0 +1,73 @@
#!/bin/bash
# Tiny helpers for the snap wrapper test scripts. Source from each test.
set -uo pipefail
# Counters maintained by the runner.
: "${PASS_COUNT:=0}"
: "${FAIL_COUNT:=0}"
: "${TEST_NAME:?TEST_NAME must be set by the calling script}"
# Path to the wrapper / hook directory, computed once.
SNAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
WRAPPERS_DIR="${SNAP_DIR}/local/bin"
HOOKS_DIR="${SNAP_DIR}/hooks"
red() { printf '\033[31m%s\033[0m' "$*"; }
green() { printf '\033[32m%s\033[0m' "$*"; }
gray() { printf '\033[90m%s\033[0m' "$*"; }
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf ' %s %s\n' "$(green ✓)" "$1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
printf ' %s %s\n' "$(red ✗)" "$1"
if [ -n "${2:-}" ]; then
printf ' %s\n' "$(gray "$2")"
fi
}
# assert_eq actual expected name
assert_eq() {
local actual="$1" expected="$2" name="$3"
if [ "$actual" = "$expected" ]; then
pass "$name"
else
fail "$name" "expected: $(printf '%q' "$expected") got: $(printf '%q' "$actual")"
fi
}
# assert_exit cmd expected_exit name [stdin]
assert_exit() {
local expected="$1" name="$2"; shift 2
local out actual
out=$("$@" 2>&1) || true
actual=$?
# bash quirk: $? from the assignment is the assignment's, not the command's.
# Re-run inline to capture exit:
"$@" >/dev/null 2>&1
actual=$?
if [ "$actual" = "$expected" ]; then
pass "$name"
else
fail "$name" "expected exit $expected, got $actual; output: $out"
fi
}
# assert_grep cmd needle name — fail if cmd's combined output doesn't match
assert_grep() {
local needle="$1" name="$2"; shift 2
local out
out=$("$@" 2>&1 || true)
if printf '%s' "$out" | grep -q -F -- "$needle"; then
pass "$name"
else
fail "$name" "expected output to contain: $needle; got: $(printf '%s' "$out" | head -3)"
fi
}
section() {
printf '\n%s %s\n' "$(gray '##')" "$1"
}

47
snap/tests/run-all.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
# Runs every test-*.sh under snap/tests/ and reports a final summary.
# Intended to be runnable both locally (`bash snap/tests/run-all.sh`)
# and in CI (`.github/workflows/snap-build.yml`). No snapd, snapcraft,
# or sudo required — every test mocks the snap surface.
set -uo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
TESTS=(test-snapcraft-yaml.sh test-cli.sh test-configure.sh test-service-bootstrap.sh)
# Bash-syntax sanity check on every wrapper / hook before running anything.
echo "## bash -n syntax check"
for f in ../local/bin/*.sh ../local/bin/etherpad-cli ../local/bin/etherpad-service \
../local/bin/etherpad-healthcheck-wrapper ../hooks/configure; do
[ -f "$f" ] || continue
if bash -n "$f" 2>/dev/null; then
printf ' \033[32m✓\033[0m %s\n' "$f"
else
printf ' \033[31m✗\033[0m %s\n' "$f"
bash -n "$f"
exit 1
fi
done
TOTAL_PASS=0
TOTAL_FAIL=0
for t in "${TESTS[@]}"; do
echo
echo "## ${t}"
PASS_COUNT=0 FAIL_COUNT=0
# Source so child counters bubble up.
set +u
source "./${t}"
set -u
TOTAL_PASS=$((TOTAL_PASS + PASS_COUNT))
TOTAL_FAIL=$((TOTAL_FAIL + FAIL_COUNT))
done
echo
echo "==========================="
printf ' Passed: %d\n' "${TOTAL_PASS}"
printf ' Failed: %d\n' "${TOTAL_FAIL}"
echo "==========================="
[ "${TOTAL_FAIL}" = 0 ]

53
snap/tests/smoke.sh Executable file
View File

@ -0,0 +1,53 @@
#!/bin/bash
# Local smoke test for the etherpad snap.
# Rebuild → install → set port=9003 → wait → check listener → curl /health → tail logs.
# Run from the worktree root: bash snap/tests/smoke.sh
set -uo pipefail
WORKTREE="/home/jose/etherpad/etherpad-lite/.claude/worktrees/pkg-snap"
SNAP_FILE="${WORKTREE}/etherpad_2.6.1_amd64.snap"
TEST_PORT=9003
BUILD_LOG=/tmp/snapcraft-build.log
cd "${WORKTREE}" || exit 1
echo "==> Rebuilding snap (destructive mode)"
sudo rm -rf parts stage prime
sudo snapcraft pack --destructive-mode --verbose 2>&1 \
| tee "${BUILD_LOG}" \
| grep -E "Building|Staging|Priming|Packing|Created snap|Packed|error|Error|FAIL"
if [ ! -f "${SNAP_FILE}" ]; then
echo "FAIL: ${SNAP_FILE} was not produced — see ${BUILD_LOG}"
exit 1
fi
echo
echo "==> Installing snap"
sudo snap install --dangerous "${SNAP_FILE}"
echo
echo "==> Configuring test port ${TEST_PORT} (production default stays 9001)"
sudo snap set etherpad port="${TEST_PORT}"
sudo snap restart etherpad
echo
echo "==> Waiting 12s for plugin migration + bind"
sleep 12
echo
echo "==> Service status"
sudo snap services etherpad
echo
echo "==> Listening sockets in 9000-9009"
sudo ss -tlnp 2>&1 | grep -E ':900[0-9]' || echo "(nothing listening in 9000-9009)"
echo
echo "==> /health response"
curl -sS -o /tmp/health.body -w 'HTTP %{http_code}\n' "http://127.0.0.1:${TEST_PORT}/health"
echo "body: $(cat /tmp/health.body 2>/dev/null)"
echo
echo "==> Last 20 log lines"
sudo snap logs etherpad -n 25 | tail -20

77
snap/tests/test-cli.sh Executable file
View File

@ -0,0 +1,77 @@
#!/bin/bash
# Unit tests for snap/local/bin/etherpad-cli.
# Exercises path-traversal rejection, extension dispatch, default-case
# rejection, no-args usage, and missing-script rejection — all with a
# mocked $SNAP root so no real install is needed.
set -uo pipefail
TEST_NAME="etherpad-cli"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
TMP=$(mktemp -d)
trap 'rm -rf "${TMP}"' EXIT
# Layout: $SNAP/opt/etherpad/bin/{checkPad.sh,importSqlFile.ts,orphan.txt}
# $SNAP/opt/node/bin/node (mock that just echoes its argv)
mkdir -p "${TMP}/opt/etherpad/bin" "${TMP}/opt/node/bin"
cat > "${TMP}/opt/node/bin/node" <<'EOF'
#!/bin/bash
echo "node-mock argv: $*"
EOF
chmod +x "${TMP}/opt/node/bin/node"
cat > "${TMP}/opt/etherpad/bin/checkPad.sh" <<'EOF'
#!/bin/bash
echo "checkPad.sh args: $*"
EOF
chmod +x "${TMP}/opt/etherpad/bin/checkPad.sh"
touch "${TMP}/opt/etherpad/bin/importSqlFile.ts"
touch "${TMP}/opt/etherpad/bin/orphan.txt"
CLI="${WRAPPERS_DIR}/etherpad-cli"
export SNAP="${TMP}"
section "path-traversal rejection"
# We assert exit code == 2 and that the stderr message matches "invalid"
run_cli() { "${CLI}" "$@"; }
assert_exit 2 "rejects ../../etc/passwd" run_cli "../../etc/passwd"
assert_exit 2 "rejects subdir/script.ts" run_cli "subdir/script.ts"
assert_exit 2 "rejects ..hidden" run_cli "..hidden"
assert_exit 2 "rejects empty argument" run_cli ""
assert_grep "invalid script name" "traversal error message mentions 'invalid'" \
run_cli "../etc/passwd"
section "missing / unsupported scripts"
assert_exit 2 "rejects nonexistent.ts" run_cli "nonexistent.ts"
assert_grep "no such script" "missing-script message" \
run_cli "nonexistent.ts"
assert_exit 2 "rejects orphan.txt (no .ts/.sh)" run_cli "orphan.txt"
assert_grep "unsupported script type" "unsupported-extension message" \
run_cli "orphan.txt"
section "valid dispatch"
# .sh runs the script directly
out=$("${CLI}" "checkPad.sh" hello world 2>&1) || true
assert_eq "$out" "checkPad.sh args: hello world" "checkPad.sh forwards args"
# .ts runs node --import tsx with the script path appended
out=$("${CLI}" "importSqlFile.ts" --some-arg 2>&1) || true
case "$out" in
*"--import tsx/esm"*"importSqlFile.ts"*"--some-arg"*) pass "importSqlFile.ts dispatched via node tsx" ;;
*) fail "importSqlFile.ts dispatched via node tsx" "got: $out" ;;
esac
section "no-args usage"
out=$("${CLI}" 2>&1) || true
case "$out" in
*"Usage: etherpad.cli"*"checkPad.sh"*"importSqlFile.ts"*) pass "no-args prints usage and lists scripts" ;;
*) fail "no-args prints usage and lists scripts" "got: $out" ;;
esac
# Summary handed back to the runner via env counters.
return 0 2>/dev/null || exit 0

62
snap/tests/test-configure.sh Executable file
View File

@ -0,0 +1,62 @@
#!/bin/bash
# Unit tests for snap/hooks/configure.
# Validates port/ip values via a mocked snapctl. Restart-on-change paths
# are not tested here (require running snapd).
set -uo pipefail
TEST_NAME="configure hook"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
TMP=$(mktemp -d)
trap 'rm -rf "${TMP}"' EXIT
# Mock snapctl: returns env-provided values for `get`, no-ops on other verbs.
cat > "${TMP}/snapctl" <<'EOF'
#!/bin/bash
case "$1 $2" in
"get port") printf '%s' "${SNAPCTL_PORT-}" ;;
"get ip") printf '%s' "${SNAPCTL_IP-}" ;;
"services etherpad.etherpad") echo "etherpad.etherpad enabled inactive -" ;;
"restart etherpad.etherpad") exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod +x "${TMP}/snapctl"
HOOK="${HOOKS_DIR}/configure"
run_hook() {
env -i PATH="${TMP}:/usr/bin:/bin" \
SNAPCTL_PORT="${1-}" SNAPCTL_IP="${2-}" \
bash "${HOOK}"
}
section "port validation"
# Tests using the project-reserved test port 9003 (per memory).
assert_exit 0 "valid port 9003" run_hook 9003 ""
assert_exit 0 "valid port 1" run_hook 1 ""
assert_exit 0 "valid port 65535" run_hook 65535 ""
assert_exit 0 "empty port (no override)" run_hook "" ""
assert_exit 1 "rejects port 0" run_hook 0 ""
assert_exit 1 "rejects port 70000" run_hook 70000 ""
assert_exit 1 "rejects port 'abc'" run_hook abc ""
assert_exit 1 "rejects port '-1'" run_hook -1 ""
assert_grep "1-65535" "out-of-range error message references valid range" \
run_hook 99999 ""
section "ip validation"
assert_exit 0 "valid ip 0.0.0.0" run_hook "" "0.0.0.0"
assert_exit 0 "valid ip 127.0.0.1" run_hook "" "127.0.0.1"
assert_exit 0 "valid ip ::1" run_hook "" "::1"
assert_exit 0 "empty ip (no override)" run_hook "" ""
assert_exit 1 "rejects ip 'not-an-ip'" run_hook "" "not-an-ip"
assert_exit 1 "rejects ip 'localhost'" run_hook "" "localhost"
assert_grep "valid IPv4/IPv6" "ip error message mentions IPv4/IPv6" \
run_hook "" "bogus"
return 0 2>/dev/null || exit 0

View File

@ -0,0 +1,138 @@
#!/bin/bash
# Unit tests for snap/local/bin/etherpad-service first-run bootstrap.
# Verifies that the wrapper:
# - seeds settings.json from the upstream template into $SNAP_COMMON/etc
# - rewrites the dirty default to sqlite at $SNAP_COMMON/var/etherpad.db
# - rewrites ip/port literals to ${IP:…} / ${PORT:…} env-substitution
# - leaves an existing settings.json untouched on subsequent runs
# - exports PORT/IP from snapctl overrides
#
# We mock node, snapctl, and the SNAP/SNAP_COMMON dirs. node-mock writes
# its argv + selected env vars to a log file, then exits 0 instead of
# actually starting Etherpad.
set -uo pipefail
TEST_NAME="etherpad-service bootstrap"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
REPO_ROOT="$(cd "${SNAP_DIR}/.." && pwd -P)"
TEMPLATE="${REPO_ROOT}/settings.json.template"
[ -f "${TEMPLATE}" ] || { fail "fixture: settings.json.template not found at ${TEMPLATE}"; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "${TMP}"' EXIT
export SNAP="${TMP}/snap"
export SNAP_COMMON="${TMP}/common"
mkdir -p "${SNAP}/opt/etherpad/src" "${SNAP}/opt/node/bin" \
"${TMP}/bin"
cp "${TEMPLATE}" "${SNAP}/opt/etherpad/settings.json.template"
# node mock: log argv + env vars we care about, exit 0 (do not exec server)
NODE_LOG="${TMP}/node-invocation.log"
cat > "${SNAP}/opt/node/bin/node" <<EOF
#!/bin/bash
{
echo "ARGV: \$*"
echo "PORT=\${PORT-unset}"
echo "IP=\${IP-unset}"
echo "NODE_ENV=\${NODE_ENV-unset}"
} > "${NODE_LOG}"
exit 0
EOF
chmod +x "${SNAP}/opt/node/bin/node"
# snapctl mock — controlled via SNAPCTL_PORT / SNAPCTL_IP env vars set per call.
cat > "${TMP}/bin/snapctl" <<'EOF'
#!/bin/bash
case "$1 $2" in
"get port") printf '%s' "${SNAPCTL_PORT-}" ;;
"get ip") printf '%s' "${SNAPCTL_IP-}" ;;
*) exit 0 ;;
esac
EOF
chmod +x "${TMP}/bin/snapctl"
export PATH="${TMP}/bin:${PATH}"
WRAPPER="${WRAPPERS_DIR}/etherpad-service"
run_wrapper() {
# Use env -i would strip too much; instead just clear the override vars.
unset PORT IP NODE_ENV
SNAPCTL_PORT="${1-}" SNAPCTL_IP="${2-}" bash "${WRAPPER}"
}
section "first-run seed and rewrite"
run_wrapper "" ""
SEEDED="${SNAP_COMMON}/etc/settings.json"
[ -f "${SEEDED}" ] && pass "settings.json seeded into \$SNAP_COMMON/etc" \
|| fail "settings.json seeded into \$SNAP_COMMON/etc" "missing: ${SEEDED}"
# dbType: dirty -> sqlite
assert_grep '"dbType": "sqlite"' "dbType rewritten to sqlite" \
cat "${SEEDED}"
# filename: var/dirty.db -> $SNAP_COMMON/var/etherpad.db
assert_grep "${SNAP_COMMON}/var/etherpad.db" "sqlite filename points at \$SNAP_COMMON/var" \
cat "${SEEDED}"
# ip / port: literal values -> env-substitution syntax
assert_grep '"ip": "${IP:0.0.0.0}"' "ip rewritten to \${IP:0.0.0.0}" \
cat "${SEEDED}"
assert_grep '"port": "${PORT:9001}"' "port rewritten to \${PORT:9001}" \
cat "${SEEDED}"
# dbSettings.port lower in the file MUST NOT have been touched (the sed
# uses 0,/.../ to bound to the first match).
dbsettings_port=$(grep -c '^[[:space:]]*"port":[[:space:]]*3306' "${SEEDED}" || true)
assert_eq "${dbsettings_port}" "1" "dbSettings.port (3306) untouched by ip/port rewrite"
section "writable directories created"
for d in etc var logs etherpad-app-var; do
if [ -d "${SNAP_COMMON}/${d}" ]; then
pass "\$SNAP_COMMON/${d} created"
else
fail "\$SNAP_COMMON/${d} created" "missing: ${SNAP_COMMON}/${d}"
fi
done
section "snapctl overrides propagate to node env"
# Re-run with port=9003 (project test port), ip=127.0.0.1.
run_wrapper 9003 127.0.0.1
assert_grep "PORT=9003" "PORT exported from snapctl override" \
cat "${NODE_LOG}"
assert_grep "IP=127.0.0.1" "IP exported from snapctl override" \
cat "${NODE_LOG}"
assert_grep "NODE_ENV=production" "NODE_ENV=production exported" \
cat "${NODE_LOG}"
assert_grep -- "--settings ${SEEDED}" "wrapper passes --settings explicitly" \
cat "${NODE_LOG}"
section "second run does not re-seed"
# Mark the seeded settings.json so we can detect rewrites.
echo '/* TEST MARKER */' >> "${SEEDED}"
run_wrapper "" ""
if grep -q "TEST MARKER" "${SEEDED}"; then
pass "existing settings.json preserved on subsequent run"
else
fail "existing settings.json preserved on subsequent run" \
"marker was removed — wrapper re-seeded the file"
fi
section "snapctl defaults when unset"
# Remove the seeded file and run with no overrides.
rm -f "${SEEDED}"
run_wrapper "" ""
assert_grep "PORT=9001" "PORT defaults to 9001 when snapctl is empty" \
cat "${NODE_LOG}"
assert_grep "IP=0.0.0.0" "IP defaults to 0.0.0.0 when snapctl is empty" \
cat "${NODE_LOG}"
return 0 2>/dev/null || exit 0

View File

@ -0,0 +1,80 @@
#!/bin/bash
# Static checks on snap/snapcraft.yaml — purely structural, no snapcraft
# binary required. Catches schema drift before a slow `snapcraft pack`.
set -uo pipefail
TEST_NAME="snapcraft.yaml"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
YAML="${SNAP_DIR}/snapcraft.yaml"
[ -f "${YAML}" ] || { fail "snapcraft.yaml exists at ${YAML}"; exit 1; }
# Use python3 for parsing — no extra deps.
python3 - "${YAML}" <<'PY' || exit 1
import sys, re, yaml
path = sys.argv[1]
with open(path) as f:
data = yaml.safe_load(f)
failures = []
def check(cond, msg):
print(f" {'✓' if cond else '✗'} {msg}")
if not cond: failures.append(msg)
# Required top-level keys.
for k in ("name","title","summary","description","base","confinement","apps","parts"):
check(k in data, f"required key present: {k}")
# Name must be lowercase, no underscores, valid for snap store.
name = data.get("name","")
check(bool(re.match(r"^[a-z][a-z0-9-]{0,39}$", name)) and not name.endswith("-"),
f"snap name '{name}' is store-valid")
check(name == "etherpad", "snap name is exactly 'etherpad' (no etherpad-lite)")
# core24 + strict.
check(data.get("base") == "core24", "base is core24")
check(data.get("confinement") == "strict", "confinement is strict")
# adopt-info points at a real part.
ai = data.get("adopt-info")
parts = data.get("parts", {})
check(ai in parts, f"adopt-info '{ai}' references an existing part")
# Daemon app shares the snap name → bare invocation works.
apps = data.get("apps", {})
daemon_apps = [n for n,v in apps.items() if "daemon" in v]
check(name in daemon_apps,
f"daemon app shares snap name (so `{name}` is the bare command)")
# Apps don't reintroduce the legacy 'etherpad-lite' name anywhere.
flat = yaml.dump(data)
check("etherpad-lite" not in flat,
"no 'etherpad-lite' references in snapcraft.yaml")
# settings.json env vars should NOT include EP_SETTINGS / EP_DATA_DIR
# (Etherpad doesn't read them; they trigger noisy warnings).
env = apps.get(name, {}).get("environment", {}) or {}
for forbidden in ("EP_SETTINGS","EP_DATA_DIR"):
check(forbidden not in env,
f"environment does not set unused var {forbidden}")
# PORT and IP env vars must be present so settings.json env-subst has defaults.
for required in ("PORT","IP","NODE_ENV"):
check(required in env,
f"environment sets {required}")
print()
print(f"{len(failures)} failure(s)" if failures else "OK")
sys.exit(1 if failures else 0)
PY
yaml_rc=$?
if [ "$yaml_rc" = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
pass "snapcraft.yaml structural checks all pass"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
fail "snapcraft.yaml structural checks failed (see python output above)"
fi
return 0 2>/dev/null || exit 0