feat(management): resolve plugin store versions from latest releases

List entries now show each plugin's latest release version and compute
update availability against it, falling back to the registry version
when the lookup fails. Lookups run concurrently and are cached per
repository with a short failure TTL to respect API rate limits.
This commit is contained in:
LTbinglingfeng
2026-06-13 04:05:09 +08:00
parent 40f4b8b856
commit 220b4e5bbd
3 changed files with 199 additions and 3 deletions

View File

@@ -54,6 +54,8 @@ type Handler struct {
configReloadHook func(context.Context, *config.Config)
pluginStoreRegistryURL string
pluginStoreHTTPClient pluginstore.HTTPDoer
pluginReleaseCacheMu sync.Mutex
pluginReleaseCache map[string]pluginReleaseCacheEntry
}
// NewHandler creates a new management handler instance.

View File

@@ -1,11 +1,14 @@
package management
import (
"context"
"errors"
"fmt"
"net/http"
"runtime"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
@@ -17,6 +20,20 @@ import (
log "github.com/sirupsen/logrus"
)
const (
// pluginReleaseCacheTTL bounds how long a resolved latest release version is
// reused before the GitHub API is queried again.
pluginReleaseCacheTTL = 10 * time.Minute
// pluginReleaseFailureCacheTTL throttles retries after a failed lookup so a
// rate-limited or unreachable API is not hammered on every listing.
pluginReleaseFailureCacheTTL = 30 * time.Second
)
type pluginReleaseCacheEntry struct {
version string
expiresAt time.Time
}
type pluginStoreListResponse struct {
PluginsEnabled bool `json:"plugins_enabled"`
PluginsDir string `json:"plugins_dir"`
@@ -77,16 +94,23 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
return
}
latestVersions := h.latestPluginVersions(c.Request.Context(), client, registry.Plugins)
entries := make([]pluginStoreListEntry, 0, len(registry.Plugins))
for _, plugin := range registry.Plugins {
for index, plugin := range registry.Plugins {
status := statuses[plugin.ID]
installedVersion := status.InstalledVersion
// Fall back to the registry version when the latest release is unknown.
storeVersion := plugin.Version
if latestVersions[index] != "" {
storeVersion = latestVersions[index]
}
entries = append(entries, pluginStoreListEntry{
ID: htmlsanitize.String(plugin.ID),
Name: htmlsanitize.String(plugin.Name),
Description: htmlsanitize.String(plugin.Description),
Author: htmlsanitize.String(plugin.Author),
Version: htmlsanitize.String(plugin.Version),
Version: htmlsanitize.String(storeVersion),
Repository: htmlsanitize.String(plugin.Repository),
Logo: htmlsanitize.String(plugin.Logo),
Homepage: htmlsanitize.String(plugin.Homepage),
@@ -99,7 +123,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
Registered: status.Registered,
Enabled: status.Enabled,
EffectiveEnabled: status.EffectiveEnabled,
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, plugin.Version),
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion),
})
}
@@ -276,6 +300,61 @@ func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client {
return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL}
}
// latestPluginVersions resolves the latest release version of each registry
// plugin concurrently, returning results positionally aligned with plugins.
// Unresolved entries are left empty so callers can fall back gracefully.
func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.Client, plugins []pluginstore.Plugin) []string {
versions := make([]string, len(plugins))
var wg sync.WaitGroup
for index := range plugins {
wg.Add(1)
go func(index int) {
defer wg.Done()
versions[index] = h.latestPluginVersion(ctx, client, plugins[index])
}(index)
}
wg.Wait()
return versions
}
// latestPluginVersion returns the plugin's latest release version, caching
// lookups per repository so repeated listings do not exhaust the GitHub API
// rate limit. Failed lookups are cached for a shorter interval and reported
// as an empty version.
func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin) string {
repository := strings.TrimSpace(plugin.Repository)
if repository == "" {
return ""
}
now := time.Now()
h.pluginReleaseCacheMu.Lock()
entry, found := h.pluginReleaseCache[repository]
h.pluginReleaseCacheMu.Unlock()
if found && now.Before(entry.expiresAt) {
return entry.version
}
version := ""
ttl := pluginReleaseFailureCacheTTL
release, errRelease := client.FetchLatestRelease(ctx, plugin)
if errRelease != nil {
log.WithError(errRelease).WithField("plugin_id", plugin.ID).Warn("pluginstore: failed to fetch latest release")
} else if latestVersion, errVersion := pluginstore.ReleaseVersion(release); errVersion != nil {
log.WithError(errVersion).WithField("plugin_id", plugin.ID).Warn("pluginstore: invalid latest release tag")
} else {
version = latestVersion
ttl = pluginReleaseCacheTTL
}
h.pluginReleaseCacheMu.Lock()
if h.pluginReleaseCache == nil {
h.pluginReleaseCache = make(map[string]pluginReleaseCacheEntry)
}
h.pluginReleaseCache[repository] = pluginReleaseCacheEntry{version: version, expiresAt: now.Add(ttl)}
h.pluginReleaseCacheMu.Unlock()
return version
}
func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[string]config.PluginInstanceConfig, host *pluginhost.Host) (map[string]pluginLocalStatus, error) {
statuses := map[string]pluginLocalStatus{}
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)

View File

@@ -15,6 +15,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
@@ -146,6 +147,98 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) {
}
}
func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
httpClient := &countingPluginStoreHTTPClient{responses: fakePluginStoreHTTPClient{
"https://registry.example/registry.json": registryJSON(t),
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
"tag_name": "v0.2.0",
"assets": []
}`),
}}
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: t.TempDir(),
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreRegistryURL: "https://registry.example/registry.json",
pluginStoreHTTPClient: httpClient,
}
listOnce := func() pluginStoreListResponse {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
h.ListPluginStore(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body pluginStoreListResponse
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
}
return body
}
for call := 0; call < 2; call++ {
body := listOnce()
if len(body.Plugins) != 1 {
t.Fatalf("plugins len = %d, want 1", len(body.Plugins))
}
if body.Plugins[0].Version != "0.2.0" {
t.Fatalf("version = %q, want 0.2.0 from latest release tag", body.Plugins[0].Version)
}
}
releaseCalls := httpClient.count("https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest")
if releaseCalls != 1 {
t.Fatalf("latest release fetched %d times, want 1 (cached)", releaseCalls)
}
}
func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: t.TempDir(),
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreRegistryURL: "https://registry.example/registry.json",
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
"https://registry.example/registry.json": registryJSON(t),
},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
h.ListPluginStore(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body pluginStoreListResponse
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
}
if len(body.Plugins) != 1 {
t.Fatalf("plugins len = %d, want 1", len(body.Plugins))
}
if body.Plugins[0].Version != "0.1.0" {
t.Fatalf("version = %q, want registry fallback 0.1.0", body.Plugins[0].Version)
}
}
func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
@@ -368,6 +461,28 @@ func (c fakePluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error)
}, nil
}
type countingPluginStoreHTTPClient struct {
responses fakePluginStoreHTTPClient
mu sync.Mutex
counts map[string]int
}
func (c *countingPluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
if c.counts == nil {
c.counts = make(map[string]int)
}
c.counts[req.URL.String()]++
c.mu.Unlock()
return c.responses.Do(req)
}
func (c *countingPluginStoreHTTPClient) count(url string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.counts[url]
}
func registryJSON(t *testing.T) []byte {
t.Helper()