mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-21 02:56:02 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user