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