diff --git a/.github/workflows/snap-publish.yml b/.github/workflows/snap-publish.yml new file mode 100644 index 00000000000..50d56d06dc5 --- /dev/null +++ b/.github/workflows/snap-publish.yml @@ -0,0 +1,87 @@ +# Builds and publishes the Etherpad snap on tagged releases. +# Mirrors the trigger pattern from .github/workflows/docker.yml / release.yml +# (tags matching v?X.Y.Z). +# +# One-time maintainer setup: +# 1. `snapcraft register etherpad-lite` claims the name. +# 2. Generate a store credential: +# snapcraft export-login --snaps etherpad-lite \ +# --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://snapcraft.io/docs/releasing-to-the-snap-store +name: Snap +on: + push: + tags: + - 'v?[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-lite-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-lite-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-lite-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/snap/README.md b/snap/README.md new file mode 100644 index 00000000000..7f6a3f7530a --- /dev/null +++ b/snap/README.md @@ -0,0 +1,61 @@ +# Etherpad snap + +Packages Etherpad as a [Snap](https://snapcraft.io/) for publishing to the +Snap Store. + +## 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-lite__.snap`. + +## Install the local build + +``` +sudo snap install --dangerous ./etherpad-lite_*.snap +sudo snap start etherpad-lite +curl http://127.0.0.1:9001/health +``` + +Logs: `sudo snap logs etherpad-lite -f`. + +## Configure + +The snap seeds `$SNAP_COMMON/etc/settings.json` from the upstream +template on first run. Edit that file to customise Etherpad, then: + +``` +sudo snap restart etherpad-lite +``` + +A few values are exposed as snap config for convenience: + +| Key | Default | Notes | +| ----------------------------------- | --------- | --------------- | +| `snap set etherpad-lite port=9001` | `9001` | Listen port | +| `snap set etherpad-lite ip=0.0.0.0` | `0.0.0.0` | Bind address | + +Pad data (dirty DB, logs) lives in `/var/snap/etherpad-lite/common/` and +survives `snap refresh`. + +## Publish to the Snap Store + +Maintainers only. See +[Releasing to the Snap Store](https://snapcraft.io/docs/releasing-to-the-snap-store). + +One-time setup: + +``` +snapcraft register etherpad-lite +snapcraft export-login --snaps etherpad-lite \ + --channels edge,stable \ + --acls package_access,package_push,package_release - +``` + +Store the printed credential in the repo secret +`SNAPCRAFT_STORE_CREDENTIALS`. CI (`.github/workflows/snap-publish.yml`) +handles the rest on every `v*` tag. diff --git a/snap/hooks/configure b/snap/hooks/configure new file mode 100755 index 00000000000..1bdf54e1594 --- /dev/null +++ b/snap/hooks/configure @@ -0,0 +1,24 @@ +#!/bin/bash +# Validates values set via `snap set etherpad-lite 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-lite.etherpad-lite 2>/dev/null | grep -q active; then + snapctl restart etherpad-lite.etherpad-lite +fi diff --git a/snap/local/bin/etherpad-cli b/snap/local/bin/etherpad-cli new file mode 100755 index 00000000000..3a08c7ecda1 --- /dev/null +++ b/snap/local/bin/etherpad-cli @@ -0,0 +1,24 @@ +#!/bin/bash +# Thin passthrough to Etherpad's bin/ scripts. +# Usage: etherpad-lite.etherpad [args...] +set -euo pipefail + +APP_DIR="${SNAP}/opt/etherpad-lite" +NODE_BIN="${SNAP}/opt/node/bin/node" +export PATH="${SNAP}/opt/node/bin:${PATH}" + +if [ "$#" -eq 0 ]; then + echo "Usage: etherpad-lite.etherpad [args...]" + echo "Available scripts:" + ls "${APP_DIR}/bin" | grep -E '\.(ts|sh)$' | sed 's/^/ /' + exit 2 +fi + +SCRIPT_NAME="$1"; shift +SCRIPT_PATH="${APP_DIR}/bin/${SCRIPT_NAME}" +[ -f "${SCRIPT_PATH}" ] || { echo "no such script: ${SCRIPT_NAME}"; exit 2; } + +case "${SCRIPT_PATH}" in + *.sh) exec "${SCRIPT_PATH}" "$@" ;; + *.ts) exec "${NODE_BIN}" --import tsx/esm "${SCRIPT_PATH}" "$@" ;; +esac diff --git a/snap/local/bin/etherpad-healthcheck-wrapper b/snap/local/bin/etherpad-healthcheck-wrapper new file mode 100755 index 00000000000..fa9db7c8466 --- /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 00000000000..1df548dace6 --- /dev/null +++ b/snap/local/bin/etherpad-service @@ -0,0 +1,58 @@ +#!/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 dirty-DB / 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-lite" +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" +mkdir -p "${ETC_DIR}" "${VAR_DIR}" "${LOG_DIR}" + +SETTINGS="${ETC_DIR}/settings.json" +if [ ! -f "${SETTINGS}" ]; then + echo "[etherpad-snap] bootstrapping ${SETTINGS} from template" + cp "${APP_DIR}/settings.json.template" "${SETTINGS}" + # Point the default dirty-DB at $SNAP_COMMON (absolute path, writable). + sed -i \ + -e 's|"filename": "var/dirty.db"|"filename": "'"${VAR_DIR}"'/dirty.db"|' \ + "${SETTINGS}" + # Rewrite ip/port literals to Etherpad's env-substitution syntax so + # `snap set etherpad-lite 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}" + +cd "${APP_DIR}" +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. +exec "${NODE_BIN}" --import tsx/esm src/node/server.ts \ + --settings "${SETTINGS}" \ + "$@" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 00000000000..6455441de1e --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,139 @@ +# 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-lite +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-lite +license: Apache-2.0 +website: https://etherpad.org +source-code: https://github.com/ether/etherpad-lite +issues: https://github.com/ether/etherpad-lite/issues +contact: https://etherpad.org/#community + +adopt-info: etherpad +grade: stable +confinement: strict +base: core24 +compression: lzo + +platforms: + amd64: + arm64: + +apps: + etherpad-lite: + command: bin/etherpad-service + daemon: simple + install-mode: enable + restart-condition: on-failure + plugs: + - network + - network-bind + environment: + HOME: $SNAP_DATA + NODE_ENV: production + EP_SETTINGS: $SNAP_COMMON/etc/settings.json + EP_DATA_DIR: $SNAP_COMMON/var + PORT: "9001" + + healthcheck: + command: bin/etherpad-healthcheck-wrapper + plugs: + - network + + etherpad: + 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 (bundled with Node 22). + 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-lite" + 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 || true + + rm -rf .git tests/frontend-new/.cache \ + src/tests/frontend-new/test-results || true + + # -- 4. 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