Files
silo-server/internal/plugins/service_hot_reload_test.go
40a9de7f26 feat(watchsync): add plugin-backed providers (#475)
* feat(watchsync): add plugin-backed providers

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

* fix(watchsync): keep device state host-private

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

* chore(deps): use released watch-sync SDK

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:30:49 -04:00

438 lines
12 KiB
Go

package plugins
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/pluginhost"
)
func TestServiceEnsureClientRestartsOnManifestDrift(t *testing.T) {
ctx := context.Background()
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
installPath := writeInstalledPluginManifest(t, manifest)
store := newFakeServiceInstallationStore(&Installation{
ID: 3,
PluginID: manifest.GetPluginId(),
Version: manifest.GetVersion(),
InstallPath: installPath,
Enabled: true,
})
host := &fakeServiceHost{
clientResult: &fakePluginClient{manifest: testPluginManifest(t, "silo.metadb", "0.0.34")},
startResult: &fakePluginClient{manifest: manifest},
}
service := &Service{
installations: store,
host: host,
}
got, err := service.MetadataProviderClient(ctx, 3, "metadb")
if err != nil {
t.Fatalf("MetadataProviderClient() returned error: %v", err)
}
if got != nil {
t.Fatalf("MetadataProviderClient() = %#v, want nil fake client", got)
}
if len(host.stopped) != 1 || host.stopped[0] != 3 {
t.Fatalf("stopped installations = %#v, want [3]", host.stopped)
}
if len(host.started) != 1 {
t.Fatalf("start calls = %d, want 1", len(host.started))
}
startReq := host.started[0]
if startReq.InstallationID != 3 {
t.Fatalf("start installation id = %d, want 3", startReq.InstallationID)
}
if startReq.BinaryPath != installPath {
t.Fatalf("start binary path = %q, want %q", startReq.BinaryPath, installPath)
}
if startReq.Manifest.GetVersion() != "0.0.36" {
t.Fatalf("start manifest version = %q, want 0.0.36", startReq.Manifest.GetVersion())
}
}
func TestServiceEnsureClientKeepsHealthyClientWhenInstalledManifestUnavailable(t *testing.T) {
ctx := context.Background()
installDir := t.TempDir()
installPath := filepath.Join(installDir, "plugin")
if err := os.WriteFile(installPath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatalf("WriteFile(%q) returned error: %v", installPath, err)
}
runningClient := &fakePluginClient{manifest: testPluginManifest(t, "silo.metadb", "0.0.36")}
store := newFakeServiceInstallationStore(&Installation{
ID: 3,
PluginID: "silo.metadb",
Version: "0.0.36",
InstallPath: installPath,
Enabled: true,
})
host := &fakeServiceHost{clientResult: runningClient}
service := &Service{
installations: store,
host: host,
}
got, err := service.ensureClient(ctx, 3)
if err != nil {
t.Fatalf("ensureClient() returned error: %v", err)
}
if got != runningClient {
t.Fatalf("ensureClient() returned %#v, want existing running client %#v", got, runningClient)
}
if len(host.started) != 0 {
t.Fatalf("start calls = %d, want 0", len(host.started))
}
if len(host.stopped) != 0 {
t.Fatalf("stop calls = %d, want 0", len(host.stopped))
}
}
func TestServiceEnsureClientRestartsWhenInstalledManifestDiffers(t *testing.T) {
ctx := context.Background()
installedManifest := testPluginManifest(t, "silo.metadb", "0.0.36")
installPath := writeInstalledPluginManifest(t, installedManifest)
runningClient := &fakePluginClient{manifest: testPluginManifest(t, "silo.metadb", "0.0.34")}
restartedClient := &fakePluginClient{manifest: installedManifest}
store := newFakeServiceInstallationStore(&Installation{
ID: 3,
PluginID: installedManifest.GetPluginId(),
Version: installedManifest.GetVersion(),
InstallPath: installPath,
Enabled: true,
})
host := &fakeServiceHost{
clientResult: runningClient,
startResult: restartedClient,
}
service := &Service{
installations: store,
host: host,
}
got, err := service.ensureClient(ctx, 3)
if err != nil {
t.Fatalf("ensureClient() returned error: %v", err)
}
if got != restartedClient {
t.Fatalf("ensureClient() returned %#v, want restarted client %#v", got, restartedClient)
}
if len(host.stopped) != 1 || host.stopped[0] != 3 {
t.Fatalf("stopped installations = %#v, want [3]", host.stopped)
}
if len(host.started) != 1 {
t.Fatalf("start calls = %d, want 1", len(host.started))
}
if host.started[0].Manifest.GetVersion() != "0.0.36" {
t.Fatalf("started manifest version = %q, want 0.0.36", host.started[0].Manifest.GetVersion())
}
}
func TestNewHostAdapterReturnsHost(t *testing.T) {
adapted := NewHostAdapter(pluginhost.NewHost(pluginhost.Config{}))
if adapted == nil {
t.Fatal("NewHostAdapter() = nil, want host adapter")
}
}
type fakeServiceHost struct {
clientResult pluginClient
clientErr error
startResult pluginClient
startErr error
started []pluginhost.StartRequest
stopped []int
events *[]string
}
func (f *fakeServiceHost) Start(_ context.Context, req pluginhost.StartRequest) (pluginClient, error) {
recordTestEvent(f.events, "start")
f.started = append(f.started, req)
if f.startErr != nil {
return nil, f.startErr
}
if f.startResult != nil {
return f.startResult, nil
}
return &fakePluginClient{manifest: req.Manifest}, nil
}
func (f *fakeServiceHost) Client(int) (pluginClient, error) {
if f.clientErr != nil {
return nil, f.clientErr
}
return f.clientResult, nil
}
func (f *fakeServiceHost) Stop(installationID int) error {
recordTestEvent(f.events, "stop")
f.stopped = append(f.stopped, installationID)
return nil
}
func (f *fakeServiceHost) Shutdown(context.Context) error {
return nil
}
type fakePluginClient struct {
manifest *pluginv1.PluginManifest
metadataProviderCalls int
}
func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
return f.manifest
}
func (f *fakePluginClient) MetadataProvider(string) (*pluginhost.MetadataProviderClient, error) {
f.metadataProviderCalls++
return nil, nil
}
func (f *fakePluginClient) ImageResolver(string) (*pluginhost.ImageResolverClient, error) {
return nil, nil
}
func (f *fakePluginClient) MarkerProvider(string) (*pluginhost.MarkerProviderClient, error) {
return nil, nil
}
func (f *fakePluginClient) MediaAnalyzer(string) (*pluginhost.MediaAnalyzerClient, error) {
return nil, nil
}
func (f *fakePluginClient) ScheduledTask(string) (*pluginhost.ScheduledTaskClient, error) {
return nil, nil
}
func (f *fakePluginClient) ScanSource(string) (*pluginhost.ScanSourceClient, error) {
return nil, nil
}
func (f *fakePluginClient) RequestRouter(string) (*pluginhost.RequestRouterClient, error) {
return nil, nil
}
func (f *fakePluginClient) EventConsumer(string) (*pluginhost.EventConsumerClient, error) {
return nil, nil
}
func (f *fakePluginClient) AuthProvider(string) (*pluginhost.AuthProviderClient, error) {
return nil, nil
}
func (f *fakePluginClient) HTTPRoutes(string) (*pluginhost.HTTPRoutesClient, error) {
return nil, nil
}
func (f *fakePluginClient) WatchSyncProvider(string) (*pluginhost.WatchSyncProviderClient, error) {
return nil, nil
}
type fakeServiceInstallationStore struct {
byID map[int]*Installation
byPluginID map[string][]*Installation
createInputs []CreateInstallationInput
updateIDs []int
updateInputs []UpdateInstallationInput
deleteIDs []int
saveArchiveIDs []int
saveArchiveErr error
listCapabilities []*Capability
events *[]string
}
func newFakeServiceInstallationStore(installations ...*Installation) *fakeServiceInstallationStore {
store := &fakeServiceInstallationStore{
byID: make(map[int]*Installation, len(installations)),
byPluginID: make(map[string][]*Installation),
}
for _, installation := range installations {
if installation == nil {
continue
}
cloned := *installation
store.byID[cloned.ID] = &cloned
store.byPluginID[cloned.PluginID] = append(store.byPluginID[cloned.PluginID], &cloned)
}
return store
}
func (s *fakeServiceInstallationStore) Create(_ context.Context, input CreateInstallationInput) (*Installation, error) {
recordTestEvent(s.events, "create")
s.createInputs = append(s.createInputs, input)
id := len(s.byID) + 1
installation := &Installation{
ID: id,
PluginID: input.PluginID,
Version: input.Version,
InstallPath: input.InstallPath,
Enabled: input.Enabled,
}
s.byID[id] = installation
s.byPluginID[input.PluginID] = append(s.byPluginID[input.PluginID], installation)
return installation, nil
}
func (s *fakeServiceInstallationStore) SaveArchive(_ context.Context, installationID int, _ []byte, _ string, _ []byte) error {
recordTestEvent(s.events, "save_archive")
s.saveArchiveIDs = append(s.saveArchiveIDs, installationID)
return s.saveArchiveErr
}
func (s *fakeServiceInstallationStore) Update(_ context.Context, id int, input UpdateInstallationInput) error {
recordTestEvent(s.events, "update")
s.updateIDs = append(s.updateIDs, id)
s.updateInputs = append(s.updateInputs, input)
installation, ok := s.byID[id]
if !ok {
return nil
}
if input.Version != nil {
installation.Version = *input.Version
}
if input.InstallPath != nil {
installation.InstallPath = *input.InstallPath
}
if input.Enabled != nil {
installation.Enabled = *input.Enabled
}
return nil
}
func (s *fakeServiceInstallationStore) Delete(_ context.Context, id int) error {
recordTestEvent(s.events, "delete")
s.deleteIDs = append(s.deleteIDs, id)
delete(s.byID, id)
return nil
}
func (s *fakeServiceInstallationStore) GetByID(_ context.Context, id int) (*Installation, error) {
installation, ok := s.byID[id]
if !ok {
return nil, ErrInstallationNotFound
}
cloned := *installation
return &cloned, nil
}
func (s *fakeServiceInstallationStore) List(context.Context) ([]*Installation, error) {
result := make([]*Installation, 0, len(s.byID))
for _, installation := range s.byID {
cloned := *installation
result = append(result, &cloned)
}
return result, nil
}
func (s *fakeServiceInstallationStore) ListEnabled(_ context.Context) ([]*Installation, error) {
var result []*Installation
for _, installation := range s.byID {
if !installation.Enabled {
continue
}
cloned := *installation
result = append(result, &cloned)
}
return result, nil
}
func (s *fakeServiceInstallationStore) ListByPluginID(_ context.Context, pluginID string) ([]*Installation, error) {
list := s.byPluginID[pluginID]
result := make([]*Installation, 0, len(list))
for _, installation := range list {
cloned := *installation
result = append(result, &cloned)
}
return result, nil
}
func (s *fakeServiceInstallationStore) ListCapabilities(context.Context, int) ([]*Capability, error) {
return s.listCapabilities, nil
}
func (s *fakeServiceInstallationStore) GetArchive(context.Context, int) (*InstallationArchive, error) {
return nil, ErrArchiveNotFound
}
func writeInstalledPluginManifest(t *testing.T, manifest *pluginv1.PluginManifest) string {
t.Helper()
installDir := t.TempDir()
installPath := filepath.Join(installDir, "plugin")
if err := os.WriteFile(installPath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatalf("WriteFile(%q) returned error: %v", installPath, err)
}
manifestBytes, err := protojson.Marshal(manifest)
if err != nil {
t.Fatalf("protojson.Marshal() returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(installDir, "manifest.json"), manifestBytes, 0644); err != nil {
t.Fatalf("WriteFile(manifest.json) returned error: %v", err)
}
return installPath
}
func writePluginArchive(t *testing.T, path string, manifest *pluginv1.PluginManifest) {
t.Helper()
binaryBytes := []byte("#!/bin/sh\nexit 0\n")
manifestCopy := proto.Clone(manifest).(*pluginv1.PluginManifest)
checksum := sha256.Sum256(binaryBytes)
manifestCopy.Checksum = hex.EncodeToString(checksum[:])
manifestBytes, err := protojson.Marshal(manifestCopy)
if err != nil {
t.Fatalf("protojson.Marshal() returned error: %v", err)
}
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(%q) returned error: %v", path, err)
}
defer file.Close()
writer := zip.NewWriter(file)
manifestEntry, err := writer.Create("manifest.json")
if err != nil {
t.Fatalf("Create(manifest.json) returned error: %v", err)
}
if _, err := manifestEntry.Write(manifestBytes); err != nil {
t.Fatalf("Write(manifest.json) returned error: %v", err)
}
binaryEntry, err := writer.Create("plugin")
if err != nil {
t.Fatalf("Create(plugin) returned error: %v", err)
}
if _, err := binaryEntry.Write(binaryBytes); err != nil {
t.Fatalf("Write(plugin) returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close() returned error: %v", err)
}
}
func recordTestEvent(events *[]string, event string) {
if events == nil {
return
}
*events = append(*events, event)
}