Files
Cyrene_v2/internal/capability/plugin/manager.go
T
AskaEth 2d21aa553e feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)
- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
2026-10-06 09:03:57 +08:00

467 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package plugin manages runtime-installed external tool providers. In v1 the
// only installable kind is an MCP server (DevDocs/design/09 插件生态 §1): a
// compiled Go plugin cannot be hot-loaded into a single static binary, so a
// runtime "install" is an external MCP server whose config is persisted and
// whose discovered tools are hot-registered into the shared capability registry.
//
// The manager owns the live *mcp.Registry of every installed server so the
// server can be refreshed (reinstall under the same name) or uninstalled, and
// the config can be restored on the next boot. A connect/discover failure never
// persists a half-installed plugin.
package plugin
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
const (
// connectTimeout bounds one server's connect + discover handshake so an
// unreachable server fails the install instead of hanging the request.
connectTimeout = 15 * time.Second
// mcpCapabilityPrefix names the KindMCP capability a server surfaces as, so
// the admin plugin list enumerates the server itself as well as its tools.
mcpCapabilityPrefix = "mcp:"
)
// Installed is one plugin as reported to the admin API: its persisted config,
// whether a live server currently backs it, and how many tools it registered.
type Installed struct {
// Config is the persisted plugin config (the caller masks it before wire).
Config runtimeconfig.PluginConfig
// Live reports whether the MCP server is connected and its tools registered.
Live bool
// ToolCount is the number of tools the live server registered (0 when not
// live).
ToolCount int
}
// server is a live installed MCP server.
type server struct {
cfg runtimeconfig.PluginConfig
reg *mcp.Registry
toolCount int
}
// Manager installs, refreshes, lists and uninstalls runtime plugins over one
// capability registry and one plugins.json path. It is safe for concurrent use.
type Manager struct {
reg *registry.Registry
path string
log *slog.Logger
mu sync.Mutex
servers map[string]*server
// panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。
// 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把
// 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。
panelKeys map[string]string
// panels 是可选的面板声明中枢(§5.2.1)。为 nil 时管理器完全不知道面板,
// 既有行为逐字不变(未接线的进程/测试就是这样)。
panels *Panels
}
// NewManager returns a Manager backed by reg and the plugins.json at path.
func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
return &Manager{
reg: reg,
path: path,
log: log,
servers: make(map[string]*server),
panelKeys: make(map[string]string),
}
}
// AttachPanels 把面板声明中枢接到管理器上(design/09 §5.2.1「插件管理器 = 唯一
// 中枢」)。必须在插件被安装**之前**接好,否则启动期恢复的那些插件不会留下目录键。
// 传 nil 等于不接线(合法:未接线的进程行为与接线前逐字一致)。
func (m *Manager) AttachPanels(p *Panels) {
m.mu.Lock()
m.panels = p
m.mu.Unlock()
}
// Panels 返回已接线的面板中枢;未接线时返回 nil(网关据此诚实作答 501,
// 而不是回一份空面板列表假装「没有插件的页面」)。
func (m *Manager) Panels() *Panels {
if m == nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
return m.panels
}
// Install connects cfg's MCP server, registers its tools, persists the config
// and holds the live registry. Installing a name that already exists refreshes
// it: the previous server is unregistered first, and if the new one fails to
// connect the previous one is restored (the on-disk config is never changed on a
// failure).
func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (Installed, error) {
return m.install(ctx, cfg, true, PanelChangeInstalled)
}
// install is Install with the persist step optional (Restore reuses it without
// rewriting the file it just read). changeReason labels the panel change pushed
// when the plugin becomes live (install vs enable).
func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) {
cfg = cfg.Normalize()
if err := cfg.Validate(); err != nil {
return Installed{}, err
}
prev := m.take(cfg.Name)
cfg = inheritIdentity(cfg, prev)
if strings.TrimSpace(cfg.CreatedAt) == "" {
cfg.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if prev != nil {
m.stop(ctx, prev)
}
srv, err := m.start(ctx, cfg)
if err != nil {
m.restore(prev)
return Installed{}, err
}
if persist {
if err := m.persistUpsert(cfg); err != nil {
m.stop(ctx, srv)
m.restore(prev)
return Installed{}, err
}
}
m.put(srv)
m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount)
m.markInstalled(cfg, changeReason)
return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil
}
// Uninstall removes the plugin matching name: it unregisters the live server's
// tools and closes it, then deletes the persisted config. It reports whether
// anything matched, so the caller can fall back to a plain capability removal.
func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) {
srv := m.takeAny(name)
deleted, err := m.persistDelete(name)
if srv != nil {
m.stop(ctx, srv)
}
if err != nil {
return srv != nil, fmt.Errorf("plugin %q: %w", name, err)
}
removed := srv != nil || deleted
if removed {
panelName := name
if srv != nil {
panelName = srv.cfg.Name
}
m.forgetPanels(panelName)
m.log.Info("plugin uninstalled", "name", name)
}
return removed, nil
}
// List returns every persisted plugin (in file order) followed by any live
// server not in the file, each annotated with its live state and tool count.
func (m *Manager) List() []Installed {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load skipped", "path", m.path, "error", err)
cfgs = nil
}
out := make([]Installed, 0, len(cfgs))
seen := make(map[string]bool, len(cfgs))
m.mu.Lock()
defer m.mu.Unlock()
for _, cfg := range cfgs {
item := Installed{Config: cfg}
if srv, ok := m.servers[cfg.Name]; ok {
item.Live = true
item.ToolCount = srv.toolCount
}
out = append(out, item)
seen[cfg.Name] = true
}
for name, srv := range m.servers {
if seen[name] {
continue
}
out = append(out, Installed{Config: srv.cfg, Live: true, ToolCount: srv.toolCount})
}
return out
}
// Restore installs every persisted plugin. A server that is unreachable at boot
// is logged and left in plugins.json (so the owner can retry or uninstall it);
// boot never fails because an external server is down.
func (m *Manager) Restore(ctx context.Context) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load skipped", "path", m.path, "error", err)
return
}
for _, cfg := range cfgs {
if _, err := m.install(ctx, cfg, false, PanelChangeInstalled); err != nil {
m.log.Warn("plugin restore skipped", "name", cfg.Name, "error", err)
}
}
}
// Close unregisters every live server without touching plugins.json.
func (m *Manager) Close(ctx context.Context) {
m.mu.Lock()
live := make([]*server, 0, len(m.servers))
for _, srv := range m.servers {
live = append(live, srv)
}
m.servers = make(map[string]*server)
m.mu.Unlock()
for _, srv := range live {
m.stop(ctx, srv)
}
}
// SetEnabled 启用或停用一个已安装插件。
//
// 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的
// 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json**
// —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并
// 让入口回来。两条路都会推一次 `panels_changed`。
//
// ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该
// 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此
// **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给
// plugins.json 加 `enabled` 字段(并让 Restore 读它)。
//
// 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。
//
// 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置
// (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.takeAny(name)
_, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if srv != nil {
m.stop(ctx, srv)
}
m.setPanelsEnabled(name, false)
m.log.Info("plugin disabled", "name", name)
return nil
}
// enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。
// 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
if m.liveServer(name) != nil {
return nil
}
cfg, ok := m.persistedConfig(name)
if !ok {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil {
return err
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
// start connects cfg, registers its tools and surfaces its KindMCP capability.
// On a registration failure it cleans up and returns the error.
func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) {
mreg := mcp.NewRegistry(m.reg, mcpConfigOf(cfg), mcp.WithLogger(m.log))
connectCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
if err := mreg.Register(connectCtx); err != nil {
_ = mreg.Unregister(context.Background())
return nil, fmt.Errorf("plugin %q: %w", cfg.Name, err)
}
names := mreg.ToolNames()
err := m.reg.Register(registry.Capability{
Name: mcpCapabilityPrefix + cfg.Name,
Kind: registry.KindMCP,
Description: "MCP server " + cfg.Name,
Value: mreg,
})
if err != nil && !errors.Is(err, registry.ErrDuplicate) {
m.log.Warn("plugin capability register skipped", "name", cfg.Name, "error", err)
}
return &server{cfg: cfg, reg: mreg, toolCount: len(names)}, nil
}
// stop unregisters a live server's tools and closes it, then drops its KindMCP
// capability. Errors are logged, never fatal: uninstall must still remove the
// config.
func (m *Manager) stop(ctx context.Context, srv *server) {
if srv == nil {
return
}
if err := srv.reg.Unregister(ctx); err != nil {
m.log.Warn("plugin unregister", "name", srv.cfg.Name, "error", err)
}
if err := m.reg.Remove(mcpCapabilityPrefix + srv.cfg.Name); err != nil && !errors.Is(err, registry.ErrNotFound) {
m.log.Warn("plugin capability remove", "name", srv.cfg.Name, "error", err)
}
}
// restore re-installs a server that a failed refresh displaced, so a failed
// refresh never leaves the registry without a previously working server.
func (m *Manager) restore(prev *server) {
if prev == nil {
return
}
srv, err := m.start(context.Background(), prev.cfg)
if err != nil {
m.log.Warn("plugin rollback failed", "name", prev.cfg.Name, "error", err)
return
}
m.put(srv)
}
// take removes and returns the live server named name, or nil.
func (m *Manager) take(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
srv := m.servers[name]
delete(m.servers, name)
return srv
}
// put records srv as the live server for its name.
func (m *Manager) put(srv *server) {
m.mu.Lock()
defer m.mu.Unlock()
m.servers[srv.cfg.Name] = srv
}
// persistUpsert writes cfg into plugins.json (upsert by name).
func (m *Manager) persistUpsert(cfg runtimeconfig.PluginConfig) error {
cur, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
return fmt.Errorf("load plugins.json: %w", err)
}
if err := runtimeconfig.SavePlugins(m.path, runtimeconfig.UpsertPlugin(cur, cfg)); err != nil {
return fmt.Errorf("save plugins.json: %w", err)
}
return nil
}
// persistDelete removes name (by name or id) from plugins.json, reporting
// whether an entry was removed. A missing file is not an error.
func (m *Manager) persistDelete(name string) (bool, error) {
cur, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
return false, fmt.Errorf("load plugins.json: %w", err)
}
out, removed := runtimeconfig.DeletePlugin(cur, name)
if !removed {
return false, nil
}
if err := runtimeconfig.SavePlugins(m.path, out); err != nil {
return false, fmt.Errorf("save plugins.json: %w", err)
}
return true, nil
}
// mcpConfigOf maps a plugin config to the MCP client config.
func mcpConfigOf(cfg runtimeconfig.PluginConfig) mcp.MCPConfig {
return mcp.MCPConfig{
Name: cfg.Name,
Command: cfg.Command,
Args: cfg.Args,
URL: cfg.Endpoint,
TransportType: cfg.Transport,
APIKey: cfg.APIKey,
Env: cfg.Env,
Enabled: true,
}
}
// inheritIdentity keeps a refresh's id and install time when the request omits
// them, so a reinstall updates a plugin rather than minting a new identity.
func inheritIdentity(cfg runtimeconfig.PluginConfig, prev *server) runtimeconfig.PluginConfig {
if prev == nil {
return cfg
}
if strings.TrimSpace(cfg.ID) == "" {
cfg.ID = prev.cfg.ID
}
if strings.TrimSpace(cfg.CreatedAt) == "" {
cfg.CreatedAt = prev.cfg.CreatedAt
}
return cfg
}