Files
DriverVault/API Server/internal/plugins/store.go
T
tajniak81andClaude Opus 5 ee4ac441be 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>
2026-08-21 17:41:02 +02:00

223 lines
6.6 KiB
Go

package plugins
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"sync"
"drivervault/apiserver/internal/pb"
)
// 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 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 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
// save, so a transient outage cannot overwrite settings it never read.
Load(ctx context.Context) (data []byte, found bool, err error)
// Save replaces the stored document.
Save(ctx context.Context, data []byte) error
// Describe names the store for log and error messages.
Describe() string
}
// PluginSettingsField is the record field the document lives in. The org (L2)
// and user (L3) layers of the cascade keep their plugin config in a field of
// this name too (see internal/api/integrations.go), so the global layer is
// stored the same way they are rather than being a special case.
//
// The documents are not identical in shape: L2/L3 hold per-plugin user config
// only, while L1 additionally carries the enable state and the registration of
// external plugins. Same mechanism and same field, different payload.
const PluginSettingsField = "pluginSettings"
// globalSettingsKey identifies the singleton record holding the global layer.
// It is a fixed constant, never caller input.
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 only store the server itself uses:
// nothing about the plugin layer touches the filesystem.
type pbStore struct {
client *pb.Client
collection string
mu sync.Mutex
id string // cached id of the singleton record, resolved on first Load/Save
}
// NewPocketBaseStore returns a Store backed by the singleton settings record in
// the given collection.
func NewPocketBaseStore(client *pb.Client, collection string) Store {
return &pbStore{client: client, collection: collection}
}
func (s *pbStore) Describe() string {
return "PocketBase " + s.collection + " (key=" + globalSettingsKey + ")"
}
// settingsRecord is the slice of the singleton record this store reads.
type settingsRecord struct {
ID string `json:"id"`
PluginSettings json.RawMessage `json:"pluginSettings"`
}
func (s *pbStore) Load(ctx context.Context) ([]byte, bool, error) {
if !s.client.Configured() {
return nil, false, fmt.Errorf("%w: no PocketBase service account configured", errNotReady)
}
rec, found, err := s.find(ctx)
if err != nil {
return nil, false, err
}
if !found {
return nil, false, nil
}
s.mu.Lock()
s.id = rec.ID
s.mu.Unlock()
return rec.PluginSettings, true, nil
}
// find resolves the singleton record. A missing record is not an error.
func (s *pbStore) find(ctx context.Context) (settingsRecord, bool, error) {
q := url.Values{}
q.Set("filter", "key='"+globalSettingsKey+"'")
q.Set("perPage", "1")
res, err := s.client.List(ctx, s.collection, q)
if err != nil {
if isNotFound(err) {
// A 404 from a list means the collection itself is absent: listing an
// existing but empty collection answers 200 with no items. Tagged
// apart from a plain outage because retrying a read against a
// collection that does not exist can never succeed — the caller has
// to create it. Still a flavour of not-ready, so nothing is
// overwritten in the meantime.
return settingsRecord{}, false, fmt.Errorf("%w: %v", errNoCollection, err)
}
return settingsRecord{}, false, fmt.Errorf("%w: %v", errNotReady, err)
}
var items []settingsRecord
if err := json.Unmarshal(res.Items, &items); err != nil {
return settingsRecord{}, false, fmt.Errorf("%w: %v", errNotReady, err)
}
if len(items) == 0 {
return settingsRecord{}, false, nil
}
return items[0], true, nil
}
func (s *pbStore) Save(ctx context.Context, data []byte) error {
if !s.client.Configured() {
return fmt.Errorf("%w: no PocketBase service account configured", errPersist)
}
s.mu.Lock()
id := s.id
s.mu.Unlock()
// Resolve the record id if this process has not seen it yet (or if the
// record was recreated behind our back).
if id == "" {
rec, found, err := s.find(ctx)
if err != nil {
return fmt.Errorf("%w: %v", errPersist, err)
}
if found {
id = rec.ID
}
}
payload := map[string]any{
"key": globalSettingsKey,
PluginSettingsField: json.RawMessage(data),
}
if id != "" {
if err := s.client.Update(ctx, s.collection, id, payload, nil); err != nil {
if !isNotFound(err) {
return fmt.Errorf("%w: %v", errPersist, err)
}
// The record was deleted since we resolved it; fall through and
// recreate it rather than losing the save.
s.mu.Lock()
s.id = ""
s.mu.Unlock()
id = ""
} else {
s.mu.Lock()
s.id = id
s.mu.Unlock()
return nil
}
}
var created settingsRecord
if err := s.client.Create(ctx, s.collection, payload, &created); err != nil {
return fmt.Errorf("%w: %v", errPersist, err)
}
s.mu.Lock()
s.id = created.ID
s.mu.Unlock()
return nil
}
// isNotFound reports whether err is a PocketBase 404.
func isNotFound(err error) bool {
var apiErr *pb.APIError
return errors.As(err, &apiErr) && apiErr.Status == 404
}
// --- In-memory store (tests) ------------------------------------------------
// memoryStore holds the document in memory. failLoad/failSave let a test drive
// the not-ready and failed-save paths.
type memoryStore struct {
mu sync.Mutex
data []byte
found bool
failLoad error
failSave error
}
// 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" }
func (s *memoryStore) Load(ctx context.Context) ([]byte, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.failLoad != nil {
return nil, false, s.failLoad
}
return s.data, s.found, nil
}
func (s *memoryStore) Save(ctx context.Context, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.failSave != nil {
return s.failSave
}
s.data = append([]byte(nil), data...)
s.found = true
return nil
}