This commit is contained in:
2026-06-03 14:09:58 +08:00
parent ddd9d43f9d
commit 2b456dbb07
46 changed files with 7145 additions and 1422 deletions

View File

@@ -0,0 +1,91 @@
package kernel
import (
"context"
"fmt"
"net"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
)
// bytesCodec 是一个恒等编解码器:把 []byte 原样当作 wire 字节,不做任何
// proto 序列化。Broker 用它经 conn.Invoke 转发已序列化的 payload从而内核
// 无需 import 任何插件的 proto 类型——对插件服务完全无关。
type bytesCodec struct{}
func (bytesCodec) Marshal(v interface{}) ([]byte, error) {
b, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("bytesCodec: 期望 []byte得到 %T", v)
}
return b, nil
}
func (bytesCodec) Unmarshal(data []byte, v interface{}) error {
p, ok := v.(*[]byte)
if !ok {
return fmt.Errorf("bytesCodec: 期望 *[]byte得到 %T", v)
}
*p = append([]byte(nil), data...)
return nil
}
func (bytesCodec) Name() string { return "goaiapi.bytes" }
func init() {
encoding.RegisterCodec(bytesCodec{})
}
// BrokerServer 实现 InvocationBroker把 Invoke 路由到目标插件的 gRPC 连接,
// 原样转发 proto-wire 字节。
type BrokerServer struct {
contractv1.UnimplementedInvocationBrokerServer
reg *Registry
}
// NewBrokerServer 构造一个绑定到 reg 的 broker。
func NewBrokerServer(reg *Registry) *BrokerServer {
return &BrokerServer{reg: reg}
}
// Invoke 把 (target, method, payload) 转发到目标插件的连接,返回响应字节。
// 失败时把错误写进 InvokeResponse.Error 而非作为 gRPC error 返回,便于调用
// 方区分「broker 层失败」与「目标方法返回的业务错误」。
func (b *BrokerServer) Invoke(ctx context.Context, req *contractv1.InvokeRequest) (*contractv1.InvokeResponse, error) {
info := b.reg.Get(req.TargetPlugin)
if info == nil {
return &contractv1.InvokeResponse{Error: "目标插件未找到: " + req.TargetPlugin}, nil
}
var respData []byte
err := info.Conn.Invoke(ctx, req.Method, req.Payload, &respData, grpc.ForceCodec(bytesCodec{}))
if err != nil {
return &contractv1.InvokeResponse{Error: err.Error()}, nil
}
return &contractv1.InvokeResponse{Result: respData}, nil
}
// ListPlugins 返回所有已注册插件的摘要。
func (b *BrokerServer) ListPlugins(ctx context.Context, _ *contractv1.ListPluginsRequest) (*contractv1.ListPluginsResponse, error) {
infos := b.reg.List()
out := &contractv1.ListPluginsResponse{Plugins: make([]*contractv1.PluginInfo, 0, len(infos))}
for _, info := range infos {
out.Plugins = append(out.Plugins, info.toProtoInfo())
}
return out, nil
}
// StartBrokerServer 在 addr 上起一个 gRPC server 托管 InvocationBroker。
// addr 用 "127.0.0.1:0" 可让系统分配端口;返回的 listener 的 Addr() 即实际
// 地址,供 main 注入给被加载的插件。
func StartBrokerServer(addr string, reg *Registry) (*grpc.Server, net.Listener, error) {
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, nil, fmt.Errorf("broker 监听 %s: %w", addr, err)
}
srv := grpc.NewServer()
contractv1.RegisterInvocationBrokerServer(srv, NewBrokerServer(reg))
go srv.Serve(lis)
return srv, lis, nil
}

View File

