From ee4ac441becf1d63a7d44b508638fc1fce6c452a Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:41:02 +0200 Subject: [PATCH] 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 --- API Server/.env.example | 7 - API Server/Dockerfile | 53 ++----- API Server/README.md | 14 +- API Server/docker-compose.yml | 6 - API Server/internal/api/control_e2e_test.go | 16 +- API Server/internal/api/integrations.go | 4 +- .../internal/api/integrations_ankersolix.go | 4 +- .../internal/api/plugins_bootstrap_test.go | 3 +- API Server/internal/api/server.go | 41 ++--- .../internal/api/vehicleproviders_test.go | 14 +- API Server/internal/config/config.go | 7 - API Server/internal/plugins/README.md | 3 - .../builtin/ankersolix/ankersolix_test.go | 2 +- .../plugins/builtin/toyota/toyota_test.go | 2 +- .../internal/plugins/manager_persist_test.go | 144 ------------------ API Server/internal/plugins/plugin.go | 7 +- API Server/internal/plugins/store.go | 134 ++-------------- API Server/internal/plugins/store_pb_test.go | 32 ---- Docker-AIO/.env.prod.example | 8 +- Docker-AIO/Dockerfile | 27 ++-- Docker-AIO/README.md | 14 +- Docker-AIO/docker-compose.prod.yml | 8 +- Docker-AIO/docker-compose.yml | 5 +- Docker/.env.prod.example | 8 +- Docker/README.md | 31 ++-- Docker/docker-compose.prod.yml | 8 +- Docker/docker-compose.yml | 8 +- 27 files changed, 107 insertions(+), 503 deletions(-) diff --git a/API Server/.env.example b/API Server/.env.example index b894e36..41d7927 100644 --- a/API Server/.env.example +++ b/API Server/.env.example @@ -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 diff --git a/API Server/Dockerfile b/API Server/Dockerfile index 26b281d..86fe882 100644 --- a/API Server/Dockerfile +++ b/API Server/Dockerfile @@ -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"] diff --git a/API Server/README.md b/API Server/README.md index 612a883..592c7bb 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -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. diff --git a/API Server/docker-compose.yml b/API Server/docker-compose.yml index 455bfa0..2748dda 100644 --- a/API Server/docker-compose.yml +++ b/API Server/docker-compose.yml @@ -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: diff --git a/API Server/internal/api/control_e2e_test.go b/API Server/internal/api/control_e2e_test.go index 5173254..5cec7a8 100644 --- a/API Server/internal/api/control_e2e_test.go +++ b/API Server/internal/api/control_e2e_test.go @@ -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) diff --git a/API Server/internal/api/integrations.go b/API Server/internal/api/integrations.go index dd2abf6..9caabb7 100644 --- a/API Server/internal/api/integrations.go +++ b/API Server/internal/api/integrations.go @@ -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. diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index 0ec51ef..c0660eb 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -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. // diff --git a/API Server/internal/api/plugins_bootstrap_test.go b/API Server/internal/api/plugins_bootstrap_test.go index 6a3f63b..e1cad71 100644 --- a/API Server/internal/api/plugins_bootstrap_test.go +++ b/API Server/internal/api/plugins_bootstrap_test.go @@ -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")) } diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 52fa788..2543909 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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) } diff --git a/API Server/internal/api/vehicleproviders_test.go b/API Server/internal/api/vehicleproviders_test.go index db8e894..8968b5a 100644 --- a/API Server/internal/api/vehicleproviders_test.go +++ b/API Server/internal/api/vehicleproviders_test.go @@ -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) diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go index b06f958..38a8119 100644 --- a/API Server/internal/config/config.go +++ b/API Server/internal/config/config.go @@ -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), diff --git a/API Server/internal/plugins/README.md b/API Server/internal/plugins/README.md index 4927628..e1ac174 100644 --- a/API Server/internal/plugins/README.md +++ b/API Server/internal/plugins/README.md @@ -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 diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go index c726ef1..1652303 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go @@ -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 } diff --git a/API Server/internal/plugins/builtin/toyota/toyota_test.go b/API Server/internal/plugins/builtin/toyota/toyota_test.go index 0e2037b..851ce04 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota_test.go +++ b/API Server/internal/plugins/builtin/toyota/toyota_test.go @@ -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 } diff --git a/API Server/internal/plugins/manager_persist_test.go b/API Server/internal/plugins/manager_persist_test.go index 1fd2c3e..7ae6094 100644 --- a/API Server/internal/plugins/manager_persist_test.go +++ b/API Server/internal/plugins/manager_persist_test.go @@ -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) -} diff --git a/API Server/internal/plugins/plugin.go b/API Server/internal/plugins/plugin.go index 136d011..fe73d9b 100644 --- a/API Server/internal/plugins/plugin.go +++ b/API Server/internal/plugins/plugin.go @@ -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 ( diff --git a/API Server/internal/plugins/store.go b/API Server/internal/plugins/store.go index 9f69879..7187608 100644 --- a/API Server/internal/plugins/store.go +++ b/API Server/internal/plugins/store.go @@ -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 -// .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 -} diff --git a/API Server/internal/plugins/store_pb_test.go b/API Server/internal/plugins/store_pb_test.go index ff95fcd..2ee470b 100644 --- a/API Server/internal/plugins/store_pb_test.go +++ b/API Server/internal/plugins/store_pb_test.go @@ -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) - } -} diff --git a/Docker-AIO/.env.prod.example b/Docker-AIO/.env.prod.example index 0c8e25e..fc07184 100644 --- a/Docker-AIO/.env.prod.example +++ b/Docker-AIO/.env.prod.example @@ -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 diff --git a/Docker-AIO/Dockerfile b/Docker-AIO/Dockerfile index e31de97..5ae9f06 100644 --- a/Docker-AIO/Dockerfile +++ b/Docker-AIO/Dockerfile @@ -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 diff --git a/Docker-AIO/README.md b/Docker-AIO/README.md index e3085da..1b8746e 100644 --- a/Docker-AIO/README.md +++ b/Docker-AIO/README.md @@ -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) diff --git a/Docker-AIO/docker-compose.prod.yml b/Docker-AIO/docker-compose.prod.yml index dac6fd1..6c40d66 100644 --- a/Docker-AIO/docker-compose.prod.yml +++ b/Docker-AIO/docker-compose.prod.yml @@ -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: diff --git a/Docker-AIO/docker-compose.yml b/Docker-AIO/docker-compose.yml index 17d6c54..1fa8b4a 100644 --- a/Docker-AIO/docker-compose.yml +++ b/Docker-AIO/docker-compose.yml @@ -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: diff --git a/Docker/.env.prod.example b/Docker/.env.prod.example index 0565bfd..edbee5d 100644 --- a/Docker/.env.prod.example +++ b/Docker/.env.prod.example @@ -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 diff --git a/Docker/README.md b/Docker/README.md index 7b26125..ca75f2f 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -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) diff --git a/Docker/docker-compose.prod.yml b/Docker/docker-compose.prod.yml index d9c09f5..1cde541 100644 --- a/Docker/docker-compose.prod.yml +++ b/Docker/docker-compose.prod.yml @@ -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: diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index f629313..405e880 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -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: