From 02e37e01128bd6f9dcbb83741b50bc0bb0a867bc Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 2 May 2026 20:19:10 +0800 Subject: [PATCH] feat(packaging): publish Etherpad as a Snap (#7558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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=` and `ip=` 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) * 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 /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) * 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) * 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 `` 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/snap-build.yml | 71 ++++++ .github/workflows/snap-publish.yml | 92 +++++++ .gitignore | 8 +- snap/README.md | 266 ++++++++++++++++++++ snap/hooks/configure | 24 ++ snap/local/bin/etherpad-cli | 35 +++ snap/local/bin/etherpad-healthcheck-wrapper | 20 ++ snap/local/bin/etherpad-service | 71 ++++++ snap/snapcraft.yaml | 161 ++++++++++++ snap/tests/lib.sh | 73 ++++++ snap/tests/run-all.sh | 47 ++++ snap/tests/smoke.sh | 53 ++++ snap/tests/test-cli.sh | 77 ++++++ snap/tests/test-configure.sh | 62 +++++ snap/tests/test-service-bootstrap.sh | 138 ++++++++++ snap/tests/test-snapcraft-yaml.sh | 80 ++++++ 16 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/snap-build.yml create mode 100644 .github/workflows/snap-publish.yml create mode 100644 snap/README.md create mode 100755 snap/hooks/configure create mode 100755 snap/local/bin/etherpad-cli create mode 100755 snap/local/bin/etherpad-healthcheck-wrapper create mode 100755 snap/local/bin/etherpad-service create mode 100644 snap/snapcraft.yaml create mode 100755 snap/tests/lib.sh create mode 100755 snap/tests/run-all.sh create mode 100755 snap/tests/smoke.sh create mode 100755 snap/tests/test-cli.sh create mode 100755 snap/tests/test-configure.sh create mode 100755 snap/tests/test-service-bootstrap.sh create mode 100755 snap/tests/test-snapcraft-yaml.sh diff --git a/.github/workflows/snap-build.yml b/.github/workflows/snap-build.yml new file mode 100644 index 000000000..ba648cb2c --- /dev/null +++ b/.github/workflows/snap-build.yml @@ -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 diff --git a/.github/workflows/snap-publish.yml b/.github/workflows/snap-publish.yml new file mode 100644 index 000000000..c040e53a5 --- /dev/null +++ b/.github/workflows/snap-publish.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3f666cd54..098074ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/snap/README.md b/snap/README.md new file mode 100644 index 000000000..ca8a0ca32 --- /dev/null +++ b/snap/README.md @@ -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 1–65535 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__.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 (1–65535 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. diff --git a/snap/hooks/configure b/snap/hooks/configure new file mode 100755 index 000000000..e41ee0472 --- /dev/null +++ b/snap/hooks/configure @@ -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 diff --git a/snap/local/bin/etherpad-cli b/snap/local/bin/etherpad-cli new file mode 100755 index 000000000..4686f691c --- /dev/null +++ b/snap/local/bin/etherpad-cli @@ -0,0 +1,35 @@ +#!/bin/bash +# Thin passthrough to Etherpad's bin/ scripts. +# Usage: etherpad.cli [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 [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 diff --git a/snap/local/bin/etherpad-healthcheck-wrapper b/snap/local/bin/etherpad-healthcheck-wrapper new file mode 100755 index 000000000..fa9db7c84 --- /dev/null +++ b/snap/local/bin/etherpad-healthcheck-wrapper @@ -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); }); +' diff --git a/snap/local/bin/etherpad-service b/snap/local/bin/etherpad-service new file mode 100755 index 000000000..140014f97 --- /dev/null +++ b/snap/local/bin/etherpad-service @@ -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=` / `ip=` 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}" \ + "$@" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 000000000..9b8cf4e31 --- /dev/null +++ b/snap/snapcraft.yaml @@ -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 diff --git a/snap/tests/lib.sh b/snap/tests/lib.sh new file mode 100755 index 000000000..af2336f34 --- /dev/null +++ b/snap/tests/lib.sh @@ -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" +} diff --git a/snap/tests/run-all.sh b/snap/tests/run-all.sh new file mode 100755 index 000000000..14b2d8477 --- /dev/null +++ b/snap/tests/run-all.sh @@ -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 ] diff --git a/snap/tests/smoke.sh b/snap/tests/smoke.sh new file mode 100755 index 000000000..72ef14425 --- /dev/null +++ b/snap/tests/smoke.sh @@ -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 diff --git a/snap/tests/test-cli.sh b/snap/tests/test-cli.sh new file mode 100755 index 000000000..68d9f54c3 --- /dev/null +++ b/snap/tests/test-cli.sh @@ -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 diff --git a/snap/tests/test-configure.sh b/snap/tests/test-configure.sh new file mode 100755 index 000000000..5273fb7e9 --- /dev/null +++ b/snap/tests/test-configure.sh @@ -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 diff --git a/snap/tests/test-service-bootstrap.sh b/snap/tests/test-service-bootstrap.sh new file mode 100755 index 000000000..3060751ce --- /dev/null +++ b/snap/tests/test-service-bootstrap.sh @@ -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" < "${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 diff --git a/snap/tests/test-snapcraft-yaml.sh b/snap/tests/test-snapcraft-yaml.sh new file mode 100755 index 000000000..9f64c4eaa --- /dev/null +++ b/snap/tests/test-snapcraft-yaml.sh @@ -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