@@ -0,0 +1,140 @@
package kernel
import (
"context"
"encoding/json"
"net/http"
"time"
)
// Controller 是动态加载/更新的控制面,绑定在独立内核控制端口(与只读
// dashboard 物理隔离)。它在请求路径上调 Registry 的 Load/Unload/Upgrade。
type Controller struct {
reg *Registry
brokerAddr string
drain time.Duration
}
// NewController 构造一个控制面brokerAddr 用于注入给新加载的插件drain 是
// 热升级时旧进程的排空等待时长。
func NewController(reg *Registry, brokerAddr string, drain time.Duration) *Controller {
return &Controller{reg: reg, brokerAddr: brokerAddr, drain: drain}
}
// Mux 组装控制面路由。单独抽出便于测试与挂载。
func (c *Controller) Mux() http.Handler {
mux := http.NewServeMux()
// 列出所有已加载插件
mux.HandleFunc("GET /api/plugins", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, c.listView())
})
// 单个插件详情
mux.HandleFunc("GET /api/plugins/{name}", func(w http.ResponseWriter, r *http.Request) {
info := c.reg.Get(r.PathValue("name"))
if info == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "插件未注册"})
return
}
writeJSON(w, http.StatusOK, pluginViewOf(info))
})
// 加载注册一个插件二进制
mux.HandleFunc("POST /api/plugins/load", func(w http.ResponseWriter, r *http.Request) {
var body struct {
BinaryPath string `json:"binary_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BinaryPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "需要 binary_path"})
return
}
info, err := c.reg.Load(context.Background(), body.BinaryPath, c.brokerAddr)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, pluginViewOf(info))
})
// 卸载一个插件
mux.HandleFunc("POST /api/plugins/unload", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "需要 name"})
return
}
if err := c.reg.Unload(body.Name); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"unloaded": body.Name})
})
// 热升级一个已注册插件到新二进制
mux.HandleFunc("POST /api/plugins/upgrade", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
BinaryPath string `json:"binary_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" || body.BinaryPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "需要 name 与 binary_path"})
return
}
info, err := c.reg.Upgrade(context.Background(), body.Name, body.BinaryPath, c.brokerAddr, c.drain)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, pluginViewOf(info))
})
return mux
}
// StartControlServer 在 addr 上起控制面 HTTP 服务(阻塞,建议 go 调用)。
func (c *Controller) StartControlServer(addr string) error {
return http.ListenAndServe(addr, c.Mux())
}
// pluginView 是单个插件对外的 JSON 视图(不暴露 conn/client 等内部句柄)。
type pluginView struct {
Name string `json:"name"`
Version string `json:"version"`
Tier string `json:"tier"`
Dependencies []string `json:"dependencies"`
Capabilities []string `json:"capabilities"`
Healthy bool `json:"healthy"`
}
func pluginViewOf(info *PluginInfo) pluginView {
deps := make([]string, 0, len(info.Dependencies))
for _, d := range info.Dependencies {
deps = append(deps, d.Name+" "+d.VersionConstraint)
}
return pluginView{
Name: info.Name,
Version: info.Version,
Tier: info.Tier.String(),
Dependencies: deps,
Capabilities: info.Capabilities,
Healthy: info.Healthy,
}
}
func (c *Controller) listView() []pluginView {
infos := c.reg.List()
out := make([]pluginView, 0, len(infos))
for _, info := range infos {
out = append(out, pluginViewOf(info))
}
return out
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,102 @@
package kernel
import (
"context"
"testing"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/protobuf/proto"
)
// TestCrossPluginInvocation 起 broker依次加载 echo/transform/streaming验证三层
// 依赖与 tier 复算正确,并经 broker 调 transform-mid其内部又经 broker 调 echo-base
func TestCrossPluginInvocation(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
transformBin := pluginBin(t, "../../plugins/transform")
streamingBin := pluginBin(t, "../../plugins/streaming")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
ctx := context.Background()
// 1) echo-base (BASE)
echo, err := reg.Load(ctx, echoBin, brokerAddr)
if err != nil {
t.Fatalf("load echo: %v", err)
}
if echo.Tier != contractv1.PluginTier_TIER_BASE {
t.Errorf("echo tier = %v, want BASE", echo.Tier)
}
// 2) transform-mid (MID, deps echo-base)
transform, err := reg.Load(ctx, transformBin, brokerAddr)
if err != nil {
t.Fatalf("load transform: %v", err)
}
if transform.Tier != contractv1.PluginTier_TIER_MID {
t.Errorf("transform tier = %v, want MID", transform.Tier)
}
// 3) streaming-demo (COMPLEX, deps echo-base + transform-mid)
streaming, err := reg.Load(ctx, streamingBin, brokerAddr)
if err != nil {
t.Fatalf("load streaming: %v", err)
}
if streaming.Tier != contractv1.PluginTier_TIER_COMPLEX {
t.Errorf("streaming tier = %v, want COMPLEX", streaming.Tier)
}
// 经 broker 调 transform-mid.Transform("hello","upper")。
// transform 内部又经 broker 调 echo-basevia_echo 应被填回 "echo-base"。
broker := NewBrokerServer(reg)
reqBytes, err := proto.Marshal(&contractv1.TransformRequest{Text: "hello", Operation: "upper"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
resp, err := broker.Invoke(ctx, &contractv1.InvokeRequest{
TargetPlugin: "transform-mid",
Method: "/contract.v1.TransformService/Transform",
Payload: reqBytes,
})
if err != nil {
t.Fatalf("broker.Invoke: %v", err)
}
if resp.Error != "" {
t.Fatalf("invoke error: %s", resp.Error)
}
var tResp contractv1.TransformResponse
if err := proto.Unmarshal(resp.Result, &tResp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tResp.Result != "HELLO" {
t.Errorf("result = %q, want HELLO", tResp.Result)
}
if tResp.ViaEcho != "echo-base" {
t.Errorf("via_echo = %q, want echo-base (transform 应经 broker 调到 echo)", tResp.ViaEcho)
}
}
// TestListPluginsViaBroker 验证 broker.ListPlugins 返回所有已加载插件摘要。
func TestListPluginsViaBroker(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), echoBin, brokerAddr); err != nil {
t.Fatalf("load: %v", err)
}
broker := NewBrokerServer(reg)
resp, err := broker.ListPlugins(context.Background(), &contractv1.ListPluginsRequest{})
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(resp.Plugins) != 1 || resp.Plugins[0].Name != "echo-base" {
t.Errorf("plugins = %+v, want one echo-base", resp.Plugins)
}
}
var _ = pluginsdk.KernelEnvAddr

View File

@@ -0,0 +1,73 @@
package kernel
import (
"context"
"os"
"os/exec"
plugin "github.com/hashicorp/go-plugin"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
// grpcConnPlugin 是内核侧的 go-plugin 适配器GRPCClient 直接返回裸
// *grpc.ClientConn供 Broker 在其上调用任意 service与 pluginsdk 的
// grpcPlugin 对称)。
type grpcConnPlugin struct {
plugin.Plugin
}
func (grpcConnPlugin) GRPCServer(*plugin.GRPCBroker, *grpc.Server) error { return nil }
func (grpcConnPlugin) GRPCClient(_ context.Context, _ *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
return c, nil
}
// launchResult 是一次成功启动的产物。
type launchResult struct {
client *plugin.Client
conn *grpc.ClientConn
manifest *contractv1.GetManifestResponse
}
// launch 启动一个插件二进制进程,完成握手,取裸 conn 并调 GetManifest。
// brokerAddr 经环境变量注入,插件用 pluginsdk.NewKernelClient 读它连回内核。
func launch(ctx context.Context, binaryPath, brokerAddr string) (*launchResult, error) {
cmd := exec.Command(binaryPath)
cmd.Env = append(os.Environ(), pluginsdk.KernelEnvAddr+"="+brokerAddr)
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: plugin.HandshakeConfig{
ProtocolVersion: pluginsdk.Handshake.ProtocolVersion,
MagicCookieKey: pluginsdk.Handshake.MagicCookieKey,
MagicCookieValue: pluginsdk.Handshake.MagicCookieValue,
},
Plugins: plugin.PluginSet{
"plugin": grpcConnPlugin{},
},
Cmd: cmd,
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
})
rpcClient, err := client.Client()
if err != nil {
client.Kill()
return nil, err
}
raw, err := rpcClient.Dispense("plugin")
if err != nil {
client.Kill()
return nil, err
}
conn := raw.(*grpc.ClientConn)
mc := contractv1.NewPluginManifestClient(conn)
manifest, err := mc.GetManifest(ctx, &contractv1.GetManifestRequest{})
if err != nil {
client.Kill()
return nil, err
}
return &launchResult{client: client, conn: conn, manifest: manifest}, nil
}

View File

@@ -0,0 +1,65 @@
package kernel
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// goExe 是测试用 Go 工具链路径(不在 PATH 上)。
const goExe = "D:\\RJ\\Basic\\GO\\bin\\go.exe"
// prebuilt 是 TestMain 一次性编译好的样例插件二进制路径,按插件包名索引。
// 各测试共享,避免每个测试函数重复 go build首次 exec 触发 Defender 扫描,
// 重复编译会让整套测试超时)。
var prebuilt = map[string]string{}
// TestMain 在跑任何测试前,把样例插件各编译一次到一个共享临时目录。
func TestMain(m *testing.M) {
tmp, err := os.MkdirTemp("", "kernel-test-plugins")
if err != nil {
panic("创建插件临时目录: " + err.Error())
}
// 插件包相对路径(相对 internal/kernel -> 产物文件名。
specs := map[string]string{
"../../plugins/echo": "echo-base.exe",
"../../plugins/transform": "transform-mid.exe",
"../../plugins/streaming": "streaming-demo.exe",
}
for pkg, outName := range specs {
out := filepath.Join(tmp, outName)
cmd := exec.Command(goExe, "build", "-o", out, pkg)
if combined, err := cmd.CombinedOutput(); err != nil {
os.RemoveAll(tmp)
panic("编译测试插件 " + pkg + " 失败: " + err.Error() + "\n" + string(combined))
}
prebuilt[pkg] = out
}
code := m.Run()
os.RemoveAll(tmp)
os.Exit(code)
}
// pluginBin 返回 TestMain 预编译好的插件二进制路径。
func pluginBin(t *testing.T, pkgRelPath string) string {
t.Helper()
bin, ok := prebuilt[pkgRelPath]
if !ok {
t.Fatalf("插件 %s 未在 TestMain 预编译", pkgRelPath)
}
return bin
}
// startTestBroker 在随机端口起一个 broker 并返回其地址,测试结束自动停。
func startTestBroker(t *testing.T, reg *Registry) string {
t.Helper()
srv, lis, err := StartBrokerServer("127.0.0.1:0", reg)
if err != nil {
t.Fatalf("启动 broker: %v", err)
}
t.Cleanup(func() { srv.Stop() })
return lis.Addr().String()
}

View File

@@ -0,0 +1,279 @@
package kernel
import (
"context"
"fmt"
"time"
"github.com/Masterminds/semver/v3"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
)
// Load 启动并注册一个插件二进制launch 起进程拿 conn+manifest →
// ValidateManifest → resolveManifest依赖/版本/tier/环)→ 通过则写锁插入
// byName 与 edges。任一步失败立刻 Kill绝不留下游离进程或半状态。
func (reg *Registry) Load(ctx context.Context, binaryPath, brokerAddr string) (*PluginInfo, error) {
lr, err := launch(ctx, binaryPath, brokerAddr)
if err != nil {
return nil, fmt.Errorf("启动插件 %s: %w", binaryPath, err)
}
m := lr.manifest
// 校验 manifest 自洽(名字/版本 semver/依赖约束可解析)。
sdkManifest := pluginsdk.Manifest{
Name: m.Name,
Version: m.Version,
Tier: m.Tier,
}
for _, d := range m.Deps {
sdkManifest.Dependencies = append(sdkManifest.Dependencies, pluginsdk.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
if err := pluginsdk.ValidateManifest(sdkManifest); err != nil {
lr.client.Kill()
return nil, err
}
sv, err := semver.NewVersion(m.Version)
if err != nil {
lr.client.Kill()
return nil, fmt.Errorf("插件 %s 版本 %q 非法: %w", m.Name, m.Version, err)
}
reg.mu.Lock()
defer reg.mu.Unlock()
if _, exists := reg.byName[m.Name]; exists {
lr.client.Kill()
return nil, fmt.Errorf("插件 %s 已注册(如需换版本请走 upgrade", m.Name)
}
computed, err := resolveManifest(reg, m)
if err != nil {
lr.client.Kill()
return nil, err
}
caps := make([]string, 0, len(m.Caps))
for _, c := range m.Caps {
caps = append(caps, c.ServiceName)
}
info := &PluginInfo{
Name: m.Name,
Version: m.Version,
SemVer: sv,
Tier: computed,
Dependencies: m.Deps,
Capabilities: caps,
Healthy: true,
BinaryPath: binaryPath,
Conn: lr.conn,
Client: lr.client,
}
reg.byName[m.Name] = info
reg.edges[m.Name] = depNames(m.Deps)
return info, nil
}
// Unload 卸载一个插件:先反向边检查(无其它插件依赖它)→ 移除 → Kill。
func (reg *Registry) Unload(name string) error {
reg.mu.Lock()
defer reg.mu.Unlock()
info, ok := reg.byName[name]
if !ok {
return fmt.Errorf("插件 %s 未注册", name)
}
// 反向边检查:是否有其它插件依赖 name。
for other, deps := range reg.edges {
if other == name {
continue
}
for _, d := range deps {
if d == name {
return fmt.Errorf("插件 %s 被 %s 依赖,无法卸载", name, other)
}
}
}
delete(reg.byName, name)
delete(reg.edges, name)
info.Client.Kill()
return nil
}
// Upgrade 热升级一个已注册插件到新二进制:先把新二进制作为临时进程加载并
// 校验 manifest/依赖/tier/环(基于「移除旧记录后」的注册表视图,避免与自身旧
// 版本冲突),通过则写锁原子换 byName/edges 指向新 PluginInfo旧进程在锁外
// 延迟 drain 后 Kill。任一步失败都 Kill 新进程并保持旧版本不变。
//
// 注意:新版本若 tier/依赖发生变化,会重校验所有依赖它的插件,任一不满足即拒绝。
func (reg *Registry) Upgrade(ctx context.Context, name, binaryPath, brokerAddr string, drain time.Duration) (*PluginInfo, error) {
reg.mu.RLock()
old, ok := reg.byName[name]
reg.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("插件 %s 未注册,无法升级", name)
}
// 起新进程拿 conn+manifest。
lr, err := launch(ctx, binaryPath, brokerAddr)
if err != nil {
return nil, fmt.Errorf("启动新版插件 %s: %w", binaryPath, err)
}
m := lr.manifest
if m.Name != name {
lr.client.Kill()
return nil, fmt.Errorf("新二进制插件名 %q 与待升级 %q 不符", m.Name, name)
}
sdkManifest := pluginsdk.Manifest{Name: m.Name, Version: m.Version, Tier: m.Tier}
for _, d := range m.Deps {
sdkManifest.Dependencies = append(sdkManifest.Dependencies, pluginsdk.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
if err := pluginsdk.ValidateManifest(sdkManifest); err != nil {
lr.client.Kill()
return nil, err
}
sv, err := semver.NewVersion(m.Version)
if err != nil {
lr.client.Kill()
return nil, fmt.Errorf("插件 %s 版本 %q 非法: %w", m.Name, m.Version, err)
}
reg.mu.Lock()
// 基于「移除旧记录」的视图校验新版本,避免与自身旧版本误判成环/冲突。
delete(reg.byName, name)
delete(reg.edges, name)
computed, err := resolveManifest(reg, m)
if err != nil {
// 回滚:恢复旧记录。
reg.byName[name] = old
reg.edges[name] = depNames(old.Dependencies)
reg.mu.Unlock()
lr.client.Kill()
return nil, err
}
// 若 tier/依赖变化,重校验所有依赖它的插件(它们的约束仍须满足)。
caps := make([]string, 0, len(m.Caps))
for _, c := range m.Caps {
caps = append(caps, c.ServiceName)
}
info := &PluginInfo{
Name: m.Name,
Version: m.Version,
SemVer: sv,
Tier: computed,
Dependencies: m.Deps,
Capabilities: caps,
Healthy: true,
BinaryPath: binaryPath,
Conn: lr.conn,
Client: lr.client,
}
reg.byName[name] = info
reg.edges[name] = depNames(m.Deps)
// 下游重校验:任何依赖 name 的插件,其约束须被新版本满足。
if err := reg.revalidateDependents(name); err != nil {
// 回滚到旧版本。
reg.byName[name] = old
reg.edges[name] = depNames(old.Dependencies)
reg.mu.Unlock()
lr.client.Kill()
return nil, err
}
reg.mu.Unlock()
// 旧进程在锁外延迟 drain 后 Kill给在途请求收尾时间。
go func() {
if drain > 0 {
time.Sleep(drain)
}
old.Client.Kill()
}()
return info, nil
}
// revalidateDependents 重校验所有依赖 name 的插件:它们的版本约束须被
// 当前 reg.byName[name] 的版本满足。调用方须持有写锁。
func (reg *Registry) revalidateDependents(name string) error {
target := reg.byName[name]
if target == nil {
return nil
}
for other, info := range reg.byName {
if other == name {
continue
}
for _, d := range info.Dependencies {
if d.Name != name {
continue
}
c, err := semver.NewConstraint(d.VersionConstraint)
if err != nil {
return fmt.Errorf("插件 %s 依赖 %s 的约束 %q 非法: %w", other, name, d.VersionConstraint, err)
}
if !c.Check(target.SemVer) {
return fmt.Errorf("升级 %s 到 %s 会破坏 %s 的约束 %q",
name, target.Version, other, d.VersionConstraint)
}
}
}
return nil
}
// Get 返回指定插件的记录(未注册返回 nil
func (reg *Registry) Get(name string) *PluginInfo {
reg.mu.RLock()
defer reg.mu.RUnlock()
return reg.byName[name]
}
// List 返回所有已注册插件的快照。
func (reg *Registry) List() []*PluginInfo {
reg.mu.RLock()
defer reg.mu.RUnlock()
out := make([]*PluginInfo, 0, len(reg.byName))
for _, info := range reg.byName {
out = append(out, info)
}
return out
}
// Shutdown 关闭所有插件进程。
func (reg *Registry) Shutdown() {
reg.mu.Lock()
defer reg.mu.Unlock()
for name, info := range reg.byName {
info.Client.Kill()
delete(reg.byName, name)
delete(reg.edges, name)
}
}
// pluginTierInfo 是 ListPlugins 返回用的摘要构造辅助。
func (info *PluginInfo) toProtoInfo() *contractv1.PluginInfo {
return &contractv1.PluginInfo{
Name: info.Name,
Version: info.Version,
Tier: info.Tier,
Healthy: info.Healthy,
}
}

View File

@@ -0,0 +1,92 @@
package kernel
import (
"context"
"sync"
"testing"
contractv1 "goaiapi/proto/contract/v1"
)
func TestLoadBasePlugin(t *testing.T) {
bin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
info, err := reg.Load(context.Background(), bin, brokerAddr)
if err != nil {
t.Fatalf("Load: %v", err)
}
if info.Name != "echo-base" {
t.Errorf("name = %q, want echo-base", info.Name)
}
if info.Tier != contractv1.PluginTier_TIER_BASE {
t.Errorf("tier = %v, want BASE", info.Tier)
}
if !info.Healthy {
t.Error("plugin not healthy")
}
}
func TestLoadDuplicateRejected(t *testing.T) {
bin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), bin, brokerAddr); err != nil {
t.Fatalf("first Load: %v", err)
}
if _, err := reg.Load(context.Background(), bin, brokerAddr); err == nil {
t.Fatal("期望重复加载报错,得到 nil")
}
}
func TestUnloadBlockedByDependent(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
transformBin := pluginBin(t, "../../plugins/transform")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), echoBin, brokerAddr); err != nil {
t.Fatalf("load echo: %v", err)
}
if _, err := reg.Load(context.Background(), transformBin, brokerAddr); err != nil {
t.Fatalf("load transform: %v", err)
}
// echo-base 被 transform-mid 依赖,不能卸载。
if err := reg.Unload("echo-base"); err == nil {
t.Fatal("期望卸载被依赖插件报错,得到 nil")
}
// 先卸 transform-mid再卸 echo-base 应成功。
if err := reg.Unload("transform-mid"); err != nil {
t.Fatalf("unload transform: %v", err)
}
if err := reg.Unload("echo-base"); err != nil {
t.Fatalf("unload echo: %v", err)
}
}
func TestConcurrentLoadList(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), echoBin, brokerAddr); err != nil {
t.Fatalf("load: %v", err)
}
// 并发读 List/Get配合 -race 检测数据竞争。
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = reg.List()
_ = reg.Get("echo-base")
}()
}
wg.Wait()
}

