mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +00:00
Extend the local plugin system's host<->plugin contract with generic, capability-neutral facilities, so transport plugins can be built on top without embedding plugin-specific logic in the host. Host services (plugin dials a broker-provided HostService gRPC): - per-plugin namespaced KV store (key/value size and TTL bounded) - account directory: ListAccounts + ResolveOutboundIdentity, restricted to the accounts a plugin's declared capability already covers - InitHostServices lifecycle hook; plugins that don't implement it run unchanged and are simply offered no host services Read-only runtime status channel: - HealthResponse gains an opaque, plugin-defined status_json produced passively (no config apply, no upstream call, no side effects) - GET /admin/plugins/:id/status (ungated) and the plugin.status UI-bridge verb map to Health, so a status UI can poll live state without a step-up prompt or a "test" side effect - config.test no longer force-shows a host success toast; the plugin UI owns result presentation, so plugins that read status do not spam toasts Document the UI-bridge verb taxonomy (read vs mutate/test gating) and the status contract under pkg/pluginapi.
131 lines
4.8 KiB
Go
131 lines
4.8 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||
hcplugin "github.com/hashicorp/go-plugin"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// brokerProbePlugin 是仅用于测试的插件传输实现:它实现 HostBrokerReceiver 以拿到
|
||
// broker,并在 InitHostServices 中用宿主下发的 broker id 拨号回宿主的 HostService,
|
||
// 走一次真实 gRPC 的 KVSet/KVGet 往返。
|
||
type brokerProbePlugin struct {
|
||
pluginv1.UnimplementedTransportPluginServer
|
||
broker *hcplugin.GRPCBroker
|
||
|
||
mu sync.Mutex
|
||
ready bool
|
||
getFound bool
|
||
getValue []byte
|
||
failure string
|
||
}
|
||
|
||
func (p *brokerProbePlugin) SetHostBroker(broker *hcplugin.GRPCBroker) { p.broker = broker }
|
||
|
||
func (p *brokerProbePlugin) InitHostServices(ctx context.Context, req *pluginv1.InitHostServicesRequest) (*pluginv1.InitHostServicesResponse, error) {
|
||
if p.broker == nil {
|
||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: "broker 未注入"}, nil
|
||
}
|
||
conn, err := p.broker.Dial(req.HostServiceId)
|
||
if err != nil {
|
||
p.record("dial: " + err.Error())
|
||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
|
||
}
|
||
defer func() { _ = conn.Close() }()
|
||
client := pluginv1.NewHostServiceClient(conn)
|
||
if _, err := client.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "probe", Value: []byte("hello"), TtlSeconds: 60}); err != nil {
|
||
p.record("set: " + err.Error())
|
||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
|
||
}
|
||
got, err := client.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "probe"})
|
||
if err != nil {
|
||
p.record("get: " + err.Error())
|
||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
|
||
}
|
||
p.mu.Lock()
|
||
p.ready = true
|
||
p.getFound = got.Found
|
||
p.getValue = got.Value
|
||
p.mu.Unlock()
|
||
return &pluginv1.InitHostServicesResponse{Ready: true}, nil
|
||
}
|
||
|
||
func (p *brokerProbePlugin) record(failure string) {
|
||
p.mu.Lock()
|
||
p.failure = failure
|
||
p.mu.Unlock()
|
||
}
|
||
|
||
// noHostServicesPlugin 模拟老插件:不实现 InitHostServices(返回 Unimplemented),
|
||
// 也不接收 broker。
|
||
type noHostServicesPlugin struct {
|
||
pluginv1.UnimplementedTransportPluginServer
|
||
}
|
||
|
||
func dispenseTransportClient(t *testing.T, impl pluginv1.TransportPluginServer) *pluginv1.TransportClient {
|
||
t.Helper()
|
||
client, _ := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{
|
||
pluginv1.TransportPluginName: &pluginv1.GRPCPlugin{Impl: impl},
|
||
})
|
||
t.Cleanup(func() { _ = client.Close() })
|
||
dispensed, err := client.Dispense(pluginv1.TransportPluginName)
|
||
require.NoError(t, err)
|
||
tc, ok := dispensed.(*pluginv1.TransportClient)
|
||
require.True(t, ok)
|
||
require.NotNil(t, tc.Broker)
|
||
return tc
|
||
}
|
||
|
||
// 端到端验证 broker 反向通道:宿主服务 AcceptAndServe + 插件 Dial 回来,经真实 gRPC
|
||
// 完成 KV 往返,且写入落到宿主注入的 pluginKey 命名空间下。
|
||
func TestOfferPluginHostServices_BrokerRoundtrip(t *testing.T) {
|
||
probe := &brokerProbePlugin{}
|
||
tc := dispenseTransportClient(t, probe)
|
||
|
||
store := newFakePluginKVStore()
|
||
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
|
||
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
|
||
|
||
probe.mu.Lock()
|
||
defer probe.mu.Unlock()
|
||
require.Empty(t, probe.failure)
|
||
require.True(t, probe.ready)
|
||
assert.True(t, probe.getFound)
|
||
assert.Equal(t, []byte("hello"), probe.getValue)
|
||
|
||
stored, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
|
||
require.NoError(t, err)
|
||
require.True(t, found)
|
||
assert.Equal(t, []byte("hello"), stored)
|
||
}
|
||
|
||
// 老插件不实现 InitHostServices 时,offerPluginHostServices 必须优雅降级、不 panic、
|
||
// 不写入任何状态。
|
||
func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) {
|
||
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
|
||
|
||
store := newFakePluginKVStore()
|
||
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
|
||
require.NotPanics(t, func() {
|
||
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
|
||
})
|
||
|
||
_, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
|
||
require.NoError(t, err)
|
||
assert.False(t, found)
|
||
}
|
||
|
||
// hostServices 为 nil(未配置键值存储)时不得触发任何 broker 交互。
|
||
func TestOfferPluginHostServices_NilHostServicesNoop(t *testing.T) {
|
||
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
|
||
require.NotPanics(t, func() {
|
||
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, nil, 5*time.Second)
|
||
})
|
||
}
|