Plugins: drop the plugins.json migration, and the volume it needed
The project has no public installs, so there is nothing to migrate from. MigrateLegacyFile, the file-backed Store it read through, PLUGINS_FILE and the legacy path threaded through the Server all go. What is left is one store, PocketBase, and a plugins package that touches no filesystem at all. That was the last thing keeping api_data alive, so the volume goes too. All four compose files now declare exactly one volume, pb_data, and the standalone API Server compose declares none - it talks to an external PocketBase and has nothing of its own to keep. Backing up the stack is backing up one path again. Both images get simpler for it. The API Server image loses VOLUME /data and the su-exec entrypoint that existed only to fix a mounted volume's ownership, so it goes back to a plain USER app; its working directory is now /app and holds nothing. The AIO image loses its second volume and chowns only /pb/pb_data. One consequence worth stating plainly, because it is a small regression rather than a no-op. The panel's Settings -> PocketBase and Settings -> Web App screens write .env in the working directory, which is now ephemeral. In the multi-container stack that changes nothing: compose sets all five of those keys as container environment, and loadDotEnv only applies a key that is not already set, so the file could never win a restart there anyway. In the AIO image it did win for POCKETBASE_ADMIN_EMAIL/_PASSWORD, which are not in that container's environment - so a service account fixed from the panel now lasts only until the container is recreated. Both READMEs say so. Moving those two screens into the app_settings singleton would close it properly; the PocketBase URL and credentials cannot follow, since they are how the database is reached in the first place. go build, go vet and go test ./... pass; the compose files parse and each resolves to a single pb_data volume. Not verified: no Docker CLI here, so neither image was built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
660af5736a
commit
ee4ac441be
@@ -30,13 +30,6 @@ WEBAPP_URL=http://localhost:8090
|
||||
# PocketBase auth collection holding app users (default: users).
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# Pre-PocketBase JSON store for the global plugin layer (default: plugins.json).
|
||||
# Those settings now live in the database, in the app_settings singleton, next to
|
||||
# the per-org and per-user layers. This path is only read once — to import an
|
||||
# existing file on the first boot after the upgrade — and is renamed to
|
||||
# plugins.json.migrated afterwards. A fresh install can leave it unset.
|
||||
PLUGINS_FILE=plugins.json
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on this server, authenticating with OCPP Basic auth
|
||||
|
||||
+12
-41
@@ -23,54 +23,25 @@ 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.
|
||||
# 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 apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Run as an unprivileged user.
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
|
||||
COPY --from=build /out/api-server /usr/local/bin/api-server
|
||||
|
||||
# The server writes .env relative to its working directory — the panel rewrites
|
||||
# it when a superadmin retargets the PocketBase connection — and reads a legacy
|
||||
# plugins.json from there once, to import it into the database. So the working
|
||||
# directory must be writable and persistent: hence /data, owned by the
|
||||
# unprivileged user and declared as a volume. A fresh named volume inherits this
|
||||
# ownership. (Plugin settings themselves live in PocketBase, not here.)
|
||||
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 .env, so retargeting PocketBase from the
|
||||
# panel silently fails to stick across a restart. The entrypoint therefore 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
|
||||
# The server keeps no state on disk: plugin settings, like everything else, live
|
||||
# in PocketBase. The working directory is only where a .env would be read from
|
||||
# at startup if one were mounted, which is a local-development convenience — in
|
||||
# Docker every setting arrives as an environment variable. So no volume, and
|
||||
# nothing to make writable beyond the image layer itself.
|
||||
RUN mkdir -p /app && chown app:app /app
|
||||
WORKDIR /app
|
||||
|
||||
# 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.
|
||||
# PLUGINS_FILE is only the one-time import path for a pre-PocketBase install;
|
||||
# the settings themselves live in the database.
|
||||
ENV API_ADDR=:8080 \
|
||||
PLUGINS_FILE=/data/plugins.json
|
||||
ENV API_ADDR=:8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Liveness only: /healthz answers 200 as soon as the process is serving, and
|
||||
@@ -79,6 +50,6 @@ EXPOSE 8080
|
||||
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
|
||||
|
||||
# The entrypoint drops to the unprivileged app user after fixing /data.
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/local/bin/api-server"]
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/api-server"]
|
||||
|
||||
@@ -318,7 +318,6 @@ Copy `.env.example` to `.env` and fill in. Summary:
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | comma-separated browser origins |
|
||||
| `WEBAPP_URL` | `http://localhost:8090` | probed by `/api/status` |
|
||||
| `AUTH_USERS_COLLECTION` | `users` | PocketBase auth collection |
|
||||
| `PLUGINS_FILE` | `plugins.json` | legacy plugin store, imported once then renamed |
|
||||
| `OCPP_REQUIRE_TLS` | `true` | reject chargers that did not connect over TLS |
|
||||
| `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at |
|
||||
| `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) |
|
||||
@@ -327,12 +326,13 @@ Copy `.env.example` to `.env` and fill in. Summary:
|
||||
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
|
||||
still honoured for older deployments; the modern names win when both are set.
|
||||
|
||||
Two paths are resolved **relative to the working directory**: the `.env` the
|
||||
panel rewrites when a superadmin retargets PocketBase, and `PLUGINS_FILE` (read
|
||||
once, to import a pre-PocketBase plugin store). In Docker the working directory
|
||||
is `/data`, a volume, so the `.env` survives a container recreate — see
|
||||
[`Dockerfile`](Dockerfile) and [`../Docker`](../Docker). Plugin settings no
|
||||
longer depend on that volume: they live in the database with everything else.
|
||||
The server keeps **no state on disk**: plugin settings, like everything else it
|
||||
owns, live in PocketBase. A `.env` in the working directory is read at startup as
|
||||
a local-development convenience, and the panel writes back to it when a
|
||||
superadmin retargets PocketBase or the Web App — but in Docker there is no volume
|
||||
behind it, so those two screens apply for the life of the container only. Set the
|
||||
environment variables to change them permanently; see [`Dockerfile`](Dockerfile)
|
||||
and [`../Docker`](../Docker).
|
||||
|
||||
`CORS_ALLOW_ORIGINS` only matters for **browser** clients (the web app). Native
|
||||
mobile apps are not subject to CORS.
|
||||
|
||||
@@ -36,10 +36,6 @@ services:
|
||||
# it already), so the WEBAPP_URL default above can reach a Web App running
|
||||
# on the host rather than in this compose file.
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Holds the .env the panel writes back when a superadmin retargets
|
||||
# PocketBase — see Dockerfile. Plugin settings live in the database.
|
||||
- api_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
@@ -47,5 +43,3 @@ services:
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
api_data:
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -120,21 +118,13 @@ func TestControlStepUpAndAuditE2E(t *testing.T) {
|
||||
pbSrv := httptest.NewServer(fake.handler(t))
|
||||
defer pbSrv.Close()
|
||||
|
||||
// Enable the anker-solix plugin globally via a plugins.json.
|
||||
dir := t.TempDir()
|
||||
pluginsFile := filepath.Join(dir, "plugins.json")
|
||||
if err := os.WriteFile(pluginsFile, []byte(`{"anker-solix":{"enabled":true}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(config.Config{
|
||||
UsersCollection: "users",
|
||||
PluginsFile: pluginsFile,
|
||||
OCPPRequireTLS: false, // httptest is plaintext; TLS enforcement covered elsewhere
|
||||
}, pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||
// The global layer normally lives in PocketBase; point it at the file above
|
||||
// so this test does not have to stand up an app_settings collection too.
|
||||
s.pluginStore = plugins.NewFileStore(pluginsFile)
|
||||
// The global layer normally lives in PocketBase; seed it in memory so this
|
||||
// test does not have to stand up an app_settings collection too.
|
||||
s.pluginStore = plugins.NewMemoryStore([]byte(`{"anker-solix":{"enabled":true}}`))
|
||||
s.plugins = plugins.NewManager(s.pluginStore)
|
||||
if err := s.plugins.Load(context.Background()); err != nil {
|
||||
t.Fatalf("load plugins: %v", err)
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
// Resolution is a cascade: the top layer wins, and a lower layer only fills a
|
||||
// field the layers above left blank.
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server
|
||||
// panel by a superadmin. This is the top of the cascade for everyone.
|
||||
// - global (L1): pluginSettings on the app_settings singleton, set in the API
|
||||
// Server panel by a superadmin. This is the top of the cascade for everyone.
|
||||
// - org (L2): pluginSettings.toyota on the caller's organization record,
|
||||
// editable by an org admin. Present only for users who belong to an org.
|
||||
// - user (L3): pluginSettings.toyota on the caller's own user record.
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
// organization, an org admin) can impose settings from above. See integrations.go
|
||||
// for the full rationale; only the fields differ.
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server
|
||||
// panel by a superadmin — the top of the cascade for everyone.
|
||||
// - global (L1): pluginSettings on the app_settings singleton, set in the API
|
||||
// Server panel by a superadmin — the top of the cascade for everyone.
|
||||
// - org (L2): pluginSettings.ankerSolix on the caller's organization record.
|
||||
// - user (L3): pluginSettings.ankerSolix on the caller's own user record.
|
||||
//
|
||||
|
||||
@@ -93,8 +93,7 @@ func writeTestJSON(w http.ResponseWriter, status int, v any) {
|
||||
func newSchemaTestServer(t *testing.T, f *fakeSchemaPB) *Server {
|
||||
t.Helper()
|
||||
pbSrv := f.server(t)
|
||||
// PluginsFile empty: no legacy import in play here.
|
||||
return New(config.Config{UsersCollection: "users", PluginsFile: ""},
|
||||
return New(config.Config{UsersCollection: "users"},
|
||||
pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||
}
|
||||
|
||||
|
||||
@@ -159,12 +159,10 @@ type Server struct {
|
||||
pb *pb.Client
|
||||
plugins *plugins.Manager
|
||||
|
||||
// pluginStore is the Manager's backing store, kept here so the one-time
|
||||
// import of a legacy plugins.json can address it directly. legacyPlugins is
|
||||
// the path that import reads; pluginsStop ends the background load retry.
|
||||
pluginStore plugins.Store
|
||||
legacyPlugins string
|
||||
pluginsStop context.CancelFunc
|
||||
// pluginStore is the Manager's backing store; pluginsStop ends the
|
||||
// background load retry started by StartPlugins.
|
||||
pluginStore plugins.Store
|
||||
pluginsStop context.CancelFunc
|
||||
|
||||
// ocpp is the OCPP 1.6J Central System that Anker Solix chargers connect to
|
||||
// when their owner picks a control mode of own/proxy (see internal/ocpp and
|
||||
@@ -181,14 +179,13 @@ func New(cfg config.Config, client *pb.Client) *Server {
|
||||
// and user (L3) layers, rather than in a file beside the binary.
|
||||
store := plugins.NewPocketBaseStore(client, colAppSettings)
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
pb: client,
|
||||
pluginStore: store,
|
||||
legacyPlugins: cfg.PluginsFile,
|
||||
plugins: plugins.NewManager(store),
|
||||
ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }),
|
||||
control: newControlIndex(),
|
||||
ctlRL: newRateLimiter(30, time.Minute), // 30 control commands / min / charger
|
||||
cfg: cfg,
|
||||
pb: client,
|
||||
pluginStore: store,
|
||||
plugins: plugins.NewManager(store),
|
||||
ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }),
|
||||
control: newControlIndex(),
|
||||
ctlRL: newRateLimiter(30, time.Minute), // 30 control commands / min / charger
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +231,7 @@ func (s *Server) StartPlugins() error {
|
||||
// create just that one collection — not a full schema reconcile, which an
|
||||
// operator who turned the bootstrap off has not asked for — and read again.
|
||||
func (s *Server) loadPlugins(ctx context.Context) error {
|
||||
err := s.readPlugins(ctx)
|
||||
err := s.plugins.Load(ctx)
|
||||
if err == nil || !plugins.IsMissingCollection(err) {
|
||||
return err
|
||||
}
|
||||
@@ -245,20 +242,6 @@ func (s *Server) loadPlugins(ctx context.Context) error {
|
||||
return err // report the original problem, not the repair's
|
||||
}
|
||||
log.Printf("plugins: created %s", colAppSettings)
|
||||
return s.readPlugins(ctx)
|
||||
}
|
||||
|
||||
// readPlugins imports a pre-PocketBase plugins.json if one is still lying around
|
||||
// and the database holds no settings yet, then reads the settings.
|
||||
func (s *Server) readPlugins(ctx context.Context) error {
|
||||
switch migrated, err := plugins.MigrateLegacyFile(ctx, s.pluginStore, s.legacyPlugins); {
|
||||
case err != nil:
|
||||
// Not fatal: the read below reports the real problem if there is one.
|
||||
log.Printf("plugins: legacy import skipped: %v", err)
|
||||
case migrated:
|
||||
log.Printf("plugins: imported %s into PocketBase; renamed it to %s.migrated",
|
||||
s.legacyPlugins, s.legacyPlugins)
|
||||
}
|
||||
return s.plugins.Load(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -456,15 +454,11 @@ func newProviderTestServer(t *testing.T, fake *fakeProviderPB) *httptest.Server
|
||||
pbSrv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(pbSrv.Close)
|
||||
|
||||
pluginsFile := filepath.Join(t.TempDir(), "plugins.json")
|
||||
if err := os.WriteFile(pluginsFile, []byte(`{"toyota":{"enabled":false}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(config.Config{UsersCollection: "users", PluginsFile: pluginsFile},
|
||||
s := New(config.Config{UsersCollection: "users"},
|
||||
pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||
// The global layer normally lives in PocketBase; point it at the file above
|
||||
// so this test does not have to stand up an app_settings collection too.
|
||||
s.pluginStore = plugins.NewFileStore(pluginsFile)
|
||||
// The global layer normally lives in PocketBase; seed it in memory so this
|
||||
// test does not have to stand up an app_settings collection too.
|
||||
s.pluginStore = plugins.NewMemoryStore([]byte(`{"toyota":{"enabled":false}}`))
|
||||
s.plugins = plugins.NewManager(s.pluginStore)
|
||||
if err := s.plugins.Load(context.Background()); err != nil {
|
||||
t.Fatalf("load plugins: %v", err)
|
||||
|
||||
@@ -19,12 +19,6 @@ type Config struct {
|
||||
// UsersCollection is the PocketBase auth collection holding app users.
|
||||
UsersCollection string
|
||||
|
||||
// PluginsFile is the pre-PocketBase JSON store for the global plugin layer.
|
||||
// Those settings now live in the database (the app_settings singleton), so
|
||||
// this path is read once — to import an existing file on the first boot
|
||||
// after the upgrade — and renamed to *.migrated afterwards.
|
||||
PluginsFile string
|
||||
|
||||
// Superuser service account. Every privileged flow (user/organization
|
||||
// management, all car-domain database access) runs through it. Optional at
|
||||
// startup: when unset those endpoints return 503 and a superadmin can still
|
||||
@@ -73,7 +67,6 @@ func Load() Config {
|
||||
WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:8090"), "/"),
|
||||
AllowOrigins: splitCSV(firstEnvOr("*", "CORS_ALLOW_ORIGINS", "CORS_ORIGINS")),
|
||||
UsersCollection: getenv("AUTH_USERS_COLLECTION", "users"),
|
||||
PluginsFile: getenv("PLUGINS_FILE", "plugins.json"),
|
||||
PocketBaseAdminEmail: firstEnv("POCKETBASE_ADMIN_EMAIL", "PB_ADMIN_EMAIL"),
|
||||
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
|
||||
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
|
||||
|
||||
@@ -260,9 +260,6 @@ it runs as its own process/container, an external plugin is also the
|
||||
and no write is accepted. The server never treats an unreachable database as
|
||||
"no plugins configured", so an outage cannot quietly erase the settings; it
|
||||
retries in the background until the read succeeds.
|
||||
- **Upgrading from a pre-PocketBase install**: an existing `plugins.json` (see
|
||||
`PLUGINS_FILE`) is imported into the database on the first boot that finds no
|
||||
settings there, and renamed to `plugins.json.migrated`.
|
||||
- **If the `app_settings` collection is missing** — an upgrade on a stack that
|
||||
runs with `PB_BOOTSTRAP=false`, so the on-boot schema pass never created it —
|
||||
the server creates that one collection itself and reads again. A missing
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestDescriptor(t *testing.T) {
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
var found bool
|
||||
for _, v := range plugins.NewManager(plugins.NewMemoryStore()).List() {
|
||||
for _, v := range plugins.NewManager(plugins.NewMemoryStore(nil)).List() {
|
||||
if v.Name == "anker-solix" {
|
||||
found = true
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestDescriptor(t *testing.T) {
|
||||
func TestRegistered(t *testing.T) {
|
||||
// The plugin must self-register via init() so the manager can construct it.
|
||||
var found bool
|
||||
for _, v := range plugins.NewManager(plugins.NewMemoryStore()).List() {
|
||||
for _, v := range plugins.NewManager(plugins.NewMemoryStore(nil)).List() {
|
||||
if v.Name == "toyota" {
|
||||
found = true
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -194,144 +191,3 @@ func TestExternalPluginRoundTrip(t *testing.T) {
|
||||
t.Fatal("removed external plugin came back")
|
||||
}
|
||||
}
|
||||
|
||||
// --- file store -------------------------------------------------------------
|
||||
|
||||
// The file store writes via temp file + rename, leaving nothing behind.
|
||||
func TestFileStoreLeavesNoTempFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "plugins.json")
|
||||
m := loadedManager(t, NewFileStore(path))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := m.Upsert(context.Background(), "test-a", true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "plugins.json" {
|
||||
t.Fatalf("unexpected leftover file: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unwritable directory is a persist failure, not a silent success.
|
||||
func TestFileStoreUnwritableIsAPersistFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "missing-dir", "plugins.json")
|
||||
m := loadedManager(t, NewFileStore(path))
|
||||
|
||||
if _, err := m.Upsert(context.Background(), "test-a", true, nil); !IsPersist(err) {
|
||||
t.Fatalf("expected persist failure, got %v", err)
|
||||
}
|
||||
if v, _ := m.Get("test-a"); v.Enabled {
|
||||
t.Fatal("plugin reported enabled although the save never landed")
|
||||
}
|
||||
}
|
||||
|
||||
// --- legacy import ----------------------------------------------------------
|
||||
|
||||
func TestMigrateLegacyFileImportsOnceThenRenames(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
legacy := `{"test-a":{"enabled":true,"config":{"user":"me"}}}`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &memoryStore{}
|
||||
ctx := context.Background()
|
||||
|
||||
migrated, err := MigrateLegacyFile(ctx, store, path)
|
||||
if err != nil || !migrated {
|
||||
t.Fatalf("first import: migrated=%v err=%v", migrated, err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the legacy file should have been renamed away")
|
||||
}
|
||||
if _, err := os.Stat(path + ".migrated"); err != nil {
|
||||
t.Fatalf("renamed file missing: %v", err)
|
||||
}
|
||||
|
||||
m := loadedManager(t, store)
|
||||
if v, _ := m.Get("test-a"); !v.Enabled || v.Config["user"] != "me" {
|
||||
t.Fatalf("imported settings wrong: enabled=%v config=%v", v.Enabled, v.Config)
|
||||
}
|
||||
|
||||
// A second pass must not re-import (and there is nothing left to import).
|
||||
if migrated, err := MigrateLegacyFile(ctx, store, path); migrated || err != nil {
|
||||
t.Fatalf("second import: migrated=%v err=%v", migrated, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A store that already holds settings must never be seeded from a stale file.
|
||||
func TestMigrateLegacyFileSkipsWhenStoreHasSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
if err := os.WriteFile(path, []byte(`{"test-a":{"enabled":true}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := seeded(`{"test-b":{"enabled":true}}`)
|
||||
|
||||
migrated, err := MigrateLegacyFile(context.Background(), store, path)
|
||||
if err != nil || migrated {
|
||||
t.Fatalf("migrated=%v err=%v — a populated store must not be overwritten", migrated, err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal("the file should be left alone when nothing was imported")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable store must not trigger an import either: seeding on a failed
|
||||
// read would overwrite live settings with a stale file.
|
||||
func TestMigrateLegacyFileSkipsWhenStoreUnreachable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
if err := os.WriteFile(path, []byte(`{"test-a":{"enabled":true}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &memoryStore{failLoad: errNotReady}
|
||||
|
||||
if migrated, err := MigrateLegacyFile(context.Background(), store, path); migrated || err == nil {
|
||||
t.Fatalf("migrated=%v err=%v — must refuse while the store is unreachable", migrated, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A corrupt legacy file is reported and left on disk, not written into the DB.
|
||||
func TestMigrateLegacyFileRejectsCorruptFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
if err := os.WriteFile(path, []byte(`{"test-a":{"enabled":true}}junk`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &memoryStore{}
|
||||
|
||||
migrated, err := MigrateLegacyFile(context.Background(), store, path)
|
||||
if migrated || err == nil {
|
||||
t.Fatalf("migrated=%v err=%v — a corrupt file must not be imported", migrated, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not imported") {
|
||||
t.Fatalf("error should say nothing was imported, got: %v", err)
|
||||
}
|
||||
if store.data != nil {
|
||||
t.Fatal("nothing should have been written")
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Fatal("the corrupt file should be left on disk for inspection")
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh install has no file and no settings; that is not an error.
|
||||
func TestMigrateLegacyFileNoFile(t *testing.T) {
|
||||
store := &memoryStore{}
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
if migrated, err := MigrateLegacyFile(context.Background(), store, path); migrated || err != nil {
|
||||
t.Fatalf("migrated=%v err=%v", migrated, err)
|
||||
}
|
||||
if migrated, err := MigrateLegacyFile(context.Background(), store, ""); migrated || err != nil {
|
||||
t.Fatalf("empty path: migrated=%v err=%v", migrated, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestFile is a small helper shared with store_pb_test.go.
|
||||
func writeTestFile(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
// - "external" — a remote service registered at runtime (no rebuild) that speaks
|
||||
// a small JSON contract over HTTP. See external.go.
|
||||
//
|
||||
// Enable-state and per-plugin config (including secrets) are persisted to a local
|
||||
// plugins.json by the Manager, mirroring how the PocketBase connection persists to
|
||||
// .env. See doc.go for the deliberately-deferred extension points.
|
||||
// Enable-state and per-plugin config (including secrets) are persisted by the
|
||||
// Manager to PocketBase — the app_settings singleton, in the same pluginSettings
|
||||
// field the org and user layers of the cascade use. Nothing is kept on disk. See
|
||||
// doc.go for the deliberately-deferred extension points.
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"drivervault/apiserver/internal/pb"
|
||||
@@ -15,14 +13,11 @@ import (
|
||||
|
||||
// Store is where a Manager keeps plugin enable-state and global (L1) config.
|
||||
//
|
||||
// It deals in the raw JSON document rather than in records, so the same Manager
|
||||
// logic works whichever backing it has: PocketBase in production, a file for the
|
||||
// one-time import of the legacy plugins.json, memory in tests.
|
||||
// It deals in the raw JSON document rather than in records, so the Manager does
|
||||
// not care where that document lives: PocketBase in production, memory in tests.
|
||||
type Store interface {
|
||||
// Load returns the stored document. found is false when the store holds no
|
||||
// document at all — a fresh install — which callers must tell apart from a
|
||||
// document that exists and is empty, because only the former may be seeded
|
||||
// from a legacy file.
|
||||
// document yet — a fresh install, nothing configured.
|
||||
//
|
||||
// A returned error means the store could not be reached or read. It must
|
||||
// NOT be taken as "no settings": the Manager stays unloaded and refuses to
|
||||
@@ -53,7 +48,8 @@ const globalSettingsKey = "global"
|
||||
// --- PocketBase-backed store (production) -----------------------------------
|
||||
|
||||
// pbStore keeps the document in a single record of the app_settings collection,
|
||||
// identified by key="global". This is the production store.
|
||||
// identified by key="global". This is the only store the server itself uses:
|
||||
// nothing about the plugin layer touches the filesystem.
|
||||
type pbStore struct {
|
||||
client *pb.Client
|
||||
collection string
|
||||
@@ -185,62 +181,6 @@ func isNotFound(err error) bool {
|
||||
return errors.As(err, &apiErr) && apiErr.Status == 404
|
||||
}
|
||||
|
||||
// --- File-backed store (legacy import, and tests) ---------------------------
|
||||
|
||||
// fileStore keeps the document in a local JSON file. This was the only store
|
||||
// before the global layer moved into PocketBase; it survives as the source of
|
||||
// the one-time import, and as a convenient store for tests.
|
||||
type fileStore struct{ path string }
|
||||
|
||||
// NewFileStore returns a Store backed by the JSON file at path.
|
||||
func NewFileStore(path string) Store { return &fileStore{path: path} }
|
||||
|
||||
func (s *fileStore) Describe() string { return s.path }
|
||||
|
||||
func (s *fileStore) Load(ctx context.Context) ([]byte, bool, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return data, true, nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return nil, false, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("%w: %v", errNotReady, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save writes through a temporary file and renames it into place, so an
|
||||
// interrupted write cannot leave a half-written document behind.
|
||||
func (s *fileStore) Save(ctx context.Context, data []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".plugins-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
if tmpName != "" {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
tmpName = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- In-memory store (tests) ------------------------------------------------
|
||||
|
||||
// memoryStore holds the document in memory. failLoad/failSave let a test drive
|
||||
@@ -253,8 +193,11 @@ type memoryStore struct {
|
||||
failSave error
|
||||
}
|
||||
|
||||
// NewMemoryStore returns a Store that keeps the document in memory.
|
||||
func NewMemoryStore() Store { return &memoryStore{} }
|
||||
// NewMemoryStore returns a Store that keeps the document in memory, seeded with
|
||||
// document (nil for an empty store). Intended for tests.
|
||||
func NewMemoryStore(document []byte) Store {
|
||||
return &memoryStore{data: document, found: document != nil}
|
||||
}
|
||||
|
||||
func (s *memoryStore) Describe() string { return "memory" }
|
||||
|
||||
@@ -277,60 +220,3 @@ func (s *memoryStore) Save(ctx context.Context, data []byte) error {
|
||||
s.found = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Legacy import ----------------------------------------------------------
|
||||
|
||||
// MigrateLegacyFile seeds store from the pre-PocketBase plugins.json at path,
|
||||
// and is a no-op unless every condition holds: the store has no document at all,
|
||||
// the file exists, and it parses. On success the file is renamed to
|
||||
// <path>.migrated so a later boot cannot import it a second time or let the two
|
||||
// copies drift apart.
|
||||
//
|
||||
// It reports whether an import happened. A store that could not be read returns
|
||||
// an error and imports nothing — seeding on a failed read would overwrite live
|
||||
// settings with a stale file.
|
||||
func MigrateLegacyFile(ctx context.Context, store Store, path string) (bool, error) {
|
||||
if path == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, found, err := store.Load(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if found {
|
||||
return false, nil // already living in the store; the file is history
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil // fresh install, nothing to import
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Only import something we can actually parse, so a corrupt file is left on
|
||||
// disk for inspection rather than written into the database.
|
||||
recs := map[string]*record{}
|
||||
if err := decodeRecords(data, recs); err != nil {
|
||||
return false, fmt.Errorf("legacy %s not imported: %v", path, err)
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
encoded, err := encodeRecords(recs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := store.Save(ctx, encoded); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.Rename(path, path+".migrated"); err != nil {
|
||||
// The settings are safely in the store; failing to rename only risks a
|
||||
// confusing leftover file, so report it without undoing the import.
|
||||
return true, fmt.Errorf("imported %s but could not rename it: %v", path, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -220,35 +220,3 @@ func TestPBStoreOutageIsNotAMissingCollection(t *testing.T) {
|
||||
t.Fatalf("an outage must not be reported as a missing collection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The legacy file is imported into PocketBase exactly once.
|
||||
func TestPBStoreLegacyImport(t *testing.T) {
|
||||
f := &fakePB{}
|
||||
store := newPBStore(t, f)
|
||||
path := t.TempDir() + "/plugins.json"
|
||||
if err := writeTestFile(path, `{"test-a":{"enabled":true,"config":{"user":"me"}}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
migrated, err := MigrateLegacyFile(context.Background(), store, path)
|
||||
if err != nil || !migrated {
|
||||
t.Fatalf("import: migrated=%v err=%v", migrated, err)
|
||||
}
|
||||
|
||||
m := loadedManager(t, store)
|
||||
if v, _ := m.Get("test-a"); !v.Enabled || v.Config["user"] != "me" {
|
||||
t.Fatalf("imported settings wrong: enabled=%v config=%v", v.Enabled, v.Config)
|
||||
}
|
||||
|
||||
// Second boot: the store now has a document, so nothing is re-imported.
|
||||
migrated, err = MigrateLegacyFile(context.Background(), store, path)
|
||||
if err != nil || migrated {
|
||||
t.Fatalf("second import: migrated=%v err=%v", migrated, err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
creates := f.creates
|
||||
f.mu.Unlock()
|
||||
if creates != 1 {
|
||||
t.Fatalf("expected one create, got %d", creates)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,7 @@ API_PORT=8080
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# Defaults are Docker-managed named volumes. Set either to an absolute host path
|
||||
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads.
|
||||
# API_DATA — the .env the API Server panel writes back (plugin settings are in
|
||||
# the database, under PB_DATA)
|
||||
# when a superadmin retargets the PocketBase connection.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# image: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
API_DATA=api_data
|
||||
|
||||
+12
-15
@@ -16,7 +16,6 @@
|
||||
# -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/_/
|
||||
@@ -162,12 +161,11 @@ 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.)
|
||||
; API Server: wait for PocketBase to be healthy, then start. It keeps no state
|
||||
; on disk — plugin settings, like everything else it owns, live in PocketBase —
|
||||
; so its working directory is just a place to run from.
|
||||
[program:api-server]
|
||||
directory=/data
|
||||
directory=/app
|
||||
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
|
||||
@@ -190,13 +188,13 @@ 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.
|
||||
# The entrypoint stays root only long enough to make the data volume writable by
|
||||
# the app user, then hands off to supervisord. The chown matters for a host bind
|
||||
# mount, which arrives owned by root rather than inheriting the image's owner.
|
||||
RUN cat > /entrypoint.sh <<'ENTRY'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
for dir in /pb/pb_data /data; do
|
||||
for dir in /pb/pb_data; do
|
||||
mkdir -p "$dir"
|
||||
if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then
|
||||
echo "entrypoint: taking ownership of $dir"
|
||||
@@ -214,7 +212,6 @@ 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.
|
||||
@@ -229,12 +226,12 @@ ENV API_ADDR=:8080 \
|
||||
# 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
|
||||
# pb_data holds everything that persists — the database, the uploads, and the
|
||||
# server settings that used to sit in a file beside the binary. It is the only
|
||||
# volume: the API Server keeps no state on disk. Both directories 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
|
||||
RUN mkdir -p /pb/pb_data /app && chown -R app:app /pb /app
|
||||
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
|
||||
|
||||
+10
-4
@@ -76,11 +76,17 @@ Identical to the multi-container stack, and idempotent:
|
||||
|
||||
| Volume | Holds |
|
||||
|---|---|
|
||||
| `/pb/pb_data` | the PocketBase SQLite database and uploaded files |
|
||||
| `/data` | the `.env` the API Server panel rewrites when a superadmin retargets the PocketBase connection (plugin settings live in `/pb/pb_data`, with everything else) |
|
||||
| `/pb/pb_data` | the PocketBase SQLite database and uploaded files — everything that persists |
|
||||
|
||||
Both default to Docker-managed named volumes; set `PB_DATA` / `API_DATA` to
|
||||
absolute host paths in the prod file for bind mounts.
|
||||
One volume, because the API Server keeps no state on disk. Plugin enable-state
|
||||
and global config live in the database like the rest of the settings, so backing
|
||||
up this one path backs up the whole image. It defaults to a Docker-managed named
|
||||
volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount.
|
||||
|
||||
> One thing does **not** persist: the API Server panel's *Settings → PocketBase*
|
||||
> and *Settings → Web App* screens apply immediately but only for the life of the
|
||||
> container. Set the corresponding environment variables to change them
|
||||
> permanently.
|
||||
|
||||
## Charger control (OCPP)
|
||||
|
||||
|
||||
@@ -48,11 +48,10 @@ services:
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# Named volumes by default; set PB_DATA / API_DATA to host paths in .env
|
||||
# for bind mounts.
|
||||
# The only volume — named by default; set PB_DATA to a host path in .env
|
||||
# for a bind mount. The API Server keeps no state on disk, so everything
|
||||
# it owns (plugin settings included) is in here.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
# The .env the panel writes back; plugin settings live in the database.
|
||||
- "${API_DATA:-api_data}:/data"
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
@@ -64,4 +63,3 @@ services:
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
api_data:
|
||||
|
||||
@@ -51,9 +51,9 @@ services:
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume: the API Server keeps no state on disk, so everything
|
||||
# it owns — plugin settings included — lives in the database.
|
||||
- pb_data:/pb/pb_data
|
||||
# The .env the panel writes back; plugin settings live in the database.
|
||||
- api_data:/data
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
@@ -65,4 +65,3 @@ services:
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
api_data:
|
||||
|
||||
@@ -61,9 +61,7 @@ API_BIND=127.0.0.1
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# Defaults are Docker-managed named volumes. To store either on a host path
|
||||
# instead, set it to an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads.
|
||||
# API_DATA — the .env the API Server panel writes back (plugin settings are in
|
||||
# the database, under PB_DATA)
|
||||
# when a superadmin retargets the PocketBase connection.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
API_DATA=api_data
|
||||
|
||||
+11
-20
@@ -64,28 +64,19 @@ schema reconcile itself.
|
||||
|
||||
| Volume | Holds |
|
||||
|---|---|
|
||||
| `pb_data` | the PocketBase SQLite database and uploaded files |
|
||||
| `api_data` | the `.env` the API Server panel rewrites when a superadmin retargets the PocketBase connection (plugin settings live in `pb_data`, with everything else) |
|
||||
| `pb_data` | the PocketBase SQLite database and uploaded files — everything that persists |
|
||||
|
||||
Both default to Docker-managed named volumes. In the prod file, set `PB_DATA` /
|
||||
`API_DATA` to absolute host paths for bind mounts instead.
|
||||
One volume, because the API Server keeps no state on disk. Plugin enable-state
|
||||
and global config live in the database like the rest of the settings, so backing
|
||||
up `pb_data` backs up the whole stack. It defaults to a Docker-managed named
|
||||
volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount
|
||||
instead. PocketBase runs as root, so a root-owned host directory is fine.
|
||||
|
||||
> The API Server serves as the unprivileged `app` user, so `/data` has to be
|
||||
> writable by it. Its entrypoint arranges that itself: it starts as root, takes
|
||||
> ownership of `/data` if `app` does not already hold it, then drops privileges.
|
||||
> So a **bind mount** to a root-owned host path needs no manual `chown`, and
|
||||
> neither does a **named volume** left over from an image that ran as root. The
|
||||
> one case it cannot fix is a container forced to another user (`user:` in
|
||||
> compose, `docker run --user`), where the entrypoint has no privileges to
|
||||
> `chown` with — prepare the host directory yourself there. PocketBase runs as
|
||||
> root, so `PB_DATA` is unaffected either way.
|
||||
|
||||
Plugin enable-state and global config used to live in a `plugins.json` on this
|
||||
volume, which made them the one piece of configuration a lost volume could erase
|
||||
without anyone noticing. They are now in PocketBase, backed up with `pb_data`
|
||||
like everything else. An existing `plugins.json` is imported automatically on the
|
||||
first boot after the upgrade and renamed to `plugins.json.migrated`; keep the
|
||||
volume mounted for that boot.
|
||||
> One thing does **not** persist: the API Server panel's *Settings → PocketBase*
|
||||
> and *Settings → Web App* screens apply immediately but only for the life of the
|
||||
> container. Set `POCKETBASE_URL`, `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD`,
|
||||
> `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently —
|
||||
> in this stack the compose environment wins over anything the panel writes.
|
||||
|
||||
## Charger control (OCPP)
|
||||
|
||||
|
||||
@@ -81,11 +81,8 @@ services:
|
||||
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||
volumes:
|
||||
# The .env the panel writes back when a superadmin retargets PocketBase —
|
||||
# see the API Server Dockerfile. Plugin settings live in the database, so
|
||||
# they no longer depend on this volume.
|
||||
- "${API_DATA:-api_data}:/data"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
@@ -119,4 +116,3 @@ services:
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
api_data:
|
||||
|
||||
@@ -74,11 +74,8 @@ services:
|
||||
# App reaches it over the internal network, not this host port. Chargers
|
||||
# dialling /ocpp/{serial} also arrive here.
|
||||
- "${API_PORT:-8080}:8080"
|
||||
volumes:
|
||||
# The .env the panel writes back when a superadmin retargets PocketBase —
|
||||
# see the API Server Dockerfile. Plugin settings live in the database, so
|
||||
# they no longer depend on this volume.
|
||||
- api_data:/data
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under pb_data.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
@@ -116,4 +113,3 @@ services:
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
api_data:
|
||||
|
||||
Reference in New Issue
Block a user