The integration cascade stored its top layer differently from the two below it: org (L2) and user (L3) plugin config lived in PocketBase, in a pluginSettings field, while the global (L1) layer sat in a plugins.json next to the binary. That split was accretion rather than design - the file was the whole store in the v1 MVP, and the per-tenant layers were later built on PocketBase and layered on top of it instead of replacing it. It also cost something real. plugins.json was a second state store with different durability from pb_data: its own volume, its own ownership, its own backup. Losing pb_data is unmissable; losing api_data was silent, which is how "every plugin comes back disabled after a redeploy" happened. L1 now lives in the app_settings collection - one record keyed "global", holding its settings in a pluginSettings field, the same mechanism and the same field name the layers below use. The documents still differ in shape, because only L1 carries enable state and the registration of external plugins, but the storage is no longer a special case. The Manager grows a Store seam (PocketBase in production, file for the import, memory for tests) and, more importantly, a loaded gate. Settings in a database mean the store can be unreachable at boot - a cold stack, or a service account still to be set from the panel. That must not read as "no plugins configured", or the first save would write emptiness over real settings. So until a read succeeds the Manager stays unloaded, every mutation is refused, /api/admin/plugins* answers 503, and a background retry backs off to two minutes. The same gate covers a document that will not parse: it is never replaced by one built from an empty map, which is a stronger guarantee than the .corrupt backup it replaces. Writing to a store also revealed a hole in the previous fix. Classifying a save failure as errPersist was left to each Store, and a store that returned a plain error would fall through to the "saved, but the plugin failed to start" branch and be reported as a 200 - the same silent-success bug through a different door. The Manager now classifies, whatever the Store returns; a test pins it. Upgrades are automatic: on the first boot that finds no settings in the database, an existing plugins.json is imported and renamed to plugins.json.migrated. The import is refused if the store is merely unreachable, or if the file does not parse, so a stale or broken file can never overwrite live settings. /data is still needed - the panel rewrites .env there when it retargets PocketBase - but plugin settings no longer depend on it. 21 tests in internal/plugins cover both stores, including the production path against a fake PocketBase: create-then-update of the singleton, round-trip across a restart, an outage that leaves settings intact, a missing collection reading as not-ready rather than empty, and the import running exactly once. go build, go vet and go test ./... pass. Schema changes are mirrored into scripts/setup-pocketbase.mjs as that file requires. Not verified: no Docker CLI here, so no image was built and the bootstrap of app_settings against a real PocketBase is untested outside the fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
9.5 KiB
Docker
249 lines
9.5 KiB
Docker
# syntax=docker/dockerfile:1
|
|
#
|
|
# All-in-one image: PocketBase + API Server + Web App in a single container.
|
|
#
|
|
# The build context MUST be the project root so this file can reach both
|
|
# "API Server/" and "Web App/". The root .dockerignore is an allow-list of the
|
|
# paths copied below — add to it if you add a COPY here. Build it with:
|
|
#
|
|
# docker build -f "Docker-AIO/Dockerfile" -t drivervault-aio .
|
|
#
|
|
# Run it (all three services start together):
|
|
#
|
|
# docker run -d --name drivervault -p 80:80 -p 8070:8070 \
|
|
# -e PB_ADMIN_EMAIL=admin@example.com \
|
|
# -e PB_ADMIN_PASSWORD=change-me \
|
|
# -e DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com \
|
|
# -e DRIVERVAULT_SUPERADMIN_PASSWORD=change-me \
|
|
# -v drivervault_pb:/pb/pb_data \
|
|
# -v drivervault_api:/data \
|
|
# drivervault-aio
|
|
#
|
|
# Then: web app on http://host/ and PocketBase admin on http://host:8070/_/
|
|
# On first boot the API Server creates the collections and the super-admin.
|
|
|
|
# --- Stage 1: build the Go API Server ---------------------------------------
|
|
FROM golang:1.26-alpine3.24 AS api-build
|
|
WORKDIR /src
|
|
COPY ["API Server/go.mod", "./"]
|
|
COPY ["API Server/go.su[m]", "./"]
|
|
RUN go mod download
|
|
# Only cmd/ + internal are needed; the panel is already built into
|
|
# internal/api/dist and embedded via //go:embed. Entry point is cmd/server.
|
|
COPY ["API Server/cmd", "./cmd"]
|
|
COPY ["API Server/internal", "./internal"]
|
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server
|
|
|
|
# --- Stage 2: build the Vue Web App -----------------------------------------
|
|
# The Vue source lives under "Web App/web/".
|
|
FROM node:22-alpine3.24 AS web-build
|
|
WORKDIR /app
|
|
COPY ["Web App/web/package.json", "Web App/web/package-lock.json", "./"]
|
|
RUN npm ci
|
|
COPY ["Web App/web/index.html", "Web App/web/vite.config.js", "./"]
|
|
COPY ["Web App/web/src", "./src"]
|
|
COPY ["Web App/web/public", "./public"]
|
|
# Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx.
|
|
ARG VITE_API_BASE
|
|
# vite.config writes to ../server/dist by default; emit into ./dist here.
|
|
RUN npm run build -- --outDir dist --emptyOutDir
|
|
|
|
# --- Stage 3: runtime (all services) ----------------------------------------
|
|
FROM alpine:3.24
|
|
|
|
# Pinned so a rebuild months from now produces the same PocketBase. Override to
|
|
# upgrade (--build-arg PB_VERSION=0.40.0); set it to empty to resolve the latest
|
|
# release at build time, which needs an unauthenticated GitHub API call and is
|
|
# therefore subject to that GitHub rate limit (60/hour per IP).
|
|
ARG PB_VERSION="0.39.11"
|
|
# Provided automatically by BuildKit (amd64 / arm64).
|
|
ARG TARGETARCH="amd64"
|
|
|
|
RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \
|
|
&& mkdir -p /run/nginx
|
|
|
|
# Unprivileged account for PocketBase and the API Server. Only nginx stays root,
|
|
# because it binds port 80; supervisord drops to this user for the other two.
|
|
RUN addgroup -S app && adduser -S -G app app
|
|
|
|
# PocketBase from the official release (pinned via PB_VERSION, else latest).
|
|
WORKDIR /pb
|
|
RUN set -eux; \
|
|
ver="${PB_VERSION}"; \
|
|
if [ -z "$ver" ]; then \
|
|
ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
|
|
| grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \
|
|
fi; \
|
|
echo "Installing PocketBase v${ver} (${TARGETARCH})"; \
|
|
wget -q -O /tmp/pb.zip \
|
|
"https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \
|
|
unzip /tmp/pb.zip -d /pb; \
|
|
rm /tmp/pb.zip
|
|
|
|
# API Server binary + built Web App static assets.
|
|
COPY --from=api-build /out/api-server /usr/local/bin/api-server
|
|
COPY --from=web-build /app/dist /usr/share/nginx/html
|
|
|
|
# WebSocket handshakes need Connection/Upgrade forwarded, and the map deriving
|
|
# them must sit in the http context, not inside a server block. It goes in
|
|
# http.d/ (which Alpine nginx includes from http{}; it does not read conf.d/),
|
|
# and the 00- prefix keeps it ahead of default.conf in the include order.
|
|
RUN cat > /etc/nginx/http.d/00-upgrade.conf <<'NGINXMAP'
|
|
map $http_upgrade $connection_upgrade {
|
|
default upgrade;
|
|
'' close;
|
|
}
|
|
NGINXMAP
|
|
|
|
# nginx: serve the SPA and proxy /api/ to the API Server on localhost.
|
|
RUN cat > /etc/nginx/http.d/default.conf <<'NGINX'
|
|
server {
|
|
listen 80;
|
|
server_name _;
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
gzip on;
|
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
|
gzip_min_length 1024;
|
|
|
|
# A real liveness route, so the SPA fallback below cannot answer a health
|
|
# probe with index.html and make a broken container look healthy.
|
|
location = /healthz {
|
|
access_log off;
|
|
add_header Content-Type text/plain;
|
|
return 200 "ok";
|
|
}
|
|
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:8080;
|
|
proxy_http_version 1.1;
|
|
# Forward WebSocket upgrades instead of silently stripping them.
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection $connection_upgrade;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
|
|
location /assets/ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
try_files $uri =404;
|
|
}
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
NGINX
|
|
|
|
# supervisord runs the three processes and keeps them alive.
|
|
RUN cat > /etc/supervisord.conf <<'SUPERVISOR'
|
|
[supervisord]
|
|
nodaemon=true
|
|
user=root
|
|
pidfile=/run/supervisord.pid
|
|
logfile=/dev/null
|
|
logfile_maxbytes=0
|
|
|
|
; PocketBase: upsert the superuser (idempotent) then serve. Runs as the
|
|
; unprivileged app user, which owns /pb and the pb_data volume.
|
|
[program:pocketbase]
|
|
directory=/pb
|
|
user=app
|
|
command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
|
|
priority=10
|
|
autostart=true
|
|
autorestart=true
|
|
stdout_logfile=/dev/stdout
|
|
stdout_logfile_maxbytes=0
|
|
stderr_logfile=/dev/stderr
|
|
stderr_logfile_maxbytes=0
|
|
|
|
; API Server: wait for PocketBase to be healthy, then start. It runs from /data
|
|
; because it writes the panel .env relative to its working directory, and /data
|
|
; is the volume that keeps it across container recreates. (Plugin settings live
|
|
; in PocketBase, under /pb/pb_data.)
|
|
[program:api-server]
|
|
directory=/data
|
|
user=app
|
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
|
|
priority=20
|
|
autostart=true
|
|
autorestart=true
|
|
stdout_logfile=/dev/stdout
|
|
stdout_logfile_maxbytes=0
|
|
stderr_logfile=/dev/stderr
|
|
stderr_logfile_maxbytes=0
|
|
|
|
; nginx stays root so it can bind :80; its own workers drop to the nginx user.
|
|
[program:nginx]
|
|
command=/usr/sbin/nginx -g 'daemon off;'
|
|
priority=30
|
|
autostart=true
|
|
autorestart=true
|
|
stdout_logfile=/dev/stdout
|
|
stdout_logfile_maxbytes=0
|
|
stderr_logfile=/dev/stderr
|
|
stderr_logfile_maxbytes=0
|
|
SUPERVISOR
|
|
|
|
# The entrypoint stays root only long enough to make the two volumes writable by
|
|
# the app user, then hands off to supervisord. The chown matters for volumes
|
|
# created by an earlier build of this image, when both services ran as root.
|
|
RUN cat > /entrypoint.sh <<'ENTRY'
|
|
#!/bin/sh
|
|
set -e
|
|
for dir in /pb/pb_data /data; do
|
|
mkdir -p "$dir"
|
|
if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then
|
|
echo "entrypoint: taking ownership of $dir"
|
|
chown -R app:app "$dir"
|
|
fi
|
|
done
|
|
exec supervisord -c /etc/supervisord.conf
|
|
ENTRY
|
|
RUN chmod +x /entrypoint.sh
|
|
|
|
# API Server config: everything is local to this container. WEBAPP_URL is what
|
|
# the panel status page probes; nginx serves the Web App on :80 in here, so the
|
|
# stock default of localhost:8090 would always report the Web App as down.
|
|
ENV API_ADDR=:8080 \
|
|
POCKETBASE_URL=http://127.0.0.1:8070 \
|
|
CORS_ALLOW_ORIGINS=http://localhost:8090 \
|
|
AUTH_USERS_COLLECTION=users \
|
|
PLUGINS_FILE=/data/plugins.json \
|
|
WEBAPP_URL=http://127.0.0.1:80
|
|
|
|
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.
|
|
# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create
|
|
# the first app super-admin on boot; PB_BOOTSTRAP=false skips schema setup.
|
|
# For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects
|
|
# chargers that did not arrive over TLS — this image serves plain HTTP, so put a
|
|
# TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss://
|
|
# base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
|
# Pass them with `docker run -e ...`.
|
|
|
|
# pb_data holds the database — including the plugin settings; /data holds the
|
|
# .env the panel rewrites when a superadmin retargets PocketBase. Both are
|
|
# pre-created and owned by app so a fresh named volume inherits that ownership.
|
|
RUN mkdir -p /pb/pb_data /data && chown -R app:app /pb /data
|
|
VOLUME /pb/pb_data
|
|
VOLUME /data
|
|
# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel
|
|
# (also the /ocpp/{serial} endpoint chargers dial into).
|
|
EXPOSE 80 8070 8080
|
|
|
|
# All three processes must answer, so a wedged component shows up in `docker ps`
|
|
# instead of a container that looks up while half of it is dead. start-period
|
|
# covers the first-boot schema bootstrap on a cold database.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
|
CMD wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1 \
|
|
&& wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 \
|
|
&& wget -qO- http://127.0.0.1:80/healthz >/dev/null 2>&1 \
|
|
|| exit 1
|
|
|
|
ENTRYPOINT ["/entrypoint.sh"]
|