mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
feat(pluginhost): enhance plugin version management and logging for hot reload
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user