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:
tajniak81
2026-08-21 17:41:02 +02:00
co-authored by Claude Opus 5
parent 660af5736a
commit ee4ac441be
27 changed files with 107 additions and 503 deletions
+10 -124
View File
@@ -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
}