Files
sub2api/backend/internal/service/plugin_host_services_broker_test.go
T
shaw c63bd14a02 feat(plugin): generic host services + read-only status bridge channel
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.
2026-09-19 11:25:56 +08:00

131 lines
4.8 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 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)
})
}