Plugins: the global layer moves into the database, beside the other two
The integration cascade stored its top layer differently from the two below it: org (L2) and user (L3) plugin config lived in PocketBase, in a pluginSettings field, while the global (L1) layer sat in a plugins.json next to the binary. That split was accretion rather than design - the file was the whole store in the v1 MVP, and the per-tenant layers were later built on PocketBase and layered on top of it instead of replacing it. It also cost something real. plugins.json was a second state store with different durability from pb_data: its own volume, its own ownership, its own backup. Losing pb_data is unmissable; losing api_data was silent, which is how "every plugin comes back disabled after a redeploy" happened. L1 now lives in the app_settings collection - one record keyed "global", holding its settings in a pluginSettings field, the same mechanism and the same field name the layers below use. The documents still differ in shape, because only L1 carries enable state and the registration of external plugins, but the storage is no longer a special case. The Manager grows a Store seam (PocketBase in production, file for the import, memory for tests) and, more importantly, a loaded gate. Settings in a database mean the store can be unreachable at boot - a cold stack, or a service account still to be set from the panel. That must not read as "no plugins configured", or the first save would write emptiness over real settings. So until a read succeeds the Manager stays unloaded, every mutation is refused, /api/admin/plugins* answers 503, and a background retry backs off to two minutes. The same gate covers a document that will not parse: it is never replaced by one built from an empty map, which is a stronger guarantee than the .corrupt backup it replaces. Writing to a store also revealed a hole in the previous fix. Classifying a save failure as errPersist was left to each Store, and a store that returned a plain error would fall through to the "saved, but the plugin failed to start" branch and be reported as a 200 - the same silent-success bug through a different door. The Manager now classifies, whatever the Store returns; a test pins it. Upgrades are automatic: on the first boot that finds no settings in the database, an existing plugins.json is imported and renamed to plugins.json.migrated. The import is refused if the store is merely unreachable, or if the file does not parse, so a stale or broken file can never overwrite live settings. /data is still needed - the panel rewrites .env there when it retargets PocketBase - but plugin settings no longer depend on it. 21 tests in internal/plugins cover both stores, including the production path against a fake PocketBase: create-then-update of the singleton, round-trip across a restart, an outage that leaves settings intact, a missing collection reading as not-ready rather than empty, and the import running exactly once. go build, go vet and go test ./... pass. Schema changes are mirrored into scripts/setup-pocketbase.mjs as that file requires. Not verified: no Docker CLI here, so no image was built and the bootstrap of app_settings against a real PocketBase is untested outside the fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d2803bbd93
commit
9bd5c523c4
@@ -8,8 +8,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -41,8 +39,9 @@ type View struct {
|
||||
|
||||
// Manager owns the plugin registry, persisted state, and live instances.
|
||||
type Manager struct {
|
||||
path string
|
||||
store Store
|
||||
mu sync.Mutex
|
||||
loaded bool // settings have been read; until then saves are refused
|
||||
factories map[string]Factory
|
||||
records map[string]*record
|
||||
live map[string]Plugin
|
||||
@@ -50,10 +49,12 @@ type Manager struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewManager builds a Manager backed by the JSON state file at path.
|
||||
func NewManager(path string) *Manager {
|
||||
// NewManager builds a Manager over the given Store. It starts unloaded: call
|
||||
// Load before serving, and keep calling it until it succeeds if the store is
|
||||
// not reachable yet (see Ready).
|
||||
func NewManager(store Store) *Manager {
|
||||
return &Manager{
|
||||
path: path,
|
||||
store: store,
|
||||
factories: builtinFactories(),
|
||||
records: map[string]*record{},
|
||||
live: map[string]Plugin{},
|
||||
@@ -62,25 +63,47 @@ func NewManager(path string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the state file and initialises every enabled plugin. A missing file
|
||||
// is fine (no plugins configured yet).
|
||||
func (m *Manager) Load() error {
|
||||
// Ready reports whether the settings have been read from the store. While it is
|
||||
// false the Manager knows of no configured plugins and refuses every mutation,
|
||||
// so an unreachable store cannot cause settings to be overwritten or lost.
|
||||
func (m *Manager) Ready() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.loaded
|
||||
}
|
||||
|
||||
// Load reads the settings from the store and initialises every enabled plugin.
|
||||
// An absent document is fine (nothing configured yet); an unreachable store is
|
||||
// not, and leaves the Manager unloaded so a caller can retry.
|
||||
func (m *Manager) Load(ctx context.Context) error {
|
||||
// Read outside the lock: the production store is a network call.
|
||||
data, found, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return classify(err, errNotReady)
|
||||
}
|
||||
|
||||
recs := map[string]*record{}
|
||||
if found {
|
||||
if err := decodeRecords(data, recs); err != nil {
|
||||
// Stay unloaded on purpose. A document we cannot parse must never be
|
||||
// replaced by one built from an empty map — that would turn a read
|
||||
// problem into permanent data loss. Saves refuse until it is fixed.
|
||||
return fmt.Errorf("plugin settings in %s are unreadable: %w", m.store.Describe(), err)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(m.path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := m.decodeLocked(data); err != nil {
|
||||
return err
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// No state file yet — first boot, nothing configured.
|
||||
default:
|
||||
return err
|
||||
// Load is called again by the boot retry, so make it idempotent: the
|
||||
// previous generation of instances must not be left running beside the new.
|
||||
for name, p := range m.live {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
m.records = recs
|
||||
m.loaded = true
|
||||
|
||||
ctx := context.Background()
|
||||
for name, rec := range m.records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
@@ -99,56 +122,38 @@ func (m *Manager) Load() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeLocked parses the state file into m.records. It is deliberately strict
|
||||
// about two shapes that would otherwise take the server down or quietly destroy
|
||||
// state:
|
||||
// decodeRecords parses a settings document into into. It is deliberately strict
|
||||
// about two shapes that would otherwise take the server down:
|
||||
//
|
||||
// - An empty file, or a literal "null", decodes to a nil map. Assigning that
|
||||
// to m.records makes the next save panic with "assignment to entry in nil
|
||||
// map", so both are treated as "nothing configured" instead.
|
||||
// - A null entry ({"toyota": null}) leaves a nil *record that the enable loop
|
||||
// in Load would dereference. Those entries are dropped.
|
||||
//
|
||||
// Content that does not parse at all is moved aside rather than left in place:
|
||||
// the server carries on with no plugins configured, and the next save would
|
||||
// otherwise overwrite the unreadable file and take every setting in it along.
|
||||
func (m *Manager) decodeLocked(data []byte) error {
|
||||
// - An empty document, or a literal "null", decodes to a nil map. Assigning
|
||||
// that straight to m.records made the next save panic with "assignment to
|
||||
// entry in nil map"; both now mean "nothing configured".
|
||||
// - A null entry ({"toyota": null}) leaves a nil *record that Load's enable
|
||||
// loop would dereference. Those entries are dropped.
|
||||
func decodeRecords(data []byte, into map[string]*record) error {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var recs map[string]*record
|
||||
if err := json.Unmarshal(data, &recs); err != nil {
|
||||
backup := m.path + ".corrupt"
|
||||
if renameErr := os.Rename(m.path, backup); renameErr != nil {
|
||||
return fmt.Errorf("plugin state file %s is unreadable (%v) and could not be set aside: %v", m.path, err, renameErr)
|
||||
}
|
||||
return fmt.Errorf("plugin state file %s was unreadable (%v); moved it to %s and started with no plugins configured", m.path, err, backup)
|
||||
return err
|
||||
}
|
||||
for name, rec := range recs {
|
||||
if rec == nil {
|
||||
delete(recs, name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if recs != nil {
|
||||
m.records = recs
|
||||
into[name] = rec
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckWritable reports whether the state file can actually be written, by
|
||||
// creating and removing a temporary file beside it. Worth calling at startup:
|
||||
// an unwritable state directory (a root-owned bind mount under an unprivileged
|
||||
// process, or a volume left over from an image that ran as root) otherwise
|
||||
// stays invisible until a restart brings every plugin back disabled.
|
||||
func (m *Manager) CheckWritable() error {
|
||||
f, err := os.CreateTemp(filepath.Dir(m.path), ".plugins-writecheck-*")
|
||||
// encodeRecords renders the settings document written to the store.
|
||||
func encodeRecords(recs map[string]*record) ([]byte, error) {
|
||||
data, err := json.MarshalIndent(recs, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
return nil, fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
name := f.Name()
|
||||
_ = f.Close()
|
||||
_ = os.Remove(name)
|
||||
return nil
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// construct builds a plugin instance from a builtin factory or an external record.
|
||||
@@ -236,6 +241,11 @@ func (m *Manager) Get(name string) (View, bool) {
|
||||
func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) {
|
||||
m.mu.Lock()
|
||||
|
||||
if !m.loaded {
|
||||
m.mu.Unlock()
|
||||
return View{}, errNotReady
|
||||
}
|
||||
|
||||
_, isBuiltin := m.factories[name]
|
||||
rec := m.records[name]
|
||||
if !isBuiltin && (rec == nil || rec.Kind != KindExternal) {
|
||||
@@ -282,11 +292,11 @@ func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incomin
|
||||
prevEnabled, prevConfig := rec.Enabled, rec.Config
|
||||
rec.Enabled = enabled
|
||||
rec.Config = merged
|
||||
if err := m.persistLocked(); err != nil {
|
||||
// Roll back, so the panel shows what is actually on disk. Keeping the
|
||||
// change in memory is what made an unwritable state file look like a
|
||||
// successful save — right up until the next restart brought it back
|
||||
// disabled.
|
||||
if err := m.saveLocked(ctx); err != nil {
|
||||
// Roll back, so the panel shows what the store actually holds. Keeping
|
||||
// the change in memory is what made a failed save look like a
|
||||
// successful one — right up until the next restart brought every
|
||||
// plugin back disabled.
|
||||
if isNew {
|
||||
delete(m.records, name)
|
||||
} else {
|
||||
@@ -320,7 +330,7 @@ func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incomin
|
||||
|
||||
// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the
|
||||
// "add a plugin without a rebuild" path. It starts disabled.
|
||||
func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
func (m *Manager) RegisterExternal(ctx context.Context, name, baseURL, provider string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if name == "" || baseURL == "" {
|
||||
@@ -332,6 +342,9 @@ func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.loaded {
|
||||
return errNotReady
|
||||
}
|
||||
if _, dup := m.factories[name]; dup {
|
||||
return errors.New("a builtin plugin already uses that name")
|
||||
}
|
||||
@@ -339,7 +352,7 @@ func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
return errors.New("a plugin with that name already exists")
|
||||
}
|
||||
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
|
||||
if err := m.persistLocked(); err != nil {
|
||||
if err := m.saveLocked(ctx); err != nil {
|
||||
delete(m.records, name)
|
||||
return err
|
||||
}
|
||||
@@ -350,6 +363,9 @@ func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.loaded {
|
||||
return errNotReady
|
||||
}
|
||||
rec := m.records[name]
|
||||
if rec == nil || rec.Kind != KindExternal {
|
||||
return errors.New("only external plugins can be removed")
|
||||
@@ -357,7 +373,7 @@ func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||
// Persist before tearing the instance down, so a failed write leaves a
|
||||
// still-registered plugin still running rather than a half-removed one.
|
||||
delete(m.records, name)
|
||||
if err := m.persistLocked(); err != nil {
|
||||
if err := m.saveLocked(ctx); err != nil {
|
||||
m.records[name] = rec
|
||||
return err
|
||||
}
|
||||
@@ -529,59 +545,48 @@ func (m *Manager) Shutdown(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// persistLocked writes the state file. Caller must hold m.mu.
|
||||
// saveLocked writes the current records to the store. Caller must hold m.mu.
|
||||
//
|
||||
// The write goes to a temporary file in the same directory, is flushed, and is
|
||||
// then renamed over the target. A truncating write in place can be interrupted
|
||||
// (crash, container stop, full disk) and leave a half-written plugins.json that
|
||||
// fails to parse on the next boot — which surfaces as every plugin coming back
|
||||
// disabled. Every failure is wrapped in errPersist so callers can tell "your
|
||||
// change was not saved" apart from "saved, but the plugin failed to start".
|
||||
func (m *Manager) persistLocked() error {
|
||||
data, err := json.MarshalIndent(m.records, "", " ")
|
||||
// Every failure is classified as errPersist here rather than relying on the
|
||||
// Store to have done it. That distinction drives the HTTP status: an
|
||||
// unclassified save failure would fall through to the "saved, but the plugin
|
||||
// failed to start" branch and be reported as a 200, which is precisely how a
|
||||
// failed save used to masquerade as a successful one.
|
||||
func (m *Manager) saveLocked(ctx context.Context) error {
|
||||
data, err := encodeRecords(m.records)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(m.path), ".plugins-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
if err := m.store.Save(ctx, data); err != nil {
|
||||
return classify(err, errPersist)
|
||||
}
|
||||
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, m.path); err != nil {
|
||||
return fmt.Errorf("%w: %v", errPersist, err)
|
||||
}
|
||||
tmpName = "" // renamed into place; nothing left to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// classify tags err with sentinel unless it already carries it, so callers can
|
||||
// branch on IsPersist/IsNotReady whatever a Store returned.
|
||||
func classify(err error, sentinel error) error {
|
||||
if errors.Is(err, sentinel) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %v", sentinel, err)
|
||||
}
|
||||
|
||||
var (
|
||||
errUnknown = errors.New("unknown plugin")
|
||||
errPersist = errors.New("plugin state could not be saved")
|
||||
errUnknown = errors.New("unknown plugin")
|
||||
errPersist = errors.New("plugin settings could not be saved")
|
||||
errNotReady = errors.New("plugin settings are not loaded yet")
|
||||
)
|
||||
|
||||
// IsUnknown reports whether err came from addressing a plugin that doesn't exist.
|
||||
func IsUnknown(err error) bool { return errors.Is(err, errUnknown) }
|
||||
|
||||
// IsPersist reports whether err means the change never reached the state file.
|
||||
// Such a change has been rolled back in memory: it must be reported as a
|
||||
// failure, or the caller sees a save that silently vanishes on the next restart.
|
||||
// IsPersist reports whether err means the change never reached the store. Such a
|
||||
// change has been rolled back in memory: it must be reported as a failure, or
|
||||
// the caller sees a save that silently vanishes on the next restart.
|
||||
func IsPersist(err error) bool { return errors.Is(err, errPersist) }
|
||||
|
||||
// IsNotReady reports whether err means the settings have not been read yet —
|
||||
// the store was unreachable at boot and is still being retried. Callers should
|
||||
// answer 503 rather than present the plugin list as empty.
|
||||
func IsNotReady(err error) bool { return errors.Is(err, errNotReady) }
|
||||
|
||||
Reference in New Issue
Block a user