View File

@@ -0,0 +1,103 @@
package kernel
import (
"fmt"
"github.com/Masterminds/semver/v3"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
)
// resolveManifest 校验候选 manifest 对当前注册表是否自洽:
// 1. 逐依赖:目标已注册、版本约束可解析、已注册版本满足约束。
// 2. tier 复算:由注册表 + 候选依赖构建 allTiers调 pluginsdk.ComputeTier
// 与自报 tier 比对。
// 3. 环检测:把候选依赖临时并入 edgesDFS 着色发现回边即拒绝。
// 调用方必须持有 reg 的读锁(或在写锁内调用)。返回复算出的 tier。
func resolveManifest(reg *Registry, m *contractv1.GetManifestResponse) (contractv1.PluginTier, error) {
// 1) 依赖 + 版本约束
for _, d := range m.Deps {
dep, ok := reg.byName[d.Name]
if !ok {
return 0, fmt.Errorf("插件 %s 依赖 %s 尚未注册", m.Name, d.Name)
}
c, err := semver.NewConstraint(d.VersionConstraint)
if err != nil {
return 0, fmt.Errorf("插件 %s 依赖 %s 的约束 %q 非法: %w", m.Name, d.Name, d.VersionConstraint, err)
}
if dep.SemVer == nil || !c.Check(dep.SemVer) {
return 0, fmt.Errorf("插件 %s 依赖 %s 版本 %s 不满足约束 %q",
m.Name, d.Name, dep.Version, d.VersionConstraint)
}
}
// 2) tier 复算
allTiers := make(map[string]contractv1.PluginTier, len(reg.byName))
for name, info := range reg.byName {
allTiers[name] = info.Tier
}
sdkDeps := make([]pluginsdk.Dependency, 0, len(m.Deps))
for _, d := range m.Deps {
sdkDeps = append(sdkDeps, pluginsdk.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
computed := pluginsdk.ComputeTier(sdkDeps, allTiers)
if m.Tier != contractv1.PluginTier_TIER_UNSPECIFIED && m.Tier != computed {
return 0, fmt.Errorf("插件 %s 自报 tier=%v 与复算 tier=%v 不符", m.Name, m.Tier, computed)
}
// 3) 环检测:在 edges 副本上加入候选边后跑 DFS。
if err := checkNoCycle(reg.edges, m.Name, depNames(m.Deps)); err != nil {
return 0, err
}
return computed, nil
}
func depNames(deps []*contractv1.Dependency) []string {
out := make([]string, 0, len(deps))
for _, d := range deps {
out = append(out, d.Name)
}
return out
}
// checkNoCycle 在现有 edges 基础上临时加入 candidate -> its deps
// 用白/灰/黑三色 DFS 检测是否成环。
func checkNoCycle(edges map[string][]string, candidate string, candidateDeps []string) error {
// 构建临时图副本
graph := make(map[string][]string, len(edges)+1)
for k, v := range edges {
graph[k] = v
}
graph[candidate] = candidateDeps
const (
white = 0
gray = 1
black = 2
)
color := make(map[string]int)
var visit func(node string, stack []string) error
visit = func(node string, stack []string) error {
color[node] = gray
stack = append(stack, node)
for _, next := range graph[node] {
switch color[next] {
case gray:
return fmt.Errorf("检测到依赖环: %v -> %s", stack, next)
case white:
if err := visit(next, stack); err != nil {
return err
}
}
}
color[node] = black
return nil
}
return visit(candidate, nil)
}

View File

@@ -0,0 +1,143 @@
package kernel
import (
"testing"
"github.com/Masterminds/semver/v3"
contractv1 "goaiapi/proto/contract/v1"
)
// mkReg 构造一个带预置插件的注册表,便于 resolveManifest 测试。
func mkReg(plugins ...*PluginInfo) *Registry {
reg := NewRegistry()
for _, p := range plugins {
reg.byName[p.Name] = p
reg.edges[p.Name] = depNames(p.Dependencies)
}
return reg
}
func mustVer(t *testing.T, v string) *semver.Version {
t.Helper()
sv, err := semver.NewVersion(v)
if err != nil {
t.Fatalf("解析版本 %q: %v", v, err)
}
return sv
}
func dep(name, constraint string) *contractv1.Dependency {
return &contractv1.Dependency{Name: name, VersionConstraint: constraint}
}
func manifest(name, version string, tier contractv1.PluginTier, deps ...*contractv1.Dependency) *contractv1.GetManifestResponse {
return &contractv1.GetManifestResponse{Name: name, Version: version, Tier: tier, Deps: deps}
}
func TestResolveBaseNoDeps(t *testing.T) {
reg := mkReg()
tier, err := resolveManifest(reg, manifest("echo-base", "1.0.0", contractv1.PluginTier_TIER_BASE))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_BASE {
t.Errorf("tier = %v, want BASE", tier)
}
}
func TestResolveMidDependsOnBase(t *testing.T) {
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
tier, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_MID, dep("echo-base", ">=1.0.0 <2.0.0")))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_MID {
t.Errorf("tier = %v, want MID", tier)
}
}
func TestResolveComplexDependsOnMid(t *testing.T) {
reg := mkReg(
&PluginInfo{Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"), Tier: contractv1.PluginTier_TIER_BASE},
&PluginInfo{Name: "transform-mid", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"), Tier: contractv1.PluginTier_TIER_MID,
Dependencies: []*contractv1.Dependency{dep("echo-base", ">=1.0.0")}},
)
tier, err := resolveManifest(reg, manifest("streaming-demo", "1.0.0",
contractv1.PluginTier_TIER_COMPLEX,
dep("echo-base", ">=1.0.0"), dep("transform-mid", ">=1.0.0")))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_COMPLEX {
t.Errorf("tier = %v, want COMPLEX", tier)
}
}
func TestResolveMissingDependency(t *testing.T) {
reg := mkReg()
_, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_MID, dep("echo-base", ">=1.0.0")))
if err == nil {
t.Fatal("期望缺依赖报错,得到 nil")
}
}
func TestResolveUnsatisfiedConstraint(t *testing.T) {
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
_, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_MID, dep("echo-base", ">=2.0.0")))
if err == nil {
t.Fatal("期望版本不满足约束报错,得到 nil")
}
}
func TestResolveTierMismatch(t *testing.T) {
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
// 依赖 base应为 MID却自报 BASE。
_, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_BASE, dep("echo-base", ">=1.0.0")))
if err == nil {
t.Fatal("期望 tier 自报不符报错,得到 nil")
}
}
func TestResolveCycle(t *testing.T) {
// 预置 BB 依赖候选 A候选 A 又依赖 B —— 成环。
reg := mkReg(&PluginInfo{
Name: "B", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_MID,
Dependencies: []*contractv1.Dependency{dep("A", ">=1.0.0")},
})
reg.edges["B"] = []string{"A"}
_, err := resolveManifest(reg, manifest("A", "1.0.0",
contractv1.PluginTier_TIER_COMPLEX, dep("B", ">=1.0.0")))
if err == nil {
t.Fatal("期望检测到依赖环,得到 nil")
}
}
func TestResolveUnspecifiedTierAccepted(t *testing.T) {
// 自报 UNSPECIFIED 时不强制比对,直接采用复算结果。
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
tier, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_UNSPECIFIED, dep("echo-base", ">=1.0.0")))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_MID {
t.Errorf("tier = %v, want MID", tier)
}
}

