Plugins: a save that fails should say so, not vanish on redeploy

Reported symptom: every plugin comes back disabled after redeploying the
image, having been enabled before it. The persistence design was already
right - each compose file mounts api_data:/data and points PLUGINS_FILE at
/data/plugins.json - so the fault was that a failed write to that file was
invisible. Three defects, each confirmed with a test before being fixed:

A failed write was reported as success. Upsert set rec.Enabled before it
persisted, and the handler folded the resulting error into the same
200-with-warning used for "saved, but the connector failed to start". The
panel reloaded, read the in-memory record and showed the plugin enabled;
only a restart revealed that nothing had reached the disk. A save that
fails now rolls back in memory and returns 500, so the panel row shows the
error instead of "Saved".

A corrupt state file silently wiped the rest. Load returned an error,
main.go logged it and carried on with an empty record set, so the next
toggle overwrote plugins.json and took every other plugin's config with
it. An unreadable file is now moved aside to plugins.json.corrupt, and
persistLocked writes through a temp file + rename so an interrupted write
cannot produce that corrupt file in the first place.

A state file holding "null" panicked the server with "assignment to entry
in nil map" on the next save, and a null entry nil-dereferenced in Load.
Both now decode to "nothing configured".

Two changes make the next such failure loud rather than silent.
StartPlugins probes writability at boot and warns that plugin changes will
not survive a restart. And the API Server image gains the root entrypoint
the AIO image already had - chown /data, then drop to app via su-exec -
because a host bind mount (API_DATA=/srv/...) or a volume created before
/data existed arrives root-owned, and the unprivileged process cannot
write to it.

Not addressed here: a deployment that never reuses the named volume
(docker compose down -v, a renamed compose project, an anonymous volume
from a bare docker run) loses the file whatever the code does. The new
boot warning tells the two apart - writable but empty means the volume is
the problem, not permissions.

go build, go vet and go test ./... all pass. The Dockerfile change is
reviewed but not built: there is no Docker CLI on this machine, so the
su-exec privilege drop follows standard Alpine practice rather than an
observed run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-21 16:17:47 +02:00
co-authored by Claude Opus 5
parent 9abb03ee4f
commit c173ca3653
5 changed files with 400 additions and 24 deletions
+29 -5
View File
@@ -23,7 +23,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-ser
FROM alpine:3.24
# HTTPS calls to PocketBase need CA certificates; tzdata for correct timestamps.
RUN apk add --no-cache ca-certificates tzdata
# su-exec lets the entrypoint fix /data ownership as root and then drop to app.
RUN apk add --no-cache ca-certificates tzdata su-exec
# Run as an unprivileged user.
RUN addgroup -S app && adduser -S -G app app
@@ -34,11 +35,34 @@ COPY --from=build /out/api-server /usr/local/bin/api-server
# (plugin enable-state + config) and .env, which the panel rewrites when a
# superadmin retargets the PocketBase connection. Both must therefore live on a
# writable, persistent path — hence /data, owned by the unprivileged user and
# declared as a volume. A named volume mounted here inherits this ownership.
# declared as a volume. A fresh named volume inherits this ownership.
RUN mkdir -p /data && chown app:app /data
WORKDIR /data
VOLUME /data
# A fresh named volume inherits /data's ownership, but two common cases do not:
# a host bind mount (API_DATA=/srv/... in docker-compose.prod.yml) arrives owned
# by root, and so does a volume created by an image from before /data existed,
# when the server ran with a root-owned working directory. In both cases the
# unprivileged process cannot write plugins.json — which shows up as plugins
# that enable fine in the panel and come back disabled after the next redeploy.
# So the entrypoint starts as root purely to fix ownership, then drops to app.
RUN cat > /entrypoint.sh <<'ENTRY'
#!/bin/sh
set -e
if [ "$(id -u)" = "0" ]; then
mkdir -p /data
if [ "$(stat -c %U /data 2>/dev/null)" != "app" ]; then
echo "entrypoint: taking ownership of /data"
chown -R app:app /data
fi
exec su-exec app "$@"
fi
# Already unprivileged (docker run --user ...): nothing to drop, just run.
exec "$@"
ENTRY
RUN chmod +x /entrypoint.sh
# Config comes entirely from environment variables (see .env.example).
# POCKETBASE_ADMIN_EMAIL / _PASSWORD are optional at startup: without them the
# server still runs and a superadmin can configure the connection from the panel.
@@ -46,12 +70,12 @@ ENV API_ADDR=:8080 \
PLUGINS_FILE=/data/plugins.json
EXPOSE 8080
USER app
# Liveness only: /healthz answers 200 as soon as the process is serving, and
# does not depend on PocketBase, so a database outage does not mark the
# container unhealthy. Lets compose gate dependants on condition: service_healthy.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1
ENTRYPOINT ["/usr/local/bin/api-server"]
# The entrypoint drops to the unprivileged app user after fixing /data.
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/local/bin/api-server"]