package plugins import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "log/slog" "os" "path/filepath" "sync/atomic" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "github.com/Silo-Server/silo-server/internal/pluginhost" ) type pluginClient interface { Manifest() *pluginv1.PluginManifest MetadataProvider(capabilityID string) (*pluginhost.MetadataProviderClient, error) MediaAnalyzer(capabilityID string) (*pluginhost.MediaAnalyzerClient, error) ScheduledTask(capabilityID string) (*pluginhost.ScheduledTaskClient, error) EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error) AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error) HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error) } type Host interface { Start(ctx context.Context, req pluginhost.StartRequest) (pluginClient, error) Client(installationID int) (pluginClient, error) Stop(installationID int) error Shutdown(ctx context.Context) error } type serviceInstallationStore interface { archiveStore GetByID(ctx context.Context, id int) (*Installation, error) List(ctx context.Context) ([]*Installation, error) ListEnabled(ctx context.Context) ([]*Installation, error) ListByPluginID(ctx context.Context, pluginID string) ([]*Installation, error) Update(ctx context.Context, id int, input UpdateInstallationInput) error ListCapabilities(ctx context.Context, installationID int) ([]*Capability, error) } type serviceConfigStore interface { ListGlobalConfigs(ctx context.Context, installationID int) ([]*RuntimeConfig, error) PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error } type Service struct { repositories *RepositoryStore installations serviceInstallationStore configs serviceConfigStore catalog *CatalogService installer *Installer archiveCache *ArchiveCache host Host testConfigSeq atomic.Int64 dispatcher *EventDispatcher } // SetEventDispatcher wires the EventDispatcher into the Service. The // dispatcher reference is retained so future hooks can act on lifecycle // changes; the current dispatcher implementation is fully driven by // per-event store reads and needs no notification on install/enable/disable. func (s *Service) SetEventDispatcher(d *EventDispatcher) { s.dispatcher = d } // OnLifecycleChange is a public hook invoked by API handlers and the installer // after every install / enable / disable / uninstall. It currently does // nothing — the dispatcher reads the live installation set on each event — // but the entry point is preserved so future cache-warming logic has a place // to attach without touching every call site again. func (s *Service) OnLifecycleChange(_ context.Context) {} func NewService( repositories *RepositoryStore, installations *InstallationStore, configs *RuntimeConfigStore, catalog *CatalogService, installer *Installer, host Host, ) *Service { return &Service{ repositories: repositories, installations: installations, configs: configs, catalog: catalog, installer: installer, archiveCache: NewArchiveCache(installations), host: host, } } func (s *Service) FetchCatalog(ctx context.Context) ([]CatalogEntry, error) { return s.catalog.Fetch(ctx) } func (s *Service) InstallLocal(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) { if req.ArchivePath == "" { return nil, fmt.Errorf("archive path is required") } data, err := os.ReadFile(req.ArchivePath) if err != nil { return nil, fmt.Errorf("read archive %q: %w", req.ArchivePath, err) } _, _, manifest, err := openPluginArchive(data) if err != nil { return nil, err } existing, err := s.existingInstallationByPluginID(ctx, manifest.GetPluginId()) if err != nil { return nil, err } var result *InstallResult if existing != nil { if err := s.stopInstallationIfRunning(existing); err != nil { return nil, err } result, err = s.installer.ReplaceLocal(ctx, existing, req) } else { result, err = s.installer.InstallLocal(ctx, req) } if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } func (s *Service) InstallRemote(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) { result, err := s.installer.InstallRemote(ctx, req) if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } func (s *Service) InstallCatalog(ctx context.Context, req InstallCatalogRequest) (*InstallResult, error) { target, err := s.catalog.ResolveInstall(ctx, req) if err != nil { return nil, err } repositoryID := target.RepositoryID existing, err := s.existingInstallationByPluginID(ctx, req.PluginID) if err != nil { return nil, err } var result *InstallResult if target.LegacyArchive { archiveReq := InstallArchiveRequest{ ArchiveURL: target.ArchiveURL, RepositoryID: &repositoryID, } if existing == nil { result, err = s.installer.InstallRemote(ctx, archiveReq) } else { if err = s.stopInstallationIfRunning(existing); err != nil { return nil, err } result, err = s.installer.ReplaceRemote(ctx, existing, archiveReq) } } else { binaryReq := InstallBinaryRequest{ BinaryURL: target.ArchiveURL, Checksum: target.Checksum, RepositoryID: &repositoryID, } if existing == nil { result, err = s.installer.InstallBinary(ctx, binaryReq) } else { if err = s.stopInstallationIfRunning(existing); err != nil { return nil, err } result, err = s.installer.ReplaceBinary(ctx, existing, binaryReq) } } if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } // UpdateToAvailableVersion updates a plugin to its available_version. // Returns the updated installation after the update completes. func (s *Service) UpdateToAvailableVersion(ctx context.Context, installationID int) (*Installation, error) { installation, err := s.installations.GetByID(ctx, installationID) if err != nil { return nil, err } if installation.AvailableVersion == nil || *installation.AvailableVersion == "" { return nil, fmt.Errorf("no update available for plugin %q", installation.PluginID) } targetVersion := *installation.AvailableVersion if installation.RepositoryID == nil || *installation.RepositoryID == 0 { return nil, fmt.Errorf("plugin %q has no repository_id, cannot update from catalog", installation.PluginID) } _, err = s.InstallCatalog(ctx, InstallCatalogRequest{ RepositoryID: *installation.RepositoryID, PluginID: installation.PluginID, Version: targetVersion, }) if err != nil { return nil, fmt.Errorf("update plugin %q to %s: %w", installation.PluginID, targetVersion, err) } // Clear available_version now that we've updated. empty := "" if err := s.installations.Update(ctx, installationID, UpdateInstallationInput{ AvailableVersion: &empty, }); err != nil { slog.Warn("failed to clear available_version after update", "installation_id", installationID, "error", err) } // Reload and return the updated installation. return s.installations.GetByID(ctx, installationID) } func (s *Service) InstallBinary(ctx context.Context, req InstallBinaryRequest) (*InstallResult, error) { var result *InstallResult var err error if req.Manifest != nil && req.Manifest.GetPluginId() != "" { existing, existErr := s.existingInstallationByPluginID(ctx, req.Manifest.GetPluginId()) if existErr != nil { return nil, existErr } if existing != nil { if err = s.stopInstallationIfRunning(existing); err != nil { return nil, err } result, err = s.installer.ReplaceBinary(ctx, existing, req) if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } } result, err = s.installer.InstallBinary(ctx, req) if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } func (s *Service) InstallBinaryUpload(ctx context.Context, binaryData []byte) (*InstallResult, error) { if len(binaryData) == 0 { return nil, fmt.Errorf("binary data is required") } manifest, err := loadManifestFromBinary(ctx, binaryData) if err != nil { return nil, err } checksum := sha256.Sum256(binaryData) actualChecksum := hex.EncodeToString(checksum[:]) var result *InstallResult if s.installations == nil { result, err = s.installer.installBinary(ctx, binaryData, actualChecksum, manifest, nil) if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } existing, err := s.installations.ListByPluginID(ctx, manifest.GetPluginId()) if err != nil { return nil, fmt.Errorf("list existing plugin installations for %q: %w", manifest.GetPluginId(), err) } if len(existing) == 0 { result, err = s.installer.installBinary(ctx, binaryData, actualChecksum, manifest, nil) if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } if len(existing) > 1 { return nil, fmt.Errorf("multiple existing installations found for plugin %q", manifest.GetPluginId()) } oldInstallation := existing[0] if err := s.stopInstallationIfRunning(oldInstallation); err != nil { return nil, err } result, err = s.installer.replaceBinary(ctx, oldInstallation, binaryData, actualChecksum, manifest) if err != nil { return nil, err } s.OnLifecycleChange(ctx) return result, nil } func (s *Service) existingInstallationByPluginID(ctx context.Context, pluginID string) (*Installation, error) { if s.installations == nil || pluginID == "" { return nil, nil } existing, err := s.installations.ListByPluginID(ctx, pluginID) if err != nil { return nil, fmt.Errorf("list existing plugin installations for %q: %w", pluginID, err) } if len(existing) == 0 { return nil, nil } if len(existing) > 1 { return nil, fmt.Errorf("multiple existing installations found for plugin %q", pluginID) } return existing[0], nil } func (s *Service) stopInstallationIfRunning(existing *Installation) error { if existing == nil || !existing.Enabled || s.host == nil { return nil } if err := s.host.Stop(existing.ID); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) { return fmt.Errorf("stop existing plugin installation %d: %w", existing.ID, err) } return nil } func (s *Service) PreloadEnabled(ctx context.Context) error { if s.installations == nil { return nil } installations, err := s.installations.ListEnabled(ctx) if err != nil { return err } for _, installation := range installations { if installation == nil { continue } if _, err := s.ensureLoadedInstallation(ctx, installation); err != nil { if errors.Is(err, ErrArchiveNotFound) { slog.Warn( "plugin preload skipped: archive not found for enabled installation", "installation_id", installation.ID, "plugin_id", installation.PluginID, "version", installation.Version, ) continue } return fmt.Errorf("preload plugin installation %d: %w", installation.ID, err) } } s.OnLifecycleChange(ctx) return nil } func (s *Service) Start(ctx context.Context, installationID int) (pluginClient, error) { installation, manifest, err := s.ensureInstallationCache(ctx, installationID, true) if err != nil { return nil, err } configEntries, err := s.globalConfigEntries(ctx, installation.ID) if err != nil { return nil, err } return s.host.Start(ctx, pluginhost.StartRequest{ InstallationID: installation.ID, BinaryPath: installation.InstallPath, Manifest: manifest, Config: configEntries, }) } func (s *Service) Stop(installationID int) error { if s.host == nil { return nil } return s.host.Stop(installationID) } func (s *Service) MediaAnalyzerClient( ctx context.Context, installationID int, capabilityID string, ) (*pluginhost.MediaAnalyzerClient, error) { client, err := s.ensureClient(ctx, installationID) if err != nil { return nil, err } return client.MediaAnalyzer(capabilityID) } func (s *Service) MetadataProviderClient( ctx context.Context, installationID int, capabilityID string, ) (*pluginhost.MetadataProviderClient, error) { client, err := s.ensureClient(ctx, installationID) if err != nil { return nil, err } return client.MetadataProvider(capabilityID) } func (s *Service) ScheduledTaskClient( ctx context.Context, installationID int, capabilityID string, ) (*pluginhost.ScheduledTaskClient, error) { client, err := s.ensureClient(ctx, installationID) if err != nil { return nil, err } return client.ScheduledTask(capabilityID) } func (s *Service) EventConsumerClient( ctx context.Context, installationID int, capabilityID string, ) (*pluginhost.EventConsumerClient, error) { client, err := s.ensureClient(ctx, installationID) if err != nil { return nil, err } return client.EventConsumer(capabilityID) } func (s *Service) AuthProviderClient( ctx context.Context, installationID int, capabilityID string, ) (*pluginhost.AuthProviderClient, error) { client, err := s.ensureClient(ctx, installationID) if err != nil { return nil, err } return client.AuthProvider(capabilityID) } func (s *Service) HTTPRoutesClient( ctx context.Context, installationID int, capabilityID string, ) (*pluginhost.HTTPRoutesClient, error) { client, err := s.ensureClient(ctx, installationID) if err != nil { return nil, err } return client.HTTPRoutes(capabilityID) } func (s *Service) RouteDescriptors(ctx context.Context, installationID int) ([]*pluginv1.HttpRouteDescriptor, error) { manifest, err := s.manifestForInstallation(ctx, installationID, true) if err != nil { return nil, err } return append([]*pluginv1.HttpRouteDescriptor(nil), manifest.GetHttpRoutes()...), nil } func (s *Service) ResolveAssetPath(ctx context.Context, installationID int, assetPath string) (string, error) { installation, manifest, err := s.ensureInstallationCache(ctx, installationID, true) if err != nil { return "", err } for _, asset := range manifest.GetAssets() { if asset.GetPath() == assetPath { resolved := filepath.Join(filepath.Dir(installation.InstallPath), assetPath) if _, err := os.Stat(resolved); err != nil { return "", fmt.Errorf("plugin asset %q: %w", assetPath, err) } return resolved, nil } } return "", fmt.Errorf("plugin asset %q not found", assetPath) } func (s *Service) UserConfigSchema(ctx context.Context, installationID int) ([]*pluginv1.ConfigSchema, error) { manifest, err := s.manifestForInstallation(ctx, installationID, false) if err != nil { return nil, err } return append([]*pluginv1.ConfigSchema(nil), manifest.GetUserConfigSchema()...), nil } func (s *Service) ManifestForInstallation( ctx context.Context, installationID int, ) (*pluginv1.PluginManifest, error) { return s.manifestForInstallation(ctx, installationID, false) } func (s *Service) ensureClient(ctx context.Context, installationID int) (pluginClient, error) { installation, err := s.loadInstallation(ctx, installationID, true) if err != nil { return nil, err } client, err := s.host.Client(installationID) if err == nil { installedManifest, manifestErr := LoadManifestFile(InstalledManifestPath(installation.InstallPath)) if manifestErr != nil { slog.Warn("plugin installed manifest unavailable; reusing healthy client", "installation_id", installation.ID, "plugin_id", installation.PluginID, "version", installation.Version, "error", manifestErr, ) return client, nil } cachedManifest := client.Manifest() if cachedManifest != nil && proto.Equal(cachedManifest, installedManifest) { return client, nil } slog.Warn("plugin client manifest drift detected; restarting", "installation_id", installation.ID, "plugin_id", installation.PluginID, "cached_plugin_id", manifestPluginID(cachedManifest), "installed_plugin_id", installedManifest.GetPluginId(), "cached_version", manifestVersion(cachedManifest), "installed_version", installedManifest.GetVersion(), ) if stopErr := s.host.Stop(installationID); stopErr != nil && !errors.Is(stopErr, pluginhost.ErrClientNotFound) { return nil, fmt.Errorf("stop stale plugin installation %d: %w", installationID, stopErr) } return s.Start(ctx, installationID) } if errors.Is(err, pluginhost.ErrPluginUnhealthy) { if stopErr := s.host.Stop(installationID); stopErr != nil && !errors.Is(stopErr, pluginhost.ErrClientNotFound) { return nil, fmt.Errorf("stop unhealthy plugin installation %d: %w", installationID, stopErr) } return s.Start(ctx, installationID) } if errors.Is(err, pluginhost.ErrClientNotFound) { return s.Start(ctx, installationID) } return nil, err } func (s *Service) manifestForInstallation(ctx context.Context, installationID int, requireEnabled bool) (*pluginv1.PluginManifest, error) { _, manifest, err := s.ensureInstallationCache(ctx, installationID, requireEnabled) return manifest, err } func (s *Service) ensureInstallationCache( ctx context.Context, installationID int, requireEnabled bool, ) (*Installation, *pluginv1.PluginManifest, error) { installation, err := s.loadInstallation(ctx, installationID, requireEnabled) if err != nil { return nil, nil, err } manifest, err := s.ensureLoadedInstallation(ctx, installation) if err != nil { return nil, nil, err } return installation, manifest, nil } func (s *Service) loadInstallation(ctx context.Context, installationID int, requireEnabled bool) (*Installation, error) { installation, err := s.installations.GetByID(ctx, installationID) if err != nil { return nil, err } if requireEnabled && !installation.Enabled { return nil, ErrInstallationDisabled } return installation, nil } func (s *Service) ensureLoadedInstallation( ctx context.Context, installation *Installation, ) (*pluginv1.PluginManifest, error) { if s.archiveCache == nil { return LoadManifestFile(InstalledManifestPath(installation.InstallPath)) } return s.archiveCache.Ensure(ctx, installation) } func (s *Service) globalConfigEntries(ctx context.Context, installationID int) ([]*pluginv1.ConfigEntry, error) { if s.configs == nil { return nil, nil } configs, err := s.configs.ListGlobalConfigs(ctx, installationID) if err != nil { return nil, fmt.Errorf("list plugin runtime configs for installation %d: %w", installationID, err) } entries := make([]*pluginv1.ConfigEntry, 0, len(configs)) for _, config := range configs { if config == nil { continue } value := config.Value if value == nil { value = map[string]any{} } structValue, err := structpb.NewStruct(value) if err != nil { return nil, fmt.Errorf( "encode runtime config %q for installation %d: %w", config.Key, installationID, err, ) } entries = append(entries, &pluginv1.ConfigEntry{ Key: config.Key, Value: structValue, }) } return entries, nil }