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") // errNoCollection is a not-ready that waiting alone will never resolve: the // collection the settings live in does not exist. It wraps errNotReady, so // the safety behaviour (refuse every write) is unchanged; the extra tag only // lets the caller fix the cause instead of retrying forever. errNoCollection = fmt.Errorf("%w: the settings collection does not exist", errNotReady) ) // 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) } // IsMissingCollection reports whether err means the settings collection does not // exist. It implies IsNotReady, and narrows it: retrying cannot help, so the // caller should create the collection and read again. func IsMissingCollection(err error) bool { return errors.Is(err, errNoCollection) }