diff --git a/internal/homeplugins/sync.go b/internal/homeplugins/sync.go index 4210ca5b2..3e8942772 100644 --- a/internal/homeplugins/sync.go +++ b/internal/homeplugins/sync.go @@ -254,42 +254,70 @@ func deletePluginArtifact(root string, id string, pluginRuntime PluginRuntime) ( if !validPluginFileID(id) { return "", false, fmt.Errorf("invalid plugin id %q", id) } - path, errPath := currentPluginFilePath(root, id) - if errPath != nil { - return "", false, errPath + paths, errPaths := pluginFilePaths(root, id) + if errPaths != nil { + return "", false, errPaths } - if path == "" { + if len(paths) == 0 { return "", false, nil } if pluginRuntime != nil && pluginRuntime.PluginBusy(id) { if !pluginRuntime.UnloadPlugin(id) && pluginRuntime.PluginBusy(id) { - return path, false, sdkpluginstore.ErrLoadedPluginLocked + return paths[0], false, sdkpluginstore.ErrLoadedPluginLocked } } - if errRemove := os.Remove(path); errRemove != nil { - if errors.Is(errRemove, os.ErrNotExist) { - return path, false, nil + deleted := false + for _, path := range paths { + if errRemove := os.Remove(path); errRemove != nil { + if errors.Is(errRemove, os.ErrNotExist) { + continue + } + return paths[0], deleted, errRemove } - return path, false, errRemove + deleted = true } - return path, true, nil + return paths[0], deleted, nil } func currentPluginFilePath(root string, id string) (string, error) { + paths, errPaths := pluginFilePaths(root, id) + if errPaths != nil { + return "", errPaths + } + if len(paths) == 0 { + return "", nil + } + return paths[0], nil +} + +func pluginFilePaths(root string, id string) ([]string, error) { + files, errFiles := pluginFileInfos(root, id) + if errFiles != nil { + return nil, errFiles + } + out := make([]string, 0, len(files)) + for _, file := range files { + out = append(out, file.Path) + } + return out, nil +} + +func pluginFileInfos(root string, id string) ([]pluginFileInfo, error) { root = strings.TrimSpace(root) if root == "" { root = "plugins" } + id = strings.TrimSpace(id) platform := CurrentPlatform() extension := pluginExtension(platform.GOOS) - var selected pluginFileInfo + candidates := make([]pluginFileInfo, 0) for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH, platform.Variant) { entries, errReadDir := os.ReadDir(dir) if errReadDir != nil { if errors.Is(errReadDir, os.ErrNotExist) { continue } - return "", errReadDir + return nil, errReadDir } files := make([]string, 0, len(entries)) for _, entry := range entries { @@ -306,12 +334,30 @@ func currentPluginFilePath(root string, id string) (string, error) { if !okFile || file.ID != id { continue } - if pluginFilePreferred(file, selected) { - selected = file - } + candidates = append(candidates, file) } } - return selected.Path, nil + if len(candidates) <= 1 { + return candidates, nil + } + bestIndex := 0 + for index := 1; index < len(candidates); index++ { + if pluginFilePreferred(candidates[index], candidates[bestIndex]) { + bestIndex = index + } + } + if bestIndex == 0 { + return candidates, nil + } + out := make([]pluginFileInfo, 0, len(candidates)) + out = append(out, candidates[bestIndex]) + for index, candidate := range candidates { + if index == bestIndex { + continue + } + out = append(out, candidate) + } + return out, nil } type pluginFileInfo struct { diff --git a/internal/homeplugins/sync_test.go b/internal/homeplugins/sync_test.go index fc4d3275d..5421cb6a0 100644 --- a/internal/homeplugins/sync_test.go +++ b/internal/homeplugins/sync_test.go @@ -62,7 +62,7 @@ func TestSyncPlatformInstallsManifestArtifact(t *testing.T) { if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil { t.Fatalf("SyncPlatform() error = %v", errSync) } - target := filepath.Join(root, "windows", "amd64", "sample.dll") + target := pluginTestPath(root, "windows", "amd64", "sample", "0.2.0") got, errRead := os.ReadFile(target) if errRead != nil { t.Fatalf("read target: %v", errRead) @@ -105,7 +105,7 @@ func TestSyncPlatformWithReportRecordsSuccessfulInstall(t *testing.T) { if plugin.ID != "sample" || plugin.InstallStatus != pluginInstallStatusInstalled || plugin.Version != "0.2.0" { t.Fatalf("plugin report = %+v, want installed sample 0.2.0", plugin) } - if wantPath := filepath.Join(root, "windows", "amd64", "sample.dll"); plugin.Path != wantPath { + if wantPath := pluginTestPath(root, "windows", "amd64", "sample", "0.2.0"); plugin.Path != wantPath { t.Fatalf("plugin path = %q, want %q", plugin.Path, wantPath) } } @@ -116,7 +116,7 @@ func TestSyncPlatformWithReportRecordsSkippedIdenticalArtifact(t *testing.T) { if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } - target := filepath.Join(targetDir, "sample.dll") + target := filepath.Join(targetDir, "sample-v0.2.0.dll") if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } @@ -159,7 +159,7 @@ func TestSyncPlatformSkipsIdenticalBusyPlugin(t *testing.T) { if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } - target := filepath.Join(targetDir, "sample.dll") + target := filepath.Join(targetDir, "sample-v0.2.0.dll") if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } @@ -329,6 +329,43 @@ func TestDeleteWithReportRemovesCurrentPlatformPlugin(t *testing.T) { } } +func TestDeleteWithReportRemovesAllCurrentPlatformPluginVersions(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll() error = %v", errMkdir) + } + extension := pluginExtension(runtime.GOOS) + olderTarget := filepath.Join(targetDir, "sample-v0.2.0"+extension) + newerTarget := filepath.Join(targetDir, "sample-v0.3.0"+extension) + otherTarget := filepath.Join(targetDir, "other-v0.3.0"+extension) + for _, target := range []string{olderTarget, newerTarget, otherTarget} { + if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { + t.Fatalf("WriteFile(%s) error = %v", target, errWrite) + } + } + runtimeHost := &fakePluginRuntime{busy: true} + + report := DeleteWithReport(context.Background(), syncTestConfig(t, root), runtimeHost, 43, "sample") + if !report.OK { + t.Fatalf("report = %+v, want successful delete task", report) + } + if len(runtimeHost.unloaded) != 1 || runtimeHost.unloaded[0] != "sample" { + t.Fatalf("UnloadPlugin calls = %v, want sample", runtimeHost.unloaded) + } + if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusDeleted || report.Plugins[0].Path != newerTarget { + t.Fatalf("plugin report = %+v, want deleted representative target %s", report.Plugins, newerTarget) + } + for _, target := range []string{olderTarget, newerTarget} { + if _, errStat := os.Stat(target); !os.IsNotExist(errStat) { + t.Fatalf("target %s stat error = %v, want not exist", target, errStat) + } + } + if _, errStat := os.Stat(otherTarget); errStat != nil { + t.Fatalf("other plugin stat error = %v, want retained", errStat) + } +} + func TestDeleteWithReportMissingPluginIsSuccess(t *testing.T) { report := DeleteWithReport(context.Background(), syncTestConfig(t, t.TempDir()), nil, 7, "missing") if !report.OK || report.Status != pluginTaskStatusOK { @@ -363,6 +400,15 @@ store: } } +func pluginTestPath(root string, goos string, goarch string, id string, version string) string { + name := strings.TrimSpace(id) + version = strings.TrimSpace(version) + if version != "" { + name += "-v" + version + } + return filepath.Join(root, goos, goarch, name+pluginExtension(goos)) +} + func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig { t.Helper() var item config.PluginInstanceConfig diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go index 2038a0128..9d6fffcb3 100644 --- a/internal/logging/global_logger.go +++ b/internal/logging/global_logger.go @@ -33,10 +33,12 @@ type LogFormatter struct{} var logFieldOrder = []string{ "provider", "model", "plugin_id", "plugin_name", "source_id", - "version", "overwritten", + "version", "active_version", "retired_version", "overwritten", "mode", "budget", "level", "original_mode", "original_value", "min", "max", "clamped_to", "error", } +var pluginPathFieldOrder = []string{"path", "active_path", "retired_path"} + // Format renders a single log entry with custom formatting. func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { var buffer *bytes.Buffer @@ -70,8 +72,10 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { } } if pluginID, ok := entry.Data["plugin_id"]; ok && strings.TrimSpace(fmt.Sprint(pluginID)) != "" { - if v, ok := entry.Data["path"]; ok { - fields = append(fields, fmt.Sprintf("path=%v", v)) + for _, k := range pluginPathFieldOrder { + if v, ok := entry.Data[k]; ok { + fields = append(fields, fmt.Sprintf("%s=%v", k, v)) + } } } if len(fields) > 0 { diff --git a/internal/logging/global_logger_test.go b/internal/logging/global_logger_test.go index f041e3dc0..417a4e65f 100644 --- a/internal/logging/global_logger_test.go +++ b/internal/logging/global_logger_test.go @@ -34,7 +34,11 @@ func TestLogFormatterPrintsPluginFields(t *testing.T) { entry.Data["plugin_id"] = "sample-provider" entry.Data["plugin_name"] = "Sample Provider" entry.Data["version"] = "0.2.0" + entry.Data["active_version"] = "0.1.0" + entry.Data["retired_version"] = "0.2.0" entry.Data["path"] = "plugins/windows/amd64/sample-provider-v0.2.0.dll" + entry.Data["active_path"] = "plugins/windows/amd64/sample-provider-v0.1.0.dll" + entry.Data["retired_path"] = "plugins/windows/amd64/sample-provider-v0.2.0.dll" formatted, errFormat := (&LogFormatter{}).Format(entry) if errFormat != nil { @@ -46,7 +50,11 @@ func TestLogFormatterPrintsPluginFields(t *testing.T) { "plugin_id=sample-provider", "plugin_name=Sample Provider", "version=0.2.0", + "active_version=0.1.0", + "retired_version=0.2.0", "path=plugins/windows/amd64/sample-provider-v0.2.0.dll", + "active_path=plugins/windows/amd64/sample-provider-v0.1.0.dll", + "retired_path=plugins/windows/amd64/sample-provider-v0.2.0.dll", } { if !strings.Contains(line, want) { t.Fatalf("formatted line %q missing %s", line, want) @@ -60,6 +68,8 @@ func TestLogFormatterOmitsGenericPathField(t *testing.T) { entry.Level = log.WarnLevel entry.Message = "failed to roll back token" entry.Data["path"] = "auths/private-token.json" + entry.Data["active_path"] = "plugins/windows/amd64/sample-provider-v0.1.0.dll" + entry.Data["retired_path"] = "plugins/windows/amd64/sample-provider-v0.2.0.dll" formatted, errFormat := (&LogFormatter{}).Format(entry) if errFormat != nil { @@ -67,7 +77,9 @@ func TestLogFormatterOmitsGenericPathField(t *testing.T) { } line := string(formatted) - if strings.Contains(line, "path=") { - t.Fatalf("formatted line %q contains generic path field", line) + for _, forbidden := range []string{"path=", "active_path=", "retired_path="} { + if strings.Contains(line, forbidden) { + t.Fatalf("formatted line %q contains generic %s field", line, forbidden) + } } } diff --git a/internal/pluginhost/config.go b/internal/pluginhost/config.go index be3396379..a004eea9d 100644 --- a/internal/pluginhost/config.go +++ b/internal/pluginhost/config.go @@ -22,6 +22,7 @@ type runtimeItemConfig struct { ID string Enabled bool Priority int + Version string ConfigYAML []byte } @@ -57,6 +58,7 @@ func runtimeConfigFromConfig(cfg *config.Config) runtimeConfig { ID: id, Enabled: enabled, Priority: item.Priority, + Version: pluginConfigDesiredVersion(item), ConfigYAML: runtimeConfigYAML(item, enabled), } } @@ -81,6 +83,73 @@ func runtimeConfigYAML(item config.PluginInstanceConfig, enabled bool) []byte { return append(append([]byte(nil), rawYAML...), '\n') } +func desiredPluginVersions(items map[string]runtimeItemConfig) map[string]string { + if len(items) == 0 { + return nil + } + out := make(map[string]string, len(items)) + for id, item := range items { + id = strings.TrimSpace(id) + version := strings.TrimSpace(item.Version) + if id == "" || version == "" { + continue + } + out[id] = version + } + if len(out) == 0 { + return nil + } + return out +} + +func pluginConfigDesiredVersion(item config.PluginInstanceConfig) string { + storeNode := yamlMappingValue(&item.Raw, "store") + if storeNode == nil { + return "" + } + if version := normalizePluginDesiredVersion(yamlScalarString(yamlMappingValue(storeNode, "version"))); version != "" { + return version + } + return normalizePluginDesiredVersion(yamlScalarString(yamlMappingValue(storeNode, "release-tag"))) +} + +func normalizePluginDesiredVersion(version string) string { + version = strings.TrimSpace(version) + if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') { + version = version[1:] + } + if !validPluginVersion(version) { + return "" + } + return version +} + +func yamlScalarString(node *yaml.Node) string { + if node == nil || node.Kind == 0 { + return "" + } + if node.Kind == yaml.ScalarNode { + return strings.TrimSpace(node.Value) + } + var value string + if errDecode := node.Decode(&value); errDecode != nil { + return "" + } + return strings.TrimSpace(value) +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index] != nil && node.Content[index].Value == key { + return node.Content[index+1] + } + } + return nil +} + func normalizedConfigNode(item config.PluginInstanceConfig, enabled bool) *yaml.Node { if item.Raw.Kind == 0 { return defaultRuntimeConfigNode(enabled, item.Priority) diff --git a/internal/pluginhost/config_test.go b/internal/pluginhost/config_test.go index adabfe1f6..cc5b98995 100644 --- a/internal/pluginhost/config_test.go +++ b/internal/pluginhost/config_test.go @@ -49,3 +49,51 @@ func TestRuntimeConfigYAMLDefaultsEnabledFalse(t *testing.T) { } } } + +func TestRuntimeConfigFromConfigExtractsStoreVersion(t *testing.T) { + var node yaml.Node + if errDecode := yaml.Unmarshal([]byte("store:\n version: 1.0.3\n release-tag: v1.0.3\n"), &node); errDecode != nil { + t.Fatalf("yaml.Unmarshal() error = %v", errDecode) + } + enabled := true + cfg := &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": { + Enabled: &enabled, + Raw: *node.Content[0], + }, + }, + }, + } + + got := runtimeConfigFromConfig(cfg) + if got.Items["alpha"].Version != "1.0.3" { + t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version) + } +} + +func TestRuntimeConfigFromConfigDerivesStoreVersionFromReleaseTag(t *testing.T) { + var node yaml.Node + if errDecode := yaml.Unmarshal([]byte("store:\n release-tag: v1.0.3\n"), &node); errDecode != nil { + t.Fatalf("yaml.Unmarshal() error = %v", errDecode) + } + enabled := true + cfg := &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": { + Enabled: &enabled, + Raw: *node.Content[0], + }, + }, + }, + } + + got := runtimeConfigFromConfig(cfg) + if got.Items["alpha"].Version != "1.0.3" { + t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version) + } +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 082632687..301945a0d 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -201,7 +202,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { return } - files, errSelect := selectPluginFiles(rc.Dir) + desiredVersions := desiredPluginVersions(rc.Items) + files, errSelect := selectPluginFiles(rc.Dir, desiredVersions) if errSelect != nil { log.Warnf("pluginhost: failed to select plugin files: %v", errSelect) h.mu.Lock() @@ -213,9 +215,11 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { h.refreshThinkingProviders(nil) return } + files = h.withLoadedPluginFallbacks(files, rc.Items, desiredVersions) records := make([]capabilityRecord, 0, len(files)) loadedFiles := make([]pluginFile, 0, len(files)) + hotReloadLogs := make([]log.Fields, 0) for _, file := range files { item, ok := rc.Items[file.ID] if !ok { @@ -238,6 +242,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { } loadedNow := false + var hotReloadFields log.Fields if lp == nil { h.mu.Lock() h.loading[file.ID] = struct{}{} @@ -255,6 +260,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { // so a nil read cannot race into a duplicate load. lp = loaded if replaced != nil { + hotReloadFields = pluginHotReloadLogFields(file.ID, file.Version, file.Path, replaced.version, replaced.path) h.retireLoadedPluginLocked(replaced) delete(h.fused, file.ID) h.removePluginRuntimeStateLocked(file.ID) @@ -281,6 +287,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { if loadedNow { log.WithFields(pluginLogFieldsFromMetadata(file.ID, plugin.Metadata, file.Path)).Info("pluginhost: plugin registered") } + if hotReloadFields != nil { + hotReloadLogs = append(hotReloadLogs, hotReloadFields) + } records = append(records, capabilityRecord{ id: file.ID, path: file.Path, @@ -302,6 +311,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { h.snapshot.Store(&Snapshot{enabled: true, records: records}) h.mu.Unlock() h.refreshThinkingProviders(records) + for _, fields := range hotReloadLogs { + log.WithFields(fields).Info("pluginhost: plugin hot reloaded") + } if cleanupFiles && len(loadedFiles) > 0 { if errCleanup := cleanupUnselectedPluginFiles(rc.Dir, loadedFiles); errCleanup != nil { log.Warnf("pluginhost: failed to clean old plugin files: %v", errCleanup) @@ -323,6 +335,46 @@ func (h *Host) load(file pluginFile) (*loadedPlugin, error) { }, nil } +func (h *Host) withLoadedPluginFallbacks(files []pluginFile, items map[string]runtimeItemConfig, desired map[string]string) []pluginFile { + if h == nil || len(desired) == 0 { + return files + } + selected := make(map[string]struct{}, len(files)) + for _, file := range files { + id := strings.TrimSpace(file.ID) + if id != "" { + selected[id] = struct{}{} + } + } + ids := make([]string, 0, len(desired)) + for id := range desired { + ids = append(ids, id) + } + sort.Strings(ids) + + h.mu.Lock() + defer h.mu.Unlock() + for _, id := range ids { + if _, ok := selected[id]; ok { + continue + } + if item, ok := items[id]; ok && !item.Enabled { + continue + } + lp := h.loaded[id] + if lp == nil || strings.TrimSpace(lp.path) == "" { + continue + } + files = append(files, pluginFile{ + ID: id, + Path: lp.path, + Version: strings.TrimSpace(lp.version), + }) + selected[id] = struct{}{} + } + return files +} + // UnloadPlugin removes one plugin from the active runtime and closes its dynamic library. func (h *Host) UnloadPlugin(id string) bool { if h == nil { diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go index d1a2d1764..483fb8484 100644 --- a/internal/pluginhost/host_test.go +++ b/internal/pluginhost/host_test.go @@ -607,6 +607,144 @@ func TestHostApplyConfigLogsLoadedAndRegisteredOnlyOnInitialLoad(t *testing.T) { } } +func TestHostApplyConfigLogsHotReloadActiveAndRetiredVersions(t *testing.T) { + var out bytes.Buffer + originalOut := log.StandardLogger().Out + originalFormatter := log.StandardLogger().Formatter + originalLevel := log.GetLevel() + log.SetOutput(&out) + log.SetFormatter(&log.TextFormatter{ + DisableColors: true, + DisableTimestamp: true, + }) + log.SetLevel(log.InfoLevel) + t.Cleanup(func() { + log.SetOutput(originalOut) + log.SetFormatter(originalFormatter) + log.SetLevel(originalLevel) + }) + + loader := newTestSymbolLoader() + loader.lookups["alpha"] = newTestSymbolLookup(&testPlugin{ + registerResult: validTestPlugin("alpha"), + }) + h := NewForTest(loader) + t.Cleanup(h.ShutdownAll) + pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.4") + + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": enabledPluginConfigWithStoreVersion(t, "1.0.4"), + }, + }, + }) + paths["1.0.3"] = writeVersionedPluginFile(t, pluginsDir, "alpha", "1.0.3") + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": enabledPluginConfigWithStoreVersion(t, "1.0.3"), + }, + }, + }) + + if !h.pluginIdentityCurrent("alpha", paths["1.0.3"], "1.0.3") { + t.Fatalf("active plugin identity did not switch to %s", paths["1.0.3"]) + } + if h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") { + t.Fatalf("old plugin identity is still active: %s", paths["1.0.4"]) + } + + logs := out.String() + if count := strings.Count(logs, `msg="pluginhost: plugin hot reloaded"`); count != 1 { + t.Fatalf("plugin hot reloaded log count = %d, want 1\n%s", count, logs) + } + for _, want := range []string{ + "plugin_id=alpha", + "active_version=1.0.3", + "retired_version=1.0.4", + "active_path=", + "retired_path=", + "alpha-v1.0.3", + "alpha-v1.0.4", + } { + if !strings.Contains(logs, want) { + t.Fatalf("plugin hot reload log missing %s:\n%s", want, logs) + } + } +} + +func TestHostApplyConfigKeepsLoadedVersionWhenPinnedVersionMissing(t *testing.T) { + loader := newTestSymbolLoader() + plugin := &testPlugin{ + registerResult: validTestPlugin("alpha"), + reconfigureResult: validTestPlugin("alpha"), + } + loader.lookups["alpha"] = newTestSymbolLookup(plugin) + h := NewForTest(loader) + t.Cleanup(h.ShutdownAll) + pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.4") + + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": enabledPluginConfigWithStoreVersion(t, "1.0.4"), + }, + }, + }) + if !h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") { + t.Fatalf("active plugin identity did not start at %s", paths["1.0.4"]) + } + + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": enabledPluginConfigWithStoreVersion(t, "1.0.5"), + }, + }, + }) + if !h.PluginRegistered("alpha") { + t.Fatal("PluginRegistered(alpha) = false, want old version to remain active while pinned version is missing") + } + if !h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") { + t.Fatalf("active plugin identity changed before pinned version was available") + } + if loader.openCalls != 1 { + t.Fatalf("Open calls = %d, want 1 while reusing loaded plugin", loader.openCalls) + } + if plugin.registerCalls != 1 || plugin.reconfigureCalls != 1 { + t.Fatalf("calls = register %d reconfigure %d, want 1/1", plugin.registerCalls, plugin.reconfigureCalls) + } + + paths["1.0.5"] = writeVersionedPluginFile(t, pluginsDir, "alpha", "1.0.5") + h.ApplyConfig(context.Background(), &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "alpha": enabledPluginConfigWithStoreVersion(t, "1.0.5"), + }, + }, + }) + if !h.pluginIdentityCurrent("alpha", paths["1.0.5"], "1.0.5") { + t.Fatalf("active plugin identity did not switch after pinned version was available") + } + if h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") { + t.Fatal("old plugin identity is still active after pinned version became available") + } + if loader.openCalls != 2 { + t.Fatalf("Open calls = %d, want 2 after loading pinned version", loader.openCalls) + } +} + func TestHostApplyConfigLogsLoadedWhenRegistrationInvalid(t *testing.T) { var out bytes.Buffer originalOut := log.StandardLogger().Out diff --git a/internal/pluginhost/logging.go b/internal/pluginhost/logging.go index 838573115..e4c48a6a1 100644 --- a/internal/pluginhost/logging.go +++ b/internal/pluginhost/logging.go @@ -26,3 +26,22 @@ func pluginLogFields(id, name, version, path string) log.Fields { func pluginLogFieldsFromMetadata(id string, meta pluginapi.Metadata, path string) log.Fields { return pluginLogFields(id, meta.Name, meta.Version, path) } + +func pluginHotReloadLogFields(id, activeVersion, activePath, retiredVersion, retiredPath string) log.Fields { + fields := log.Fields{ + "plugin_id": strings.TrimSpace(id), + } + if activeVersion = strings.TrimSpace(activeVersion); activeVersion != "" { + fields["active_version"] = activeVersion + } + if activePath = strings.TrimSpace(activePath); activePath != "" { + fields["active_path"] = activePath + } + if retiredVersion = strings.TrimSpace(retiredVersion); retiredVersion != "" { + fields["retired_version"] = retiredVersion + } + if retiredPath = strings.TrimSpace(retiredPath); retiredPath != "" { + fields["retired_path"] = retiredPath + } + return fields +} diff --git a/internal/pluginhost/logging_test.go b/internal/pluginhost/logging_test.go index fd0c14609..e9273db95 100644 --- a/internal/pluginhost/logging_test.go +++ b/internal/pluginhost/logging_test.go @@ -32,3 +32,25 @@ func TestPluginLogFieldsOmitsEmptyName(t *testing.T) { t.Fatalf("plugin_name = %v, want omitted", fields["plugin_name"]) } } + +func TestPluginHotReloadLogFieldsIncludesActiveAndRetiredIdentity(t *testing.T) { + fields := pluginHotReloadLogFields( + "sample", + "0.1.0", + "/tmp/plugins/sample-v0.1.0.dll", + "0.2.0", + "/tmp/plugins/sample-v0.2.0.dll", + ) + + for key, want := range map[string]string{ + "plugin_id": "sample", + "active_version": "0.1.0", + "active_path": "/tmp/plugins/sample-v0.1.0.dll", + "retired_version": "0.2.0", + "retired_path": "/tmp/plugins/sample-v0.2.0.dll", + } { + if fields[key] != want { + t.Fatalf("%s = %v, want %s", key, fields[key], want) + } + } +} diff --git a/internal/pluginhost/platform.go b/internal/pluginhost/platform.go index 7d43c86d3..e8699b9cc 100644 --- a/internal/pluginhost/platform.go +++ b/internal/pluginhost/platform.go @@ -112,16 +112,17 @@ func pluginExtension(goos string) string { } } -func selectPluginFiles(root string) ([]pluginFile, error) { - selected, _, errSelect := selectPluginFilesWithCandidates(root) +func selectPluginFiles(root string, desiredVersions ...map[string]string) ([]pluginFile, error) { + selected, _, errSelect := selectPluginFilesWithCandidates(root, desiredVersions...) return selected, errSelect } -func selectPluginFilesWithCandidates(root string) ([]pluginFile, []pluginFile, error) { +func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string]string) ([]pluginFile, []pluginFile, error) { root = strings.TrimSpace(root) if root == "" { root = "plugins" } + desired := normalizeDesiredPluginVersions(desiredVersions...) candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant()) extension := pluginExtension(runtime.GOOS) @@ -158,18 +159,49 @@ func selectPluginFilesWithCandidates(root string) ([]pluginFile, []pluginFile, e order = append(order, file.ID) continue } - if pluginFilePreferred(file, current) { + if pluginFilePreferredForDesired(file, current, desired[file.ID]) { selectedByID[file.ID] = file } } } selected := make([]pluginFile, 0, len(order)) for _, id := range order { - selected = append(selected, selectedByID[id]) + file := selectedByID[id] + if desiredVersion := desired[id]; desiredVersion != "" && file.Version != desiredVersion { + continue + } + selected = append(selected, file) } return selected, all, nil } +func normalizeDesiredPluginVersions(sources ...map[string]string) map[string]string { + out := make(map[string]string) + for _, source := range sources { + for id, version := range source { + id = strings.TrimSpace(id) + version = normalizePluginDesiredVersion(version) + if id == "" || version == "" { + continue + } + out[id] = version + } + } + return out +} + +func pluginFilePreferredForDesired(candidate pluginFile, current pluginFile, desiredVersion string) bool { + desiredVersion = normalizePluginDesiredVersion(desiredVersion) + if desiredVersion != "" { + candidateMatches := candidate.Version == desiredVersion + currentMatches := current.Version == desiredVersion + if candidateMatches != currentMatches { + return candidateMatches + } + } + return pluginFilePreferred(candidate, current) +} + func pluginFilePreferred(candidate pluginFile, current pluginFile) bool { if candidate.Version == "" { return false diff --git a/internal/pluginhost/platform_test.go b/internal/pluginhost/platform_test.go index b2f640eb8..e8f959f15 100644 --- a/internal/pluginhost/platform_test.go +++ b/internal/pluginhost/platform_test.go @@ -159,6 +159,84 @@ func TestDiscoverPluginFilesReturnsSelectedPluginFiles(t *testing.T) { } } +func TestSelectPluginFilesPrefersConfiguredVersionOverHigherVersion(t *testing.T) { + root := t.TempDir() + archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll() error = %v", errMkdirAll) + } + + extension := pluginExtension(runtime.GOOS) + olderPath := filepath.Join(archDir, "alpha-v1.0.3"+extension) + newerPath := filepath.Join(archDir, "alpha-v1.0.4"+extension) + for _, path := range []string{olderPath, newerPath} { + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + } + + files, errSelect := selectPluginFiles(root, map[string]string{"alpha": "1.0.3"}) + if errSelect != nil { + t.Fatalf("selectPluginFiles() error = %v", errSelect) + } + if len(files) != 1 { + t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files) + } + if files[0] != (pluginFile{ID: "alpha", Path: olderPath, Version: "1.0.3"}) { + t.Fatalf("selectPluginFiles()[0] = %v, want configured plugin %s", files[0], olderPath) + } +} + +func TestSelectPluginFilesFallsBackToHighestVersionWithoutConfiguredVersion(t *testing.T) { + root := t.TempDir() + archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll() error = %v", errMkdirAll) + } + + extension := pluginExtension(runtime.GOOS) + olderPath := filepath.Join(archDir, "alpha-v1.0.3"+extension) + newerPath := filepath.Join(archDir, "alpha-v1.0.4"+extension) + for _, path := range []string{olderPath, newerPath} { + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + } + + files, errSelect := selectPluginFiles(root) + if errSelect != nil { + t.Fatalf("selectPluginFiles() error = %v", errSelect) + } + if len(files) != 1 { + t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files) + } + if files[0] != (pluginFile{ID: "alpha", Path: newerPath, Version: "1.0.4"}) { + t.Fatalf("selectPluginFiles()[0] = %v, want highest plugin %s", files[0], newerPath) + } +} + +func TestSelectPluginFilesSkipsPluginWhenConfiguredVersionIsMissing(t *testing.T) { + root := t.TempDir() + archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll() error = %v", errMkdirAll) + } + + extension := pluginExtension(runtime.GOOS) + path := filepath.Join(archDir, "alpha-v1.0.4"+extension) + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + + files, errSelect := selectPluginFiles(root, map[string]string{"alpha": "1.0.3"}) + if errSelect != nil { + t.Fatalf("selectPluginFiles() error = %v", errSelect) + } + if len(files) != 0 { + t.Fatalf("selectPluginFiles() = %v, want no selected alpha plugin", files) + } +} + func TestSelectPluginFilesPrefersCPUVariantOverGenericArchDir(t *testing.T) { variant := cpuVariant() if variant == "" { diff --git a/internal/pluginhost/test_helpers_test.go b/internal/pluginhost/test_helpers_test.go index d0c3334c0..c3deb906f 100644 --- a/internal/pluginhost/test_helpers_test.go +++ b/internal/pluginhost/test_helpers_test.go @@ -9,8 +9,10 @@ import ( "runtime" "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "gopkg.in/yaml.v3" ) type testSymbolLoader struct { @@ -333,3 +335,39 @@ func makePluginDir(t *testing.T, ids ...string) string { } return root } + +func makeVersionedPluginDir(t *testing.T, id string, versions ...string) (string, map[string]string) { + t.Helper() + root := t.TempDir() + paths := make(map[string]string, len(versions)) + for _, version := range versions { + paths[version] = writeVersionedPluginFile(t, root, id, version) + } + return root, paths +} + +func writeVersionedPluginFile(t *testing.T, root, id, version string) string { + t.Helper() + archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll() error = %v", errMkdirAll) + } + path := filepath.Join(archDir, fmt.Sprintf("%s-v%s%s", id, version, pluginExtension(runtime.GOOS))) + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + return path +} + +func enabledPluginConfigWithStoreVersion(t *testing.T, version string) config.PluginInstanceConfig { + t.Helper() + var node yaml.Node + if errDecode := yaml.Unmarshal([]byte(fmt.Sprintf("store:\n version: %s\n", version)), &node); errDecode != nil { + t.Fatalf("yaml.Unmarshal() error = %v", errDecode) + } + enabled := true + return config.PluginInstanceConfig{ + Enabled: &enabled, + Raw: *node.Content[0], + } +}