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>
593 lines
18 KiB
Go
593 lines
18 KiB
Go
package plugins
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// secretMask is what a set secret value is echoed back as. On save, a field that
|
||
// still equals the mask is left unchanged (mirrors the pb-config password flow).
|
||
const secretMask = "••••••••"
|
||
|
||
// record is the persisted state for one plugin. For builtins, Kind/BaseURL are
|
||
// omitted (the descriptor comes from the registry); external plugins set them.
|
||
type record struct {
|
||
Kind string `json:"kind,omitempty"`
|
||
BaseURL string `json:"baseURL,omitempty"`
|
||
Provider string `json:"provider,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
Config map[string]string `json:"config,omitempty"`
|
||
}
|
||
|
||
// View is the plugin shape returned to the panel (secrets masked).
|
||
type View struct {
|
||
Descriptor
|
||
Enabled bool `json:"enabled"`
|
||
Config map[string]string `json:"config"`
|
||
BaseURL string `json:"baseURL,omitempty"`
|
||
Health *Health `json:"health,omitempty"`
|
||
}
|
||
|
||
// Manager owns the plugin registry, persisted state, and live instances.
|
||
type Manager struct {
|
||
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
|
||
health map[string]*Health
|
||
client *http.Client
|
||
}
|
||
|
||
// 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{
|
||
store: store,
|
||
factories: builtinFactories(),
|
||
records: map[string]*record{},
|
||
live: map[string]Plugin{},
|
||
health: map[string]*Health{},
|
||
client: &http.Client{Timeout: 12 * time.Second},
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
|
||
// 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
|
||
|
||
for name, rec := range m.records {
|
||
if !rec.Enabled {
|
||
continue
|
||
}
|
||
p := construct(name, m.factories[name], rec)
|
||
if p == nil {
|
||
log.Printf("plugins: cannot construct %q (unknown builtin?)", name)
|
||
continue
|
||
}
|
||
if err := p.Init(ctx, rec.Config); err != nil {
|
||
log.Printf("plugins: init %q failed: %v", name, err)
|
||
continue
|
||
}
|
||
m.live[name] = p
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// decodeRecords parses a settings document into into. It is deliberately strict
|
||
// about two shapes that would otherwise take the server down:
|
||
//
|
||
// - 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 {
|
||
return err
|
||
}
|
||
for name, rec := range recs {
|
||
if rec == nil {
|
||
continue
|
||
}
|
||
into[name] = rec
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 nil, fmt.Errorf("%w: %v", errPersist, err)
|
||
}
|
||
return append(data, '\n'), nil
|
||
}
|
||
|
||
// construct builds a plugin instance from a builtin factory or an external record.
|
||
func construct(name string, f Factory, rec *record) Plugin {
|
||
if f != nil {
|
||
return f()
|
||
}
|
||
if rec != nil && rec.Kind == KindExternal {
|
||
return newExternalPlugin(name, rec.BaseURL, rec.Provider)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// descriptorFor returns a plugin's descriptor without needing a live instance.
|
||
func (m *Manager) descriptorFor(name string, rec *record) Descriptor {
|
||
if p := m.live[name]; p != nil {
|
||
return p.Descriptor()
|
||
}
|
||
if f := m.factories[name]; f != nil {
|
||
return f().Descriptor()
|
||
}
|
||
if rec != nil && rec.Kind == KindExternal {
|
||
return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor()
|
||
}
|
||
return Descriptor{Name: name}
|
||
}
|
||
|
||
// maskConfig echoes config back with secret fields masked when set.
|
||
func maskConfig(d Descriptor, cfg map[string]string) map[string]string {
|
||
out := map[string]string{}
|
||
for k, v := range cfg {
|
||
out[k] = v
|
||
}
|
||
for _, f := range d.ConfigFields {
|
||
if f.Secret && out[f.Key] != "" {
|
||
out[f.Key] = secretMask
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// List returns every known plugin (registry ∪ persisted), sorted by name.
|
||
func (m *Manager) List() []View {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
names := map[string]bool{}
|
||
for n := range m.factories {
|
||
names[n] = true
|
||
}
|
||
for n := range m.records {
|
||
names[n] = true
|
||
}
|
||
|
||
out := make([]View, 0, len(names))
|
||
for name := range names {
|
||
rec := m.records[name]
|
||
d := m.descriptorFor(name, rec)
|
||
v := View{Descriptor: d, Health: m.health[name]}
|
||
if rec != nil {
|
||
v.Enabled = rec.Enabled
|
||
v.BaseURL = rec.BaseURL
|
||
v.Config = maskConfig(d, rec.Config)
|
||
} else {
|
||
v.Config = map[string]string{}
|
||
}
|
||
out = append(out, v)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|
||
|
||
// Get returns a single plugin view (ok=false when unknown).
|
||
func (m *Manager) Get(name string) (View, bool) {
|
||
for _, v := range m.List() {
|
||
if v.Name == name {
|
||
return v, true
|
||
}
|
||
}
|
||
return View{}, false
|
||
}
|
||
|
||
// Upsert enables/disables a plugin and merges its config, then (re)initialises or
|
||
// shuts down the live instance to match. Secrets left at the mask are preserved.
|
||
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) {
|
||
m.mu.Unlock()
|
||
return View{}, errUnknown
|
||
}
|
||
isNew := rec == nil
|
||
if isNew {
|
||
rec = &record{}
|
||
m.records[name] = rec
|
||
}
|
||
|
||
d := m.descriptorFor(name, rec)
|
||
merged := map[string]string{}
|
||
for k, v := range rec.Config {
|
||
merged[k] = v
|
||
}
|
||
// Apply incoming values, honouring the secret-mask keep-current rule.
|
||
secretKeys := map[string]bool{}
|
||
for _, f := range d.ConfigFields {
|
||
if f.Secret {
|
||
secretKeys[f.Key] = true
|
||
}
|
||
}
|
||
for k, v := range incoming {
|
||
if secretKeys[k] && v == secretMask {
|
||
continue // keep existing secret
|
||
}
|
||
merged[k] = strings.TrimSpace(v)
|
||
}
|
||
// Validate required fields when enabling.
|
||
if enabled {
|
||
for _, f := range d.ConfigFields {
|
||
if f.Required && merged[f.Key] == "" {
|
||
if isNew {
|
||
delete(m.records, name) // don't leave a blank record behind
|
||
}
|
||
m.mu.Unlock()
|
||
return View{}, errors.New("missing required setting: " + f.Label)
|
||
}
|
||
}
|
||
}
|
||
|
||
prevEnabled, prevConfig := rec.Enabled, rec.Config
|
||
rec.Enabled = enabled
|
||
rec.Config = merged
|
||
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 {
|
||
rec.Enabled, rec.Config = prevEnabled, prevConfig
|
||
}
|
||
m.mu.Unlock()
|
||
return View{}, err
|
||
}
|
||
|
||
// Reconcile the live instance.
|
||
if old := m.live[name]; old != nil {
|
||
_ = old.Shutdown(ctx)
|
||
delete(m.live, name)
|
||
}
|
||
var initErr error
|
||
if enabled {
|
||
p := construct(name, m.factories[name], rec)
|
||
if p != nil {
|
||
if err := p.Init(ctx, merged); err != nil {
|
||
initErr = err
|
||
} else {
|
||
m.live[name] = p
|
||
}
|
||
}
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
v, _ := m.Get(name)
|
||
return v, initErr
|
||
}
|
||
|
||
// 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(ctx context.Context, name, baseURL, provider string) error {
|
||
name = strings.TrimSpace(name)
|
||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||
if name == "" || baseURL == "" {
|
||
return errors.New("name and baseURL are required")
|
||
}
|
||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||
baseURL = "http://" + baseURL
|
||
}
|
||
|
||
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")
|
||
}
|
||
if _, dup := m.records[name]; dup {
|
||
return errors.New("a plugin with that name already exists")
|
||
}
|
||
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
|
||
if err := m.saveLocked(ctx); err != nil {
|
||
delete(m.records, name)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Remove deletes an external plugin registration. Builtins can only be disabled.
|
||
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")
|
||
}
|
||
// 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.saveLocked(ctx); err != nil {
|
||
m.records[name] = rec
|
||
return err
|
||
}
|
||
if p := m.live[name]; p != nil {
|
||
_ = p.Shutdown(ctx)
|
||
delete(m.live, name)
|
||
}
|
||
delete(m.health, name)
|
||
return nil
|
||
}
|
||
|
||
// HealthCheck probes a plugin now, building a transient instance if it is not
|
||
// currently live (so disabled plugins can still be tested). Result is cached.
|
||
func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) {
|
||
m.mu.Lock()
|
||
p := m.live[name]
|
||
transient := false
|
||
var cfg map[string]string
|
||
if p == nil {
|
||
rec := m.records[name]
|
||
if rec != nil {
|
||
cfg = rec.Config
|
||
}
|
||
p = construct(name, m.factories[name], rec)
|
||
transient = true
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
if p == nil {
|
||
return Health{}, errUnknown
|
||
}
|
||
if transient {
|
||
_ = p.Init(ctx, cfg)
|
||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||
}
|
||
h := p.HealthCheck(ctx)
|
||
|
||
m.mu.Lock()
|
||
hc := h
|
||
m.health[name] = &hc
|
||
m.mu.Unlock()
|
||
return h, nil
|
||
}
|
||
|
||
// HealthCheckWith probes a plugin against a caller-resolved config rather than
|
||
// the stored global config. It builds a transient instance, Inits it with cfg,
|
||
// probes, and tears it down — so a per-user cascade (see internal/api/
|
||
// integrations.go) can health-check under the credentials in force for that
|
||
// caller without disturbing the global instance or its cached health.
|
||
func (m *Manager) HealthCheckWith(ctx context.Context, name string, cfg map[string]string) (Health, error) {
|
||
m.mu.Lock()
|
||
rec := m.records[name]
|
||
p := construct(name, m.factories[name], rec)
|
||
m.mu.Unlock()
|
||
|
||
if p == nil {
|
||
return Health{}, errUnknown
|
||
}
|
||
_ = p.Init(ctx, cfg)
|
||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||
return p.HealthCheck(ctx), nil
|
||
}
|
||
|
||
// InvokeWith runs a capability against a caller-resolved config. Like
|
||
// HealthCheckWith, it uses a transient instance Inited with cfg so per-user
|
||
// credentials drive the call. Returns the plugin's raw JSON result.
|
||
func (m *Manager) InvokeWith(ctx context.Context, name string, cfg map[string]string, action string, payload json.RawMessage) (json.RawMessage, error) {
|
||
m.mu.Lock()
|
||
rec := m.records[name]
|
||
p := construct(name, m.factories[name], rec)
|
||
m.mu.Unlock()
|
||
|
||
if p == nil {
|
||
return nil, errUnknown
|
||
}
|
||
_ = p.Init(ctx, cfg)
|
||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||
return p.Invoke(ctx, action, payload)
|
||
}
|
||
|
||
// BatchCall is one capability invocation inside an InvokeBatchWith request.
|
||
type BatchCall struct {
|
||
ID string // caller-chosen id, echoed back on the result
|
||
Action string // capability id
|
||
Params json.RawMessage // action params; may be nil
|
||
}
|
||
|
||
// BatchResult is the outcome of one BatchCall. Exactly one of Result/Err is set.
|
||
type BatchResult struct {
|
||
ID string
|
||
Result json.RawMessage
|
||
Err error
|
||
}
|
||
|
||
// batchConcurrency caps how many calls of one batch are in flight at once, so a
|
||
// snapshot of a whole vehicle doesn't arrive at the upstream as a burst.
|
||
const batchConcurrency = 4
|
||
|
||
// InvokeBatchWith runs several capabilities against one caller-resolved config,
|
||
// sharing a single transient instance. A connector that authenticates lazily
|
||
// (Toyota's OAuth login on first request) would otherwise repeat that login for
|
||
// every action, because InvokeWith builds and tears down an instance per call;
|
||
// sharing the instance logs in once for the whole batch.
|
||
//
|
||
// Calls run concurrently, so a plugin's Invoke must be safe for concurrent use —
|
||
// which the contract already implies, since the live instance is shared by every
|
||
// HTTP request. Results come back in request order, each carrying its own error;
|
||
// a non-nil error return means the batch never started (unknown plugin).
|
||
func (m *Manager) InvokeBatchWith(ctx context.Context, name string, cfg map[string]string, calls []BatchCall) ([]BatchResult, error) {
|
||
m.mu.Lock()
|
||
rec := m.records[name]
|
||
p := construct(name, m.factories[name], rec)
|
||
m.mu.Unlock()
|
||
|
||
if p == nil {
|
||
return nil, errUnknown
|
||
}
|
||
_ = p.Init(ctx, cfg)
|
||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||
|
||
out := make([]BatchResult, len(calls))
|
||
sem := make(chan struct{}, batchConcurrency)
|
||
var wg sync.WaitGroup
|
||
for i, c := range calls {
|
||
wg.Add(1)
|
||
go func(i int, c BatchCall) {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
res, err := p.Invoke(ctx, c.Action, c.Params)
|
||
out[i] = BatchResult{ID: c.ID, Result: res, Err: err}
|
||
}(i, c)
|
||
}
|
||
wg.Wait()
|
||
return out, nil
|
||
}
|
||
|
||
// RawConfig returns a copy of a plugin's stored (global) config and its enabled
|
||
// flag. ok is false for an unknown plugin. This is the top layer (L1) of the
|
||
// per-user cascade: the config a superadmin set in the panel, which lower layers
|
||
// inherit blank fields from. Secrets are returned in clear — callers must mask
|
||
// before returning anything to a client.
|
||
func (m *Manager) RawConfig(name string) (cfg map[string]string, enabled, ok bool) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
_, isBuiltin := m.factories[name]
|
||
rec := m.records[name]
|
||
if !isBuiltin && rec == nil {
|
||
return nil, false, false
|
||
}
|
||
out := map[string]string{}
|
||
if rec != nil {
|
||
for k, v := range rec.Config {
|
||
out[k] = v
|
||
}
|
||
enabled = rec.Enabled
|
||
}
|
||
return out, enabled, true
|
||
}
|
||
|
||
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
|
||
func (m *Manager) Shutdown(ctx context.Context) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
for name, p := range m.live {
|
||
_ = p.Shutdown(ctx)
|
||
delete(m.live, name)
|
||
}
|
||
}
|
||
|
||
// saveLocked writes the current records to the store. Caller must hold m.mu.
|
||
//
|
||
// 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 err
|
||
}
|
||
if err := m.store.Save(ctx, data); err != nil {
|
||
return classify(err, errPersist)
|
||
}
|
||
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 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 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) }
|