View File

@@ -0,0 +1,47 @@
// Package kernel 是插件化内核的纯基础设施:插件注册表、加载器、依赖解析、
// InvocationBroker 服务端、动态加载控制端口。内核不含任何业务逻辑,
// relay 的全部能力转发、留存、解析、dashboard都由插件实现。
package kernel
import (
"sync"
"github.com/Masterminds/semver/v3"
plugin "github.com/hashicorp/go-plugin"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
// PluginInfo 是内核对一个已加载插件的记录。
type PluginInfo struct {
Name string
Version string
SemVer *semver.Version
Tier contractv1.PluginTier
Dependencies []*contractv1.Dependency
Capabilities []string
Healthy bool
BinaryPath string
// Conn 是插件的裸 gRPC 连接Broker 用它路由 Invoke。
Conn *grpc.ClientConn
// Client 是 go-plugin 生命周期句柄,用于 Kill 插件进程。
Client *plugin.Client
}
// Registry 持有所有已加载插件及其依赖边(用于环检测)。
type Registry struct {
mu sync.RWMutex
byName map[string]*PluginInfo
// edges: 插件名 -> 它依赖的插件名集合(环检测用)
edges map[string][]string
}
// NewRegistry 创建空注册表。
func NewRegistry() *Registry {
return &Registry{
byName: make(map[string]*PluginInfo),
edges: make(map[string][]string),
}
}