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: