367 lines
9.9 KiB
Go
367 lines
9.9 KiB
Go
package pluginhost
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-hclog"
|
|
"github.com/hashicorp/go-plugin"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/health/grpc_health_v1"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
sdkruntime "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/runtime"
|
|
)
|
|
|
|
type Config struct {
|
|
Logger hclog.Logger
|
|
HealthCheckInterval time.Duration
|
|
HealthFailureLimit int
|
|
|
|
// EventPublisher receives events that plugins publish via RuntimeHost.
|
|
// Typically silo's *events.Hub. When nil, plugins that try to
|
|
// PublishEvent will receive an error.
|
|
EventPublisher EventPublisher
|
|
// LibraryLister answers ListLibraries calls from plugins. When nil,
|
|
// plugins will see empty library lists.
|
|
LibraryLister LibraryLister
|
|
// CatalogPresence answers CheckMediaPresence calls from plugins. When nil,
|
|
// plugins will receive empty presence results.
|
|
CatalogPresence CatalogPresenceLookup
|
|
// InstalledPlugins answers ListInstalledPlugins calls from plugins. When nil,
|
|
// plugins will receive an empty plugin list.
|
|
InstalledPlugins InstalledPluginLister
|
|
// GlobalConfigSetter persists SetGlobalConfigEntry calls from plugins. When
|
|
// nil, SetGlobalConfigEntry returns an error.
|
|
GlobalConfigSetter GlobalConfigSetter
|
|
}
|
|
|
|
type StartRequest struct {
|
|
InstallationID int
|
|
BinaryPath string
|
|
Manifest *pluginv1.PluginManifest
|
|
Config []*pluginv1.ConfigEntry
|
|
}
|
|
|
|
type Host struct {
|
|
logger hclog.Logger
|
|
healthCheckInterval time.Duration
|
|
healthFailureLimit int
|
|
|
|
eventPublisher EventPublisher
|
|
libraryLister LibraryLister
|
|
catalogPresence CatalogPresenceLookup
|
|
installedPlugins InstalledPluginLister
|
|
globalConfigSetter GlobalConfigSetter
|
|
|
|
mu sync.RWMutex
|
|
instances map[int]*instance
|
|
}
|
|
|
|
type instance struct {
|
|
process *plugin.Client
|
|
protocol plugin.ClientProtocol
|
|
client *Client
|
|
cancelHealth context.CancelFunc
|
|
}
|
|
|
|
func NewHost(cfg Config) *Host {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = hclog.NewNullLogger()
|
|
}
|
|
interval := cfg.HealthCheckInterval
|
|
if interval <= 0 {
|
|
interval = DefaultHealthCheckInterval
|
|
}
|
|
failureLimit := cfg.HealthFailureLimit
|
|
if failureLimit <= 0 {
|
|
failureLimit = DefaultHealthFailureLimit
|
|
}
|
|
|
|
return &Host{
|
|
logger: logger,
|
|
healthCheckInterval: interval,
|
|
healthFailureLimit: failureLimit,
|
|
eventPublisher: cfg.EventPublisher,
|
|
libraryLister: cfg.LibraryLister,
|
|
catalogPresence: cfg.CatalogPresence,
|
|
installedPlugins: cfg.InstalledPlugins,
|
|
globalConfigSetter: cfg.GlobalConfigSetter,
|
|
instances: make(map[int]*instance),
|
|
}
|
|
}
|
|
|
|
func (h *Host) Start(ctx context.Context, req StartRequest) (*Client, error) {
|
|
if req.InstallationID == 0 {
|
|
return nil, fmt.Errorf("installation id is required")
|
|
}
|
|
if req.BinaryPath == "" {
|
|
return nil, fmt.Errorf("plugin binary path is required")
|
|
}
|
|
if req.Manifest == nil {
|
|
return nil, fmt.Errorf("plugin manifest is required")
|
|
}
|
|
|
|
h.mu.Lock()
|
|
if existing, ok := h.instances[req.InstallationID]; ok {
|
|
delete(h.instances, req.InstallationID)
|
|
h.mu.Unlock()
|
|
h.stopInstance(existing)
|
|
h.mu.Lock()
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
process := plugin.NewClient(&plugin.ClientConfig{
|
|
HandshakeConfig: HandshakeConfig(),
|
|
AllowedProtocols: []plugin.Protocol{
|
|
plugin.ProtocolGRPC,
|
|
},
|
|
Cmd: exec.Command(req.BinaryPath),
|
|
Plugins: sdkruntime.DefaultPluginSet(sdkruntime.CapabilityServers{}),
|
|
Logger: h.logger,
|
|
Stderr: os.Stderr,
|
|
SyncStdout: os.Stdout,
|
|
SyncStderr: os.Stderr,
|
|
})
|
|
|
|
protocol, err := process.Client()
|
|
if err != nil {
|
|
process.Kill()
|
|
return nil, fmt.Errorf("start plugin process: %w", err)
|
|
}
|
|
|
|
rawClient, err := protocol.Dispense(sdkruntime.PluginSetName)
|
|
if err != nil {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("dispense plugin runtime client: %w", err)
|
|
}
|
|
|
|
rpcClient, ok := rawClient.(*sdkruntime.Client)
|
|
if !ok {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("unexpected plugin runtime client type %T", rawClient)
|
|
}
|
|
|
|
if err := h.bindRuntimeHost(ctx, rpcClient, req.Manifest.GetPluginId(), req.InstallationID); err != nil {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("bind runtime host: %w", err)
|
|
}
|
|
|
|
controlCtx, cancel := ensureDeadline(ctx, DefaultControlTimeout)
|
|
defer cancel()
|
|
|
|
liveManifestResponse, err := rpcClient.Runtime().GetManifest(controlCtx, &pluginv1.GetManifestRequest{})
|
|
if err != nil {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("fetch plugin manifest: %w", err)
|
|
}
|
|
if liveManifestResponse.GetManifest() == nil {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("plugin runtime returned an empty manifest")
|
|
}
|
|
if !proto.Equal(req.Manifest, liveManifestResponse.GetManifest()) {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("plugin runtime manifest does not match installed manifest")
|
|
}
|
|
configureCtx, configureCancel := ensureDeadline(ctx, DefaultControlTimeout)
|
|
defer configureCancel()
|
|
|
|
_, err = rpcClient.Runtime().Configure(configureCtx, &pluginv1.ConfigureRequest{
|
|
Config: req.Config,
|
|
})
|
|
if err != nil && status.Code(err) != codes.Unimplemented {
|
|
_ = protocol.Close()
|
|
process.Kill()
|
|
return nil, fmt.Errorf("configure plugin runtime: %w", err)
|
|
}
|
|
|
|
client := newClient(req.InstallationID, rpcClient, liveManifestResponse.GetManifest())
|
|
|
|
healthCtx, healthCancel := context.WithCancel(context.Background())
|
|
instance := &instance{
|
|
process: process,
|
|
protocol: protocol,
|
|
client: client,
|
|
cancelHealth: healthCancel,
|
|
}
|
|
|
|
h.mu.Lock()
|
|
h.instances[req.InstallationID] = instance
|
|
h.mu.Unlock()
|
|
|
|
go h.monitorHealth(healthCtx, req.InstallationID, instance)
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (h *Host) Client(installationID int) (*Client, error) {
|
|
h.mu.RLock()
|
|
instance, ok := h.instances[installationID]
|
|
h.mu.RUnlock()
|
|
if !ok {
|
|
return nil, ErrClientNotFound
|
|
}
|
|
|
|
instance.client.mu.RLock()
|
|
unhealthy := instance.client.unhealthy
|
|
instance.client.mu.RUnlock()
|
|
if unhealthy {
|
|
return nil, ErrPluginUnhealthy
|
|
}
|
|
|
|
return instance.client, nil
|
|
}
|
|
|
|
func (h *Host) Stop(installationID int) error {
|
|
h.mu.Lock()
|
|
instance, ok := h.instances[installationID]
|
|
if ok {
|
|
delete(h.instances, installationID)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
if !ok {
|
|
return ErrClientNotFound
|
|
}
|
|
h.stopInstance(instance)
|
|
return nil
|
|
}
|
|
|
|
func (h *Host) Shutdown(ctx context.Context) error {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.mu.Lock()
|
|
instances := make([]*instance, 0, len(h.instances))
|
|
for installationID, instance := range h.instances {
|
|
delete(h.instances, installationID)
|
|
instances = append(instances, instance)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
for _, instance := range instances {
|
|
h.stopInstance(instance)
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-done:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (h *Host) monitorHealth(ctx context.Context, installationID int, instance *instance) {
|
|
ticker := time.NewTicker(h.healthCheckInterval)
|
|
defer ticker.Stop()
|
|
|
|
failures := 0
|
|
healthClient := grpc_health_v1.NewHealthClient(instance.client.rpc.Conn())
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
checkCtx, cancel := context.WithTimeout(context.Background(), DefaultControlTimeout)
|
|
_, err := healthClient.Check(checkCtx, &grpc_health_v1.HealthCheckRequest{Service: plugin.GRPCServiceName})
|
|
cancel()
|
|
if err == nil {
|
|
failures = 0
|
|
continue
|
|
}
|
|
|
|
failures++
|
|
if failures < h.healthFailureLimit {
|
|
continue
|
|
}
|
|
|
|
instance.client.markUnhealthy()
|
|
h.logger.Error("plugin health check failed", "installation_id", installationID, "error", err)
|
|
h.stopInstance(instance)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Host) stopInstance(instance *instance) {
|
|
if instance == nil {
|
|
return
|
|
}
|
|
if instance.cancelHealth != nil {
|
|
instance.cancelHealth()
|
|
}
|
|
if instance.protocol != nil {
|
|
_ = instance.protocol.Close()
|
|
}
|
|
if instance.process != nil {
|
|
instance.process.Kill()
|
|
}
|
|
}
|
|
|
|
// bindRuntimeHost stands up a RuntimeHost gRPC server on a fresh broker
|
|
// stream and tells the plugin its stream ID via Runtime.BindHostBroker. The
|
|
// broker stream lives for the plugin's lifetime; closing the plugin process
|
|
// tears it down.
|
|
//
|
|
// Skipped when no RuntimeHost services are configured.
|
|
func (h *Host) bindRuntimeHost(ctx context.Context, sdkClient *sdkruntime.Client, pluginID string, installationID int) error {
|
|
if h.eventPublisher == nil && h.libraryLister == nil && h.catalogPresence == nil && h.installedPlugins == nil && h.globalConfigSetter == nil {
|
|
return nil
|
|
}
|
|
|
|
broker := sdkClient.Broker()
|
|
if broker == nil {
|
|
// Non-gRPC protocol or client constructed directly (e.g. in tests);
|
|
// broker is unavailable so skip binding.
|
|
return nil
|
|
}
|
|
|
|
streamID := broker.NextId()
|
|
go broker.AcceptAndServe(streamID, func(opts []grpc.ServerOption) *grpc.Server {
|
|
s := grpc.NewServer(opts...)
|
|
srv := NewRuntimeHostServerWithServices(
|
|
h.eventPublisher,
|
|
h.libraryLister,
|
|
h.catalogPresence,
|
|
h.installedPlugins,
|
|
h.globalConfigSetter,
|
|
pluginID,
|
|
installationID,
|
|
)
|
|
pluginv1.RegisterRuntimeHostServer(s, srv)
|
|
return s
|
|
})
|
|
|
|
bindCtx, cancel := ensureDeadline(ctx, DefaultControlTimeout)
|
|
defer cancel()
|
|
if _, err := sdkClient.Runtime().BindHostBroker(bindCtx, &pluginv1.BindHostBrokerRequest{BrokerId: streamID}); err != nil {
|
|
// Plugins built against pre-RuntimeHost SDK versions don't implement
|
|
// BindHostBroker. Tolerate that — they simply don't get host services
|
|
// (PublishEvent / ListLibraries / CheckMediaPresence). Treat any
|
|
// gRPC Unimplemented as a soft skip; everything else is still a hard
|
|
// error because it indicates a real comm problem.
|
|
if status.Code(err) == codes.Unimplemented {
|
|
h.logger.Debug("plugin runtime does not implement BindHostBroker; skipping host bind", "plugin_id", pluginID)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("bind host broker: %w", err)
|
|
}
|
|
return nil
|
|
}
|