4
This commit is contained in:
91
src/go/internal/kernel/broker.go
Normal file
91
src/go/internal/kernel/broker.go
Normal 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
|
||||
}
|
||||
140
src/go/internal/kernel/control.go
Normal file
140
src/go/internal/kernel/control.go
Normal 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)
|
||||
}
|
||||
102
src/go/internal/kernel/integration_test.go
Normal file
102
src/go/internal/kernel/integration_test.go
Normal 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-base,via_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
|
||||
73
src/go/internal/kernel/launcher.go
Normal file
73
src/go/internal/kernel/launcher.go
Normal 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
|
||||
}
|
||||
65
src/go/internal/kernel/main_test.go
Normal file
65
src/go/internal/kernel/main_test.go
Normal 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()
|
||||
}
|
||||
279
src/go/internal/kernel/registry.go
Normal file
279
src/go/internal/kernel/registry.go
Normal 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,
|
||||
}
|
||||
}
|
||||
92
src/go/internal/kernel/registry_test.go
Normal file
92
src/go/internal/kernel/registry_test.go
Normal 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()
|
||||
}
|
||||
103
src/go/internal/kernel/resolver.go
Normal file
103
src/go/internal/kernel/resolver.go
Normal 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. 环检测:把候选依赖临时并入 edges,DFS 着色发现回边即拒绝。
|
||||
// 调用方必须持有 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)
|
||||
}
|
||||
143
src/go/internal/kernel/resolver_test.go
Normal file
143
src/go/internal/kernel/resolver_test.go
Normal 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) {
|
||||
// 预置 B,B 依赖候选 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)
|
||||
}
|
||||
}
|
||||
47
src/go/internal/kernel/types.go
Normal file
47
src/go/internal/kernel/types.go
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user