mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 01:46:48 +08:00
Merge pull request #4035 from router-for-me/plugin
feat(pluginstore): introduce support for direct install type and version management
This commit is contained in:
@@ -64,6 +64,13 @@ plugins:
|
||||
# Additional plugin store registries. The built-in official registry is always included.
|
||||
# store-sources:
|
||||
# - "https://example.com/cliproxy-plugins/registry.json"
|
||||
# Optional plugin store auth rules. Values are read from environment variables;
|
||||
# tokens are not written into plugin manifests or node status.
|
||||
# store-auth:
|
||||
# - match: "https://example.com/cliproxy-plugins/"
|
||||
# apply-to: ["registry", "artifact"]
|
||||
# type: bearer
|
||||
# token-env: "CLIPROXY_PLUGIN_STORE_TOKEN"
|
||||
configs:
|
||||
example:
|
||||
enabled: true
|
||||
|
||||
@@ -87,7 +87,7 @@ All three implementations parse incoming JSON requests for the methods where req
|
||||
|
||||
Build from the repository root.
|
||||
|
||||
Build all plugin examples, including all three `simple` variants:
|
||||
Build all plugin examples:
|
||||
|
||||
```bash
|
||||
make -C examples/plugin build
|
||||
@@ -129,7 +129,6 @@ The plugin ID is the dynamic library basename without the platform extension. Ma
|
||||
The host searches:
|
||||
|
||||
```text
|
||||
plugins/<GOOS>/<GOARCH>-<variant>
|
||||
plugins/<GOOS>/<GOARCH>
|
||||
plugins
|
||||
```
|
||||
|
||||
@@ -127,7 +127,6 @@ Linux、FreeBSD 或 Windows 使用相同源码目录,平台扩展名以 `examp
|
||||
宿主搜索:
|
||||
|
||||
```text
|
||||
plugins/<GOOS>/<GOARCH>-<variant>
|
||||
plugins/<GOOS>/<GOARCH>
|
||||
plugins
|
||||
```
|
||||
|
||||
@@ -2,9 +2,12 @@ package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,6 +21,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -56,28 +60,37 @@ type pluginStoreSourceErr struct {
|
||||
}
|
||||
|
||||
type pluginStoreListEntry struct {
|
||||
StoreID string `json:"store_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Repository string `json:"repository"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Installed bool `json:"installed"`
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
Path string `json:"path"`
|
||||
Configured bool `json:"configured"`
|
||||
Registered bool `json:"registered"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EffectiveEnabled bool `json:"effective_enabled"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
StoreID string `json:"store_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Repository string `json:"repository"`
|
||||
InstallType string `json:"install_type"`
|
||||
AuthRequired bool `json:"auth_required"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Installed bool `json:"installed"`
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
Path string `json:"path"`
|
||||
Configured bool `json:"configured"`
|
||||
Registered bool `json:"registered"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EffectiveEnabled bool `json:"effective_enabled"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
}
|
||||
|
||||
type pluginStorePlatform struct {
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
}
|
||||
|
||||
type pluginInstallResponse struct {
|
||||
@@ -87,11 +100,16 @@ type pluginInstallResponse struct {
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
InstallType string `json:"install_type"`
|
||||
Path string `json:"path"`
|
||||
PluginsEnabled bool `json:"plugins_enabled"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
}
|
||||
|
||||
type pluginInstallRequest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type pluginLocalStatus struct {
|
||||
Installed bool
|
||||
InstalledVersion string
|
||||
@@ -108,13 +126,13 @@ type sourcedPlugin struct {
|
||||
}
|
||||
|
||||
func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, host := h.pluginStoreSnapshot()
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot()
|
||||
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
||||
if errSources != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
||||
return
|
||||
}
|
||||
plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, sources)
|
||||
plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, storeAuth, sources)
|
||||
if len(plugins) == 0 && len(sourceErrors) > 0 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message})
|
||||
return
|
||||
@@ -129,7 +147,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
for _, item := range plugins {
|
||||
latestInput = append(latestInput, item.plugin)
|
||||
}
|
||||
client := h.newPluginStoreClient(proxyURL, "")
|
||||
client := h.newPluginStoreClient(proxyURL, "", storeAuth)
|
||||
latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput)
|
||||
|
||||
entries := make([]pluginStoreListEntry, 0, len(plugins))
|
||||
@@ -153,6 +171,10 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
Author: htmlsanitize.String(plugin.Author),
|
||||
Version: htmlsanitize.String(storeVersion),
|
||||
Repository: htmlsanitize.String(plugin.Repository),
|
||||
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
|
||||
AuthRequired: plugin.AuthRequired,
|
||||
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
|
||||
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
|
||||
Logo: htmlsanitize.String(plugin.Logo),
|
||||
Homepage: htmlsanitize.String(plugin.Homepage),
|
||||
License: htmlsanitize.String(plugin.License),
|
||||
@@ -186,25 +208,47 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
if !okID {
|
||||
return
|
||||
}
|
||||
requestedVersion, errVersionRequest := pluginInstallRequestedVersion(c)
|
||||
if errVersionRequest != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "message": errVersionRequest.Error()})
|
||||
return
|
||||
}
|
||||
installCtx := c.Request.Context()
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, _, host := h.pluginStoreSnapshot()
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, _, host := h.pluginStoreSnapshot()
|
||||
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
||||
if errSources != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
||||
return
|
||||
}
|
||||
source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, sources, id, c.Query("source"), c)
|
||||
source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, storeAuth, sources, id, c.Query("source"), c)
|
||||
if !okPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
pluginIsBusy := func() bool { return pluginBusy(host, id) }
|
||||
result, errInstall := client.Install(installCtx, plugin, pluginstore.InstallOptions{
|
||||
installOptions := pluginstore.InstallOptions{
|
||||
PluginsDir: pluginsDir,
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
PluginLoaded: pluginIsBusy,
|
||||
})
|
||||
}
|
||||
var manifest pluginstore.Manifest
|
||||
var result pluginstore.InstallResult
|
||||
var errInstall error
|
||||
switch pluginstore.PluginInstallType(plugin) {
|
||||
case pluginstore.InstallTypeDirect:
|
||||
var errManifest error
|
||||
manifest, errManifest = pluginStoreDirectManifest(source, plugin, requestedVersion)
|
||||
if errManifest != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_manifest_invalid", "message": errManifest.Error()})
|
||||
return
|
||||
}
|
||||
result, errInstall = client.InstallManifest(installCtx, manifest, installOptions)
|
||||
case pluginstore.InstallTypeGitHubRelease:
|
||||
result, errInstall = installPluginStoreGitHubRelease(installCtx, client, plugin, requestedVersion, installOptions)
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_manifest_invalid", "message": fmt.Sprintf("unsupported install type %q", plugin.Install.Type)})
|
||||
return
|
||||
}
|
||||
if errInstall != nil {
|
||||
if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
@@ -217,6 +261,18 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_install_failed", "message": errInstall.Error()})
|
||||
return
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
var errManifest error
|
||||
manifest, errManifest = pluginStoreManifestForInstall(source, plugin, result)
|
||||
if errManifest != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "plugin_manifest_failed",
|
||||
"message": fmt.Sprintf("plugin file installed at %s but creating store manifest failed: %s", result.Path, errManifest.Error()),
|
||||
"path": result.Path,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
restartRequired := false
|
||||
|
||||
h.mu.Lock()
|
||||
@@ -229,7 +285,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if errEnable := h.enablePluginConfigLocked(id); errEnable != nil {
|
||||
if errEnable := h.enablePluginConfigLocked(id, manifest); errEnable != nil {
|
||||
h.mu.Unlock()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "config_update_failed",
|
||||
@@ -252,12 +308,13 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
|
||||
h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot)
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": result.ID,
|
||||
"plugin_name": plugin.Name,
|
||||
"source_id": source.ID,
|
||||
"version": result.Version,
|
||||
"path": result.Path,
|
||||
"overwritten": result.Overwritten,
|
||||
"plugin_id": result.ID,
|
||||
"plugin_name": plugin.Name,
|
||||
"source_id": source.ID,
|
||||
"version": result.Version,
|
||||
"install_type": result.InstallType,
|
||||
"path": result.Path,
|
||||
"overwritten": result.Overwritten,
|
||||
}).Info("pluginstore: plugin installed")
|
||||
|
||||
c.JSON(http.StatusOK, pluginInstallResponse{
|
||||
@@ -267,18 +324,130 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
SourceURL: htmlsanitize.String(source.URL),
|
||||
ID: htmlsanitize.String(result.ID),
|
||||
Version: htmlsanitize.String(result.Version),
|
||||
InstallType: htmlsanitize.String(result.InstallType),
|
||||
Path: htmlsanitize.String(result.Path),
|
||||
PluginsEnabled: pluginsEnabled,
|
||||
RestartRequired: restartRequired,
|
||||
})
|
||||
}
|
||||
|
||||
// enablePluginConfigLocked sets plugins.configs.<id>.enabled to true while preserving
|
||||
// the rest of the plugin's raw configuration. Callers must hold h.mu.
|
||||
func (h *Handler) enablePluginConfigLocked(id string) error {
|
||||
func pluginStoreDirectManifest(source pluginstore.Source, plugin pluginstore.Plugin, requestedVersion string) (pluginstore.Manifest, error) {
|
||||
version := normalizePluginStoreRequestedVersion(requestedVersion)
|
||||
if version == "" {
|
||||
version = normalizePluginStoreRequestedVersion(plugin.Version)
|
||||
}
|
||||
if normalizePluginStoreRequestedVersion(plugin.Version) == version {
|
||||
plugin.Version = version
|
||||
return pluginstore.ManifestFromPlugin(source, plugin)
|
||||
}
|
||||
for _, candidate := range plugin.Versions {
|
||||
if normalizePluginStoreRequestedVersion(candidate.Version) != version {
|
||||
continue
|
||||
}
|
||||
plugin.Version = version
|
||||
plugin.Install = candidate.Install
|
||||
if strings.TrimSpace(plugin.Install.Type) == "" {
|
||||
plugin.Install.Type = pluginstore.InstallTypeDirect
|
||||
}
|
||||
return pluginstore.ManifestFromPlugin(source, plugin)
|
||||
}
|
||||
return pluginstore.Manifest{}, fmt.Errorf("direct plugin version %q not found", version)
|
||||
}
|
||||
|
||||
func installPluginStoreGitHubRelease(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin, requestedVersion string, options pluginstore.InstallOptions) (pluginstore.InstallResult, error) {
|
||||
version := normalizePluginStoreRequestedVersion(requestedVersion)
|
||||
if version == "" {
|
||||
return client.Install(ctx, plugin, options)
|
||||
}
|
||||
tags := pluginStoreReleaseTagCandidates(requestedVersion)
|
||||
errs := make([]error, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
result, errInstall := client.InstallVersion(ctx, plugin, tag, version, options)
|
||||
if errInstall == nil {
|
||||
return result, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", tag, errInstall))
|
||||
}
|
||||
return pluginstore.InstallResult{}, fmt.Errorf("install release by tag: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func pluginStoreManifestForInstall(source pluginstore.Source, plugin pluginstore.Plugin, result pluginstore.InstallResult) (pluginstore.Manifest, error) {
|
||||
installType := strings.TrimSpace(result.InstallType)
|
||||
if installType == "" {
|
||||
installType = pluginstore.PluginInstallType(plugin)
|
||||
}
|
||||
switch installType {
|
||||
case pluginstore.InstallTypeDirect:
|
||||
plugin.Version = strings.TrimSpace(result.Version)
|
||||
plugin.Install = pluginstore.NormalizeInstallPlan(plugin.Install)
|
||||
return pluginstore.ManifestFromPlugin(source, plugin)
|
||||
case pluginstore.InstallTypeGitHubRelease:
|
||||
releaseTag := strings.TrimSpace(result.ReleaseTag)
|
||||
if releaseTag == "" {
|
||||
return pluginstore.Manifest{}, fmt.Errorf("release tag is required")
|
||||
}
|
||||
return pluginstore.ManifestFromRelease(source, plugin, pluginstore.Release{TagName: releaseTag})
|
||||
default:
|
||||
return pluginstore.Manifest{}, fmt.Errorf("unsupported install type %q", result.InstallType)
|
||||
}
|
||||
}
|
||||
|
||||
func pluginInstallRequestedVersion(c *gin.Context) (string, error) {
|
||||
requestedVersion := strings.TrimSpace(c.Query("version"))
|
||||
if c == nil || c.Request == nil || c.Request.Body == nil || c.Request.Body == http.NoBody {
|
||||
return requestedVersion, nil
|
||||
}
|
||||
body, errRead := io.ReadAll(c.Request.Body)
|
||||
if errRead != nil {
|
||||
return "", fmt.Errorf("read install request: %w", errRead)
|
||||
}
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
return requestedVersion, nil
|
||||
}
|
||||
var req pluginInstallRequest
|
||||
if errDecode := json.Unmarshal(body, &req); errDecode != nil {
|
||||
return "", fmt.Errorf("decode install request: %w", errDecode)
|
||||
}
|
||||
bodyVersion := strings.TrimSpace(req.Version)
|
||||
if requestedVersion == "" {
|
||||
return bodyVersion, nil
|
||||
}
|
||||
if bodyVersion == "" || normalizePluginStoreRequestedVersion(bodyVersion) == normalizePluginStoreRequestedVersion(requestedVersion) {
|
||||
return requestedVersion, nil
|
||||
}
|
||||
return "", fmt.Errorf("version query %q does not match request body version %q", requestedVersion, bodyVersion)
|
||||
}
|
||||
|
||||
func pluginStoreReleaseTagCandidates(version string) []string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(version), "v") {
|
||||
return []string{version, strings.TrimSpace(version[1:])}
|
||||
}
|
||||
return []string{version, "v" + version}
|
||||
}
|
||||
|
||||
func normalizePluginStoreRequestedVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if strings.HasPrefix(strings.ToLower(version), "v") {
|
||||
return strings.TrimSpace(version[1:])
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// enablePluginConfigLocked sets plugins.configs.<id>.enabled and store while
|
||||
// preserving the rest of the plugin's raw configuration. Callers must hold h.mu.
|
||||
func (h *Handler) enablePluginConfigLocked(id string, storeManifest pluginstore.Manifest) error {
|
||||
ensurePluginConfigMap(h.cfg)
|
||||
node := pluginConfigNode(h.cfg.Plugins.Configs[id])
|
||||
storeNode, errStoreNode := pluginStoreManifestYAMLNode(storeManifest)
|
||||
if errStoreNode != nil {
|
||||
return errStoreNode
|
||||
}
|
||||
setYAMLMappingValue(node, "enabled", boolYAMLNode(true))
|
||||
setYAMLMappingValue(node, "store", storeNode)
|
||||
updated, errConfig := pluginInstanceConfigFromNode(node)
|
||||
if errConfig != nil {
|
||||
return fmt.Errorf("decode plugin config: %w", errConfig)
|
||||
@@ -287,24 +456,33 @@ func (h *Handler) enablePluginConfigLocked(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, map[string]config.PluginInstanceConfig, *pluginhost.Host) {
|
||||
func pluginStoreManifestYAMLNode(manifest pluginstore.Manifest) (*yaml.Node, error) {
|
||||
var node yaml.Node
|
||||
if errEncode := node.Encode(manifest); errEncode != nil {
|
||||
return nil, fmt.Errorf("encode store manifest: %w", errEncode)
|
||||
}
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, []pluginstore.AuthConfig, map[string]config.PluginInstanceConfig, *pluginhost.Host) {
|
||||
if h == nil {
|
||||
return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil
|
||||
return false, "plugins", "", nil, nil, map[string]config.PluginInstanceConfig{}, nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil
|
||||
return false, "plugins", "", nil, nil, map[string]config.PluginInstanceConfig{}, nil
|
||||
}
|
||||
pluginsEnabled := h.cfg.Plugins.Enabled
|
||||
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
|
||||
proxyURL := strings.TrimSpace(h.cfg.ProxyURL)
|
||||
sourceConfigs := append([]string(nil), h.cfg.Plugins.StoreSources...)
|
||||
storeAuth := append([]pluginstore.AuthConfig(nil), h.cfg.Plugins.StoreAuth...)
|
||||
configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs))
|
||||
for id, item := range h.cfg.Plugins.Configs {
|
||||
configs[id] = item
|
||||
}
|
||||
return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, h.pluginHost
|
||||
return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, h.pluginHost
|
||||
}
|
||||
|
||||
func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Source, error) {
|
||||
@@ -316,7 +494,7 @@ func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Sour
|
||||
return pluginstore.NormalizeSources(sourceConfigs)
|
||||
}
|
||||
|
||||
func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) pluginstore.Client {
|
||||
func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string, storeAuth []pluginstore.AuthConfig) pluginstore.Client {
|
||||
registryURL = strings.TrimSpace(registryURL)
|
||||
var httpClient pluginstore.HTTPDoer
|
||||
if h != nil {
|
||||
@@ -326,20 +504,20 @@ func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) plug
|
||||
registryURL = pluginstore.DefaultRegistryURL
|
||||
}
|
||||
if httpClient != nil {
|
||||
return pluginstore.Client{HTTPClient: httpClient, RegistryURL: registryURL}
|
||||
return pluginstore.Client{HTTPClient: httpClient, RegistryURL: registryURL, Auth: storeAuth}
|
||||
}
|
||||
client := &http.Client{}
|
||||
if strings.TrimSpace(proxyURL) != "" {
|
||||
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}, client)
|
||||
}
|
||||
return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL}
|
||||
return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL, Auth: storeAuth}
|
||||
}
|
||||
|
||||
func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) {
|
||||
func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, storeAuth []pluginstore.AuthConfig, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) {
|
||||
plugins := make([]sourcedPlugin, 0)
|
||||
sourceErrors := make([]pluginStoreSourceErr, 0)
|
||||
for _, source := range sources {
|
||||
client := h.newPluginStoreClient(proxyURL, source.URL)
|
||||
client := h.newPluginStoreClient(proxyURL, source.URL, storeAuth)
|
||||
registry, errRegistry := client.FetchRegistry(ctx)
|
||||
if errRegistry != nil {
|
||||
sourceErrors = append(sourceErrors, pluginStoreSourceErr{
|
||||
@@ -357,14 +535,14 @@ func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, sour
|
||||
return plugins, sourceErrors
|
||||
}
|
||||
|
||||
func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) {
|
||||
func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, storeAuth []pluginstore.AuthConfig, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) {
|
||||
requestedSourceID = strings.TrimSpace(requestedSourceID)
|
||||
if requestedSourceID != "" {
|
||||
for _, source := range sources {
|
||||
if source.ID != requestedSourceID {
|
||||
continue
|
||||
}
|
||||
client := h.newPluginStoreClient(proxyURL, source.URL)
|
||||
client := h.newPluginStoreClient(proxyURL, source.URL, storeAuth)
|
||||
registry, errRegistry := client.FetchRegistry(ctx)
|
||||
if errRegistry != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()})
|
||||
@@ -381,7 +559,7 @@ func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL str
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
|
||||
plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, sources)
|
||||
plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, storeAuth, sources)
|
||||
matches := make([]sourcedPlugin, 0)
|
||||
for _, item := range plugins {
|
||||
if item.plugin.ID == id {
|
||||
@@ -405,7 +583,7 @@ func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL str
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
match := matches[0]
|
||||
return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL), true
|
||||
return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL, storeAuth), true
|
||||
}
|
||||
|
||||
func sourcedPluginSources(plugins []sourcedPlugin) []pluginstore.Source {
|
||||
@@ -444,6 +622,53 @@ func sanitizePluginStoreSourceErrors(sourceErrors []pluginStoreSourceErr) []plug
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizePluginStorePlatforms(platforms []pluginstore.Platform) []pluginStorePlatform {
|
||||
if len(platforms) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginStorePlatform, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
out = append(out, pluginStorePlatform{
|
||||
GOOS: htmlsanitize.String(platform.GOOS),
|
||||
GOARCH: htmlsanitize.String(platform.GOARCH),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginAuthConfigured(source pluginstore.Source, plugin pluginstore.Plugin, storeAuth []pluginstore.AuthConfig) bool {
|
||||
if pluginstore.AuthConfigured(storeAuth, source.URL, pluginstore.RequestKindRegistry) {
|
||||
return true
|
||||
}
|
||||
switch pluginstore.PluginInstallType(plugin) {
|
||||
case pluginstore.InstallTypeDirect:
|
||||
for _, artifact := range pluginstore.PluginArtifacts(plugin) {
|
||||
if pluginstore.AuthConfigured(storeAuth, artifact.URL, pluginstore.RequestKindArtifact) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case pluginstore.InstallTypeGitHubRelease:
|
||||
return pluginGitHubReleaseAuthConfigured(plugin, storeAuth)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pluginGitHubReleaseAuthConfigured(plugin pluginstore.Plugin, storeAuth []pluginstore.AuthConfig) bool {
|
||||
owner, repo, errRepository := pluginstore.GitHubRepositoryParts(plugin.Repository)
|
||||
if errRepository != nil {
|
||||
return false
|
||||
}
|
||||
releasesURL := fmt.Sprintf(
|
||||
"https://api.github.com/repos/%s/%s/releases/",
|
||||
url.PathEscape(owner),
|
||||
url.PathEscape(repo),
|
||||
)
|
||||
latestURL := releasesURL + "latest"
|
||||
tagsURL := releasesURL + "tags/"
|
||||
return pluginstore.AuthConfigured(storeAuth, latestURL, pluginstore.RequestKindMetadata) ||
|
||||
pluginstore.AuthConfigured(storeAuth, tagsURL, pluginstore.RequestKindMetadata)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -466,6 +691,9 @@ func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.C
|
||||
// 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 {
|
||||
if pluginstore.PluginInstallType(plugin) != pluginstore.InstallTypeGitHubRelease {
|
||||
return ""
|
||||
}
|
||||
repository := strings.TrimSpace(plugin.Repository)
|
||||
if repository == "" {
|
||||
return ""
|
||||
@@ -501,7 +729,7 @@ func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Cl
|
||||
|
||||
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)
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs))
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
@@ -509,6 +737,9 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
|
||||
status := statuses[file.ID]
|
||||
status.Installed = true
|
||||
status.Path = file.Path
|
||||
if strings.TrimSpace(file.Version) != "" {
|
||||
status.InstalledVersion = strings.TrimSpace(file.Version)
|
||||
}
|
||||
status.Enabled = true
|
||||
statuses[file.ID] = status
|
||||
}
|
||||
@@ -537,6 +768,75 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
func pluginStoreDesiredVersions(configs map[string]config.PluginInstanceConfig) map[string]string {
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(configs))
|
||||
for id, item := range configs {
|
||||
id = strings.TrimSpace(id)
|
||||
version := pluginStoreDesiredVersion(item)
|
||||
if id == "" || version == "" {
|
||||
continue
|
||||
}
|
||||
out[id] = version
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginStoreDesiredVersion(item config.PluginInstanceConfig) string {
|
||||
storeNode := pluginStoreConfigNode(item)
|
||||
if storeNode == nil {
|
||||
return ""
|
||||
}
|
||||
if version := pluginStoreNormalizeDesiredVersion(pluginStoreYAMLScalar(yamlMappingValue(storeNode, "version"))); version != "" {
|
||||
return version
|
||||
}
|
||||
return pluginStoreNormalizeDesiredVersion(pluginStoreYAMLScalar(yamlMappingValue(storeNode, "release-tag")))
|
||||
}
|
||||
|
||||
func pluginStoreConfigNode(item config.PluginInstanceConfig) *yaml.Node {
|
||||
if item.Raw.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
return yamlMappingValue(&item.Raw, "store")
|
||||
}
|
||||
|
||||
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
keyNode := node.Content[i]
|
||||
if keyNode == nil || keyNode.Value != key {
|
||||
continue
|
||||
}
|
||||
return node.Content[i+1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginStoreYAMLScalar(node *yaml.Node) string {
|
||||
if node == nil || node.Kind != yaml.ScalarNode {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(node.Value)
|
||||
}
|
||||
|
||||
func pluginStoreNormalizeDesiredVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') {
|
||||
version = version[1:]
|
||||
}
|
||||
if version == "" || version[0] < '0' || version[0] > '9' {
|
||||
return ""
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func pluginBusy(host *pluginhost.Host, id string) bool {
|
||||
if host == nil {
|
||||
return false
|
||||
|
||||
@@ -80,6 +80,112 @@ func TestListPluginStoreMergesInstalledStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreUsesVersionFromInstalledFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
|
||||
}
|
||||
pluginPath := filepath.Join(archDir, "sample-provider-v0.0.1"+managementPluginExtension(runtime.GOOS))
|
||||
if errWriteFile := os.WriteFile(pluginPath, []byte("x"), 0o644); errWriteFile != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", pluginPath, errWriteFile)
|
||||
}
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: pluginsDir,
|
||||
},
|
||||
},
|
||||
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))
|
||||
}
|
||||
entry := body.Plugins[0]
|
||||
if !entry.Installed || entry.InstalledVersion != "0.0.1" {
|
||||
t.Fatalf("store entry status = %#v, want installed version 0.0.1", entry)
|
||||
}
|
||||
if !entry.UpdateAvailable {
|
||||
t.Fatalf("update_available = false, want true for installed 0.0.1 and registry 0.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
|
||||
}
|
||||
extension := managementPluginExtension(runtime.GOOS)
|
||||
pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension)
|
||||
newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension)
|
||||
for _, path := range []string{pinnedPath, newerPath} {
|
||||
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
|
||||
}
|
||||
}
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: pluginsDir,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n release-tag: v0.1.0\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
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))
|
||||
}
|
||||
entry := body.Plugins[0]
|
||||
if !entry.Installed || entry.InstalledVersion != "0.1.0" || entry.Path != pinnedPath {
|
||||
t.Fatalf("store entry status = %#v, want pinned version/path %s", entry, pinnedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreEscapesRegistryStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -296,6 +402,146 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreIncludesDirectMetadataAndAuth(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
StoreAuth: []pluginstore.AuthConfig{{
|
||||
Match: "https://registry.example/",
|
||||
ApplyTo: []string{pluginstore.RequestKindRegistry},
|
||||
Type: pluginstore.AuthTypeBearer,
|
||||
TokenEnv: "PLUGIN_STORE_TOKEN",
|
||||
}},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": directRegistryJSON("https://downloads.example/sample-provider.zip", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
|
||||
},
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
entry := body.Plugins[0]
|
||||
if entry.InstallType != pluginstore.InstallTypeDirect || !entry.AuthRequired || !entry.AuthConfigured {
|
||||
t.Fatalf("direct metadata = %#v, want direct auth metadata", entry)
|
||||
}
|
||||
if !pluginStorePlatformsContain(entry.Platforms, "linux", "amd64") {
|
||||
t.Fatalf("platforms = %#v, want linux/amd64", entry.Platforms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreReportsVersionArtifactAuth(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
StoreAuth: []pluginstore.AuthConfig{{
|
||||
Match: "https://versioned.example/",
|
||||
ApplyTo: []string{pluginstore.RequestKindArtifact},
|
||||
Type: pluginstore.AuthTypeBearer,
|
||||
TokenEnv: "PLUGIN_STORE_TOKEN",
|
||||
}},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": directRegistryJSONWithVersionArtifact(
|
||||
"https://downloads.example/sample-provider.zip",
|
||||
"https://versioned.example/sample-provider-0.3.0.zip",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
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].AuthConfigured {
|
||||
t.Fatalf("auth_configured = false, want true for version artifact auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreReportsGitHubMetadataAuth(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
StoreAuth: []pluginstore.AuthConfig{{
|
||||
Match: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/",
|
||||
ApplyTo: []string{pluginstore.RequestKindMetadata},
|
||||
Type: pluginstore.AuthTypeBearer,
|
||||
TokenEnv: "PLUGIN_STORE_TOKEN",
|
||||
}},
|
||||
},
|
||||
},
|
||||
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].AuthConfigured {
|
||||
t.Fatalf("auth_configured = false, want true for GitHub metadata auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -358,7 +604,7 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
if body.RestartRequired {
|
||||
t.Fatal("restart_required = true, want false")
|
||||
}
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS))
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.1.0"+managementPluginExtension(runtime.GOOS))
|
||||
data, errRead := os.ReadFile(targetPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead)
|
||||
@@ -384,11 +630,134 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
if !strings.Contains(raw, "mode: fast") {
|
||||
t.Fatalf("plugin raw config lost custom field:\n%s", raw)
|
||||
}
|
||||
manifest := pluginStoreManifestFromConfig(t, item)
|
||||
if manifest.InstallType() != pluginstore.InstallTypeGitHubRelease || manifest.ReleaseTag != "v0.1.0" || manifest.Version != "0.1.0" {
|
||||
t.Fatalf("store manifest = %#v, want github-release v0.1.0", manifest)
|
||||
}
|
||||
if raw := marshalPluginRaw(t, snapshotItem); !strings.Contains(raw, "mode: fast") {
|
||||
t.Fatalf("snapshot plugin raw config lost custom field:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreInstallsDirectArtifact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "direct-library-data")
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
artifactURL := "https://downloads.example/sample-provider.zip"
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: false,
|
||||
Dir: pluginsDir,
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": directRegistryJSON(artifactURL, hex.EncodeToString(checksum[:])),
|
||||
artifactURL: archiveData,
|
||||
},
|
||||
}
|
||||
reloads, reloadDone := captureConfigReload(h)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil)
|
||||
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
waitForAsyncReload(t, reloads)
|
||||
waitForReloadDone(t, reloadDone)
|
||||
var body pluginInstallResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if body.InstallType != pluginstore.InstallTypeDirect || body.Version != "0.4.0" {
|
||||
t.Fatalf("install response = %#v, want direct 0.4.0", body)
|
||||
}
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.4.0"+managementPluginExtension(runtime.GOOS))
|
||||
data, errRead := os.ReadFile(targetPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead)
|
||||
}
|
||||
if string(data) != "direct-library-data" {
|
||||
t.Fatalf("installed file = %q, want direct-library-data", data)
|
||||
}
|
||||
manifest := pluginStoreManifestFromConfig(t, h.cfg.Plugins.Configs["sample-provider"])
|
||||
if manifest.SchemaVersion != pluginstore.SchemaVersionV2 || manifest.InstallType() != pluginstore.InstallTypeDirect || manifest.Version != "0.4.0" {
|
||||
t.Fatalf("store manifest = %#v, want direct schema v2 0.4.0", manifest)
|
||||
}
|
||||
if manifest.SourceURL != "https://registry.example/registry.json" || len(manifest.Install.Artifacts) != 0 {
|
||||
t.Fatalf("store manifest source/artifacts = %q/%d, want source URL without artifacts", manifest.SourceURL, len(manifest.Install.Artifacts))
|
||||
}
|
||||
if raw := marshalPluginRaw(t, h.cfg.Plugins.Configs["sample-provider"]); strings.Contains(raw, "artifacts:") {
|
||||
t.Fatalf("direct store manifest should not persist artifacts:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreHonorsDirectQueryVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "direct-history-data")
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
topArtifactURL := "https://downloads.example/sample-provider-0.4.0.zip"
|
||||
versionArtifactURL := "https://downloads.example/sample-provider-0.3.0.zip"
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: false,
|
||||
Dir: pluginsDir,
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": directRegistryJSONWithVersionArtifact(topArtifactURL, versionArtifactURL, hex.EncodeToString(checksum[:])),
|
||||
versionArtifactURL: archiveData,
|
||||
},
|
||||
}
|
||||
reloads, reloadDone := captureConfigReload(h)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?version=0.3.0", nil)
|
||||
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
waitForAsyncReload(t, reloads)
|
||||
waitForReloadDone(t, reloadDone)
|
||||
var body pluginInstallResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if body.InstallType != pluginstore.InstallTypeDirect || body.Version != "0.3.0" {
|
||||
t.Fatalf("install response = %#v, want direct 0.3.0", body)
|
||||
}
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.3.0"+managementPluginExtension(runtime.GOOS))
|
||||
data, errRead := os.ReadFile(targetPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead)
|
||||
}
|
||||
if string(data) != "direct-history-data" {
|
||||
t.Fatalf("installed file = %q, want direct-history-data", data)
|
||||
}
|
||||
manifest := pluginStoreManifestFromConfig(t, h.cfg.Plugins.Configs["sample-provider"])
|
||||
if manifest.Version != "0.3.0" || manifest.InstallType() != pluginstore.InstallTypeDirect || len(manifest.Install.Artifacts) != 0 {
|
||||
t.Fatalf("store manifest = %#v, want source-backed direct 0.3.0", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -444,7 +813,7 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) {
|
||||
if body.SourceID != communitySourceID || body.Version != "0.3.0" {
|
||||
t.Fatalf("install response = %#v, want community source version 0.3.0", body)
|
||||
}
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS))
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.3.0"+managementPluginExtension(runtime.GOOS))
|
||||
data, errRead := os.ReadFile(targetPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead)
|
||||
@@ -495,7 +864,10 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
existingPath := filepath.Join(pluginsDir, "sample-provider"+managementPluginExtension(runtime.GOOS))
|
||||
existingPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.1.0"+managementPluginExtension(runtime.GOOS))
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(existingPath), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(existingPath), errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(existingPath, []byte("old-library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", existingPath, errWrite)
|
||||
}
|
||||
@@ -588,7 +960,7 @@ func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if errEnable := h.enablePluginConfigLocked("sample-provider"); errEnable != nil {
|
||||
if errEnable := h.enablePluginConfigLocked("sample-provider", testStoreManifest()); errEnable != nil {
|
||||
t.Fatalf("enablePluginConfigLocked() error = %v", errEnable)
|
||||
}
|
||||
if h.cfg.Plugins.Enabled {
|
||||
@@ -602,7 +974,7 @@ func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) {
|
||||
t.Fatalf("plugin priority = %d, want 5", item.Priority)
|
||||
}
|
||||
raw := marshalPluginRaw(t, item)
|
||||
if !strings.Contains(raw, "mode: fast") {
|
||||
if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "store:") {
|
||||
t.Fatalf("plugin raw config lost custom field:\n%s", raw)
|
||||
}
|
||||
}
|
||||
@@ -611,13 +983,17 @@ func TestEnablePluginConfigLockedCreatesMissingConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handler{cfg: &config.Config{}}
|
||||
if errEnable := h.enablePluginConfigLocked("sample-provider"); errEnable != nil {
|
||||
if errEnable := h.enablePluginConfigLocked("sample-provider", testStoreManifest()); errEnable != nil {
|
||||
t.Fatalf("enablePluginConfigLocked() error = %v", errEnable)
|
||||
}
|
||||
item := h.cfg.Plugins.Configs["sample-provider"]
|
||||
if item.Enabled == nil || !*item.Enabled {
|
||||
t.Fatalf("plugin enabled = %#v, want true", item.Enabled)
|
||||
}
|
||||
manifest := pluginStoreManifestFromConfig(t, item)
|
||||
if manifest.ID != "sample-provider" || manifest.ReleaseTag != "v0.1.0" {
|
||||
t.Fatalf("store manifest = %#v, want sample-provider v0.1.0", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePluginStoreHTTPClient map[string][]byte
|
||||
@@ -695,6 +1071,114 @@ func thirdPartySampleRegistryJSON(t *testing.T) []byte {
|
||||
}`)
|
||||
}
|
||||
|
||||
func directRegistryJSON(artifactURL string, checksum string) []byte {
|
||||
return []byte(`{
|
||||
"schema_version": 2,
|
||||
"plugins": [{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"description": "Adds sample provider support.",
|
||||
"author": "author-name",
|
||||
"version": "0.4.0",
|
||||
"auth_required": true,
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "` + runtime.GOOS + `",
|
||||
"goarch": "` + runtime.GOARCH + `",
|
||||
"url": "` + artifactURL + `",
|
||||
"sha256": "` + checksum + `"
|
||||
}, {
|
||||
"goos": "linux",
|
||||
"goarch": "amd64",
|
||||
"url": "` + artifactURL + `",
|
||||
"sha256": "` + checksum + `"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
}
|
||||
|
||||
func directRegistryJSONWithVersionArtifact(artifactURL string, versionArtifactURL string, checksum string) []byte {
|
||||
return []byte(`{
|
||||
"schema_version": 2,
|
||||
"plugins": [{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"description": "Adds sample provider support.",
|
||||
"author": "author-name",
|
||||
"version": "0.4.0",
|
||||
"auth_required": true,
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "` + runtime.GOOS + `",
|
||||
"goarch": "` + runtime.GOARCH + `",
|
||||
"url": "` + artifactURL + `",
|
||||
"sha256": "` + checksum + `"
|
||||
}]
|
||||
},
|
||||
"versions": [{
|
||||
"version": "0.3.0",
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "` + runtime.GOOS + `",
|
||||
"goarch": "` + runtime.GOARCH + `",
|
||||
"url": "` + versionArtifactURL + `",
|
||||
"sha256": "` + checksum + `"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
}
|
||||
|
||||
func testStoreManifest() pluginstore.Manifest {
|
||||
return pluginstore.Manifest{
|
||||
ID: "sample-provider",
|
||||
Name: "Sample Provider",
|
||||
Description: "Adds sample provider support.",
|
||||
Author: "author-name",
|
||||
Version: "0.1.0",
|
||||
ReleaseTag: "v0.1.0",
|
||||
Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin",
|
||||
Install: pluginstore.InstallPlan{Type: pluginstore.InstallTypeGitHubRelease},
|
||||
}
|
||||
}
|
||||
|
||||
func pluginStoreManifestFromConfig(t *testing.T, item config.PluginInstanceConfig) pluginstore.Manifest {
|
||||
t.Helper()
|
||||
|
||||
node := pluginConfigNode(item)
|
||||
for index := 0; index+1 < len(node.Content); index += 2 {
|
||||
key := node.Content[index]
|
||||
value := node.Content[index+1]
|
||||
if key == nil || key.Value != "store" {
|
||||
continue
|
||||
}
|
||||
var manifest pluginstore.Manifest
|
||||
if errDecode := value.Decode(&manifest); errDecode != nil {
|
||||
t.Fatalf("decode store manifest: %v", errDecode)
|
||||
}
|
||||
if errValidate := manifest.Validate(); errValidate != nil {
|
||||
t.Fatalf("store manifest Validate() error = %v; manifest=%#v", errValidate, manifest)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
t.Fatalf("plugin config missing store manifest:\n%s", marshalPluginRaw(t, item))
|
||||
return pluginstore.Manifest{}
|
||||
}
|
||||
|
||||
func pluginStorePlatformsContain(platforms []pluginStorePlatform, goos string, goarch string) bool {
|
||||
for _, platform := range platforms {
|
||||
if platform.GOOS == goos && platform.GOARCH == goarch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func makeManagementPluginStoreZip(t *testing.T, name string, content string) []byte {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ func (h *Handler) ListPlugins(c *gin.Context) {
|
||||
h.mu.Unlock()
|
||||
|
||||
entries := make(map[string]pluginListEntry)
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs))
|
||||
if errDiscover != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()})
|
||||
return
|
||||
@@ -322,11 +322,15 @@ func (h *Handler) DeletePlugin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
|
||||
_, configured := h.cfg.Plugins.Configs[id]
|
||||
item, configured := h.cfg.Plugins.Configs[id]
|
||||
host := h.pluginHost
|
||||
h.mu.Unlock()
|
||||
|
||||
path, errPath := pluginFilePath(pluginsDir, id)
|
||||
var desiredVersions map[string]string
|
||||
if configured {
|
||||
desiredVersions = pluginStoreDesiredVersions(map[string]config.PluginInstanceConfig{id: item})
|
||||
}
|
||||
path, errPath := pluginFilePath(pluginsDir, id, desiredVersions)
|
||||
if errPath != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errPath.Error()})
|
||||
return
|
||||
@@ -425,8 +429,8 @@ func pluginDiscovered(pluginsDir string, id string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func pluginFilePath(pluginsDir string, id string) (string, error) {
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
|
||||
func pluginFilePath(pluginsDir string, id string, desiredVersions ...map[string]string) (string, error) {
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, desiredVersions...)
|
||||
if errDiscover != nil {
|
||||
return "", errDiscover
|
||||
}
|
||||
|
||||
@@ -172,6 +172,60 @@ func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginsUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
|
||||
}
|
||||
extension := managementPluginExtension(runtime.GOOS)
|
||||
pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension)
|
||||
newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension)
|
||||
for _, path := range []string{pinnedPath, newerPath} {
|
||||
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
|
||||
}
|
||||
}
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: pluginsDir,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
|
||||
|
||||
h.ListPlugins(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body pluginListResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
for _, entry := range body.Plugins {
|
||||
if entry.ID != "sample-provider" {
|
||||
continue
|
||||
}
|
||||
if entry.Path != pinnedPath || !entry.Configured || !entry.Enabled {
|
||||
t.Fatalf("plugin entry = %#v, want pinned path %s", entry, pinnedPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("sample-provider entry missing: %#v", body.Plugins)
|
||||
}
|
||||
|
||||
func TestGetPluginConfigReturnsPreservedRawConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -541,6 +595,55 @@ func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) {
|
||||
waitForReloadDone(t, reloadDone)
|
||||
}
|
||||
|
||||
func TestDeletePluginUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
|
||||
}
|
||||
extension := managementPluginExtension(runtime.GOOS)
|
||||
pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension)
|
||||
newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension)
|
||||
for _, path := range []string{pinnedPath, newerPath} {
|
||||
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
|
||||
}
|
||||
}
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Dir: pluginsDir,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample-provider", nil)
|
||||
|
||||
h.DeletePlugin(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if _, ok := h.cfg.Plugins.Configs["sample-provider"]; ok {
|
||||
t.Fatal("plugin config still exists after delete")
|
||||
}
|
||||
if _, errStat := os.Stat(pinnedPath); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("pinned plugin stat error = %v, want not exist", errStat)
|
||||
}
|
||||
if _, errStat := os.Stat(newerPath); errStat != nil {
|
||||
t.Fatalf("newer plugin stat error = %v, want still exists", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePluginReturnsNotFoundForUnknownPlugin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -173,6 +174,8 @@ type PluginsConfig struct {
|
||||
Dir string `yaml:"dir" json:"dir"`
|
||||
// StoreSources appends third-party plugin store registries to the built-in official source.
|
||||
StoreSources []string `yaml:"store-sources,omitempty" json:"store-sources,omitempty"`
|
||||
// StoreAuth defines optional auth rules for plugin store registry, metadata, and artifact requests.
|
||||
StoreAuth []sdkpluginstore.AuthConfig `yaml:"store-auth,omitempty" json:"store-auth,omitempty"`
|
||||
// Configs stores per-plugin instance configuration by plugin ID.
|
||||
Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"`
|
||||
}
|
||||
@@ -827,6 +830,7 @@ func (cfg *Config) NormalizePluginsConfig() {
|
||||
}
|
||||
cfg.Plugins.StoreSources = sources
|
||||
}
|
||||
cfg.Plugins.StoreAuth = sdkpluginstore.NormalizeAuthConfigs(cfg.Plugins.StoreAuth)
|
||||
if cfg.Plugins.Configs == nil {
|
||||
cfg.Plugins.Configs = map[string]PluginInstanceConfig{}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,33 @@ plugins:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_PluginStoreAuth(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte(`
|
||||
plugins:
|
||||
store-auth:
|
||||
- match: " https://plugins.example.com/ "
|
||||
apply-to: ["registry", "artifact", "registry"]
|
||||
type: bearer
|
||||
token-env: " CLIPROXY_PLUGIN_STORE_TOKEN "
|
||||
- match: ""
|
||||
type: bearer
|
||||
`))
|
||||
if errParse != nil {
|
||||
t.Fatalf("ParseConfigBytes() error = %v", errParse)
|
||||
}
|
||||
|
||||
if len(cfg.Plugins.StoreAuth) != 1 {
|
||||
t.Fatalf("Plugins.StoreAuth len = %d, want 1", len(cfg.Plugins.StoreAuth))
|
||||
}
|
||||
auth := cfg.Plugins.StoreAuth[0]
|
||||
if auth.Match != "https://plugins.example.com/" || auth.Type != "bearer" || auth.TokenEnv != "CLIPROXY_PLUGIN_STORE_TOKEN" {
|
||||
t.Fatalf("Plugins.StoreAuth[0] = %#v", auth)
|
||||
}
|
||||
if len(auth.ApplyTo) != 2 || auth.ApplyTo[0] != "registry" || auth.ApplyTo[1] != "artifact" {
|
||||
t.Fatalf("Plugins.StoreAuth[0].ApplyTo = %#v", auth.ApplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte(`
|
||||
plugins:
|
||||
|
||||
@@ -16,14 +16,12 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
"golang.org/x/sys/cpu"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Platform struct {
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
}
|
||||
|
||||
type PluginRuntime interface {
|
||||
@@ -56,6 +54,7 @@ type PluginInstallStatus struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
InstallType string `json:"install_type,omitempty"`
|
||||
InstallStatus string `json:"install_status"`
|
||||
LoadStatus string `json:"load_status,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
@@ -85,9 +84,8 @@ const (
|
||||
// CurrentPlatform reports the platform used by pluginhost discovery.
|
||||
func CurrentPlatform() Platform {
|
||||
return Platform{
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
Variant: cpuVariant(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +102,7 @@ func NormalizePlatform(platform Platform) Platform {
|
||||
case "aarch64":
|
||||
goarch = "arm64"
|
||||
}
|
||||
variant := strings.ToLower(strings.TrimSpace(platform.Variant))
|
||||
return Platform{GOOS: goos, GOARCH: goarch, Variant: variant}
|
||||
return Platform{GOOS: goos, GOARCH: goarch}
|
||||
}
|
||||
|
||||
func Sync(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) error {
|
||||
@@ -311,7 +308,7 @@ func pluginFileInfos(root string, id string) ([]pluginFileInfo, error) {
|
||||
platform := CurrentPlatform()
|
||||
extension := pluginExtension(platform.GOOS)
|
||||
candidates := make([]pluginFileInfo, 0)
|
||||
for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH, platform.Variant) {
|
||||
for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH) {
|
||||
entries, errReadDir := os.ReadDir(dir)
|
||||
if errReadDir != nil {
|
||||
if errors.Is(errReadDir, os.ErrNotExist) {
|
||||
@@ -366,11 +363,8 @@ type pluginFileInfo struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string {
|
||||
dirs := make([]string, 0, 3)
|
||||
if variant != "" {
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant))
|
||||
}
|
||||
func pluginCandidateDirs(root string, goos string, goarch string) []string {
|
||||
dirs := make([]string, 0, 2)
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch))
|
||||
dirs = append(dirs, root)
|
||||
return dirs
|
||||
@@ -551,6 +545,7 @@ func pluginStatusFromManifest(manifest sdkpluginstore.Manifest) PluginInstallSta
|
||||
Version: strings.TrimSpace(manifest.Version),
|
||||
ReleaseTag: strings.TrimSpace(manifest.ReleaseTag),
|
||||
Repository: strings.TrimSpace(manifest.Repository),
|
||||
InstallType: manifest.InstallType(),
|
||||
InstallStatus: pluginInstallStatusFailed,
|
||||
}
|
||||
}
|
||||
@@ -592,28 +587,16 @@ func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
|
||||
var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
|
||||
client := &http.Client{}
|
||||
var storeAuth []sdkpluginstore.AuthConfig
|
||||
if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" {
|
||||
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client)
|
||||
}
|
||||
return sdkpluginstore.NewClient(client, "")
|
||||
if cfg != nil {
|
||||
storeAuth = cfg.Plugins.StoreAuth
|
||||
}
|
||||
return sdkpluginstore.NewClientWithAuth(client, "", storeAuth)
|
||||
}
|
||||
|
||||
func pluginConfigEnabled(item config.PluginInstanceConfig) bool {
|
||||
return item.Enabled != nil && *item.Enabled
|
||||
}
|
||||
|
||||
func cpuVariant() string {
|
||||
if runtime.GOARCH != "amd64" {
|
||||
return ""
|
||||
}
|
||||
if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL {
|
||||
return "v4"
|
||||
}
|
||||
if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA {
|
||||
return "v3"
|
||||
}
|
||||
if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT {
|
||||
return "v2"
|
||||
}
|
||||
return "v1"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/cpu"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -124,7 +123,7 @@ func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string]
|
||||
}
|
||||
desired := normalizeDesiredPluginVersions(desiredVersions...)
|
||||
|
||||
candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant())
|
||||
candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH)
|
||||
extension := pluginExtension(runtime.GOOS)
|
||||
selectedByID := make(map[string]pluginFile)
|
||||
order := make([]string, 0)
|
||||
@@ -290,8 +289,8 @@ func cleanupUnselectedPluginFiles(root string, loaded []pluginFile) error {
|
||||
}
|
||||
|
||||
// DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules.
|
||||
func DiscoverPluginFiles(root string) ([]PluginFileInfo, error) {
|
||||
files, errSelect := selectPluginFiles(root)
|
||||
func DiscoverPluginFiles(root string, desiredVersions ...map[string]string) ([]PluginFileInfo, error) {
|
||||
files, errSelect := selectPluginFiles(root, desiredVersions...)
|
||||
if errSelect != nil {
|
||||
return nil, errSelect
|
||||
}
|
||||
@@ -306,28 +305,9 @@ func DiscoverPluginFiles(root string) ([]PluginFileInfo, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func candidateDirs(root, goos, goarch, variant string) []string {
|
||||
dirs := make([]string, 0, 3)
|
||||
if variant != "" {
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant))
|
||||
}
|
||||
func candidateDirs(root, goos, goarch string) []string {
|
||||
dirs := make([]string, 0, 2)
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch))
|
||||
dirs = append(dirs, root)
|
||||
return dirs
|
||||
}
|
||||
|
||||
func cpuVariant() string {
|
||||
if runtime.GOARCH != "amd64" {
|
||||
return ""
|
||||
}
|
||||
if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL {
|
||||
return "v4"
|
||||
}
|
||||
if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA {
|
||||
return "v3"
|
||||
}
|
||||
if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT {
|
||||
return "v2"
|
||||
}
|
||||
return "v1"
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCandidateDirs(t *testing.T) {
|
||||
got := candidateDirs("plugins", "darwin", "arm64", "v3")
|
||||
got := candidateDirs("plugins", "darwin", "arm64")
|
||||
want := []string{
|
||||
filepath.Join("plugins", "darwin", "arm64-v3"),
|
||||
filepath.Join("plugins", "darwin", "arm64"),
|
||||
"plugins",
|
||||
}
|
||||
@@ -25,22 +24,6 @@ func TestCandidateDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateDirsOmitsEmptyVariant(t *testing.T) {
|
||||
got := candidateDirs("plugins", "linux", "arm64", "")
|
||||
want := []string{
|
||||
filepath.Join("plugins", "linux", "arm64"),
|
||||
"plugins",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(candidateDirs) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("candidateDirs[%d] = %q, want %q", index, got[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginExtensionForPlatform(t *testing.T) {
|
||||
cases := []struct {
|
||||
goos string
|
||||
@@ -236,38 +219,3 @@ func TestSelectPluginFilesSkipsPluginWhenConfiguredVersionIsMissing(t *testing.T
|
||||
t.Fatalf("selectPluginFiles() = %v, want no selected alpha plugin", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectPluginFilesPrefersCPUVariantOverGenericArchDir(t *testing.T) {
|
||||
variant := cpuVariant()
|
||||
if variant == "" {
|
||||
t.Skip("current GOARCH has no plugin CPU variant")
|
||||
}
|
||||
root := t.TempDir()
|
||||
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
|
||||
variantDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH+"-"+variant)
|
||||
for _, dir := range []string{archDir, variantDir} {
|
||||
if errMkdirAll := os.MkdirAll(dir, 0o755); errMkdirAll != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", dir, errMkdirAll)
|
||||
}
|
||||
}
|
||||
|
||||
extension := pluginExtension(runtime.GOOS)
|
||||
genericPath := filepath.Join(archDir, "alpha"+extension)
|
||||
variantPath := filepath.Join(variantDir, "alpha"+extension)
|
||||
for _, path := range []string{genericPath, variantPath} {
|
||||
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: variantPath}) {
|
||||
t.Fatalf("selectPluginFiles()[0] = %v, want CPU variant plugin %s", files[0], variantPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package pluginstore
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
RequestKindRegistry = "registry"
|
||||
RequestKindMetadata = "metadata"
|
||||
RequestKindArtifact = "artifact"
|
||||
|
||||
AuthTypeNone = "none"
|
||||
AuthTypeBearer = "bearer"
|
||||
AuthTypeBasic = "basic"
|
||||
AuthTypeHeader = "header"
|
||||
AuthTypeGitHubToken = "github-token"
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
Match string `yaml:"match,omitempty" json:"match,omitempty"`
|
||||
ApplyTo []string `yaml:"apply-to,omitempty" json:"apply_to,omitempty"`
|
||||
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||
TokenEnv string `yaml:"token-env,omitempty" json:"token_env,omitempty"`
|
||||
UsernameEnv string `yaml:"username-env,omitempty" json:"username_env,omitempty"`
|
||||
PasswordEnv string `yaml:"password-env,omitempty" json:"password_env,omitempty"`
|
||||
HeaderName string `yaml:"header-name,omitempty" json:"header_name,omitempty"`
|
||||
HeaderValueEnv string `yaml:"header-value-env,omitempty" json:"header_value_env,omitempty"`
|
||||
AllowInsecure bool `yaml:"allow-insecure,omitempty" json:"allow_insecure,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeAuthConfigs(auth []AuthConfig) []AuthConfig {
|
||||
if len(auth) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]AuthConfig, 0, len(auth))
|
||||
for _, item := range auth {
|
||||
item.Match = strings.TrimSpace(item.Match)
|
||||
item.Type = strings.ToLower(strings.TrimSpace(item.Type))
|
||||
item.TokenEnv = strings.TrimSpace(item.TokenEnv)
|
||||
item.UsernameEnv = strings.TrimSpace(item.UsernameEnv)
|
||||
item.PasswordEnv = strings.TrimSpace(item.PasswordEnv)
|
||||
item.HeaderName = strings.TrimSpace(item.HeaderName)
|
||||
item.HeaderValueEnv = strings.TrimSpace(item.HeaderValueEnv)
|
||||
if item.Type == "" {
|
||||
item.Type = AuthTypeNone
|
||||
}
|
||||
if item.Match == "" {
|
||||
continue
|
||||
}
|
||||
if len(item.ApplyTo) > 0 {
|
||||
applyTo := make([]string, 0, len(item.ApplyTo))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range item.ApplyTo {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
applyTo = append(applyTo, value)
|
||||
}
|
||||
item.ApplyTo = applyTo
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool {
|
||||
item, ok := matchingAuthConfig(auth, requestURL, kind)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Type)) {
|
||||
case AuthTypeNone:
|
||||
return false
|
||||
case AuthTypeBearer, AuthTypeGitHubToken:
|
||||
envName := item.TokenEnv
|
||||
if envName == "" && item.Type == AuthTypeGitHubToken {
|
||||
envName = "GITSTORE_GIT_TOKEN"
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv(envName)) != ""
|
||||
case AuthTypeBasic:
|
||||
return strings.TrimSpace(os.Getenv(item.UsernameEnv)) != "" && strings.TrimSpace(os.Getenv(item.PasswordEnv)) != ""
|
||||
case AuthTypeHeader:
|
||||
return item.HeaderName != "" && strings.TrimSpace(os.Getenv(item.HeaderValueEnv)) != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func applyPluginStoreAuth(headers http.Header, auth []AuthConfig, requestURL string, kind string) error {
|
||||
item, ok := matchingAuthConfig(auth, requestURL, kind)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Type)) {
|
||||
case "", AuthTypeNone:
|
||||
return nil
|
||||
case AuthTypeBearer:
|
||||
token, errToken := envValueRequired(item.TokenEnv, "token-env")
|
||||
if errToken != nil {
|
||||
return errToken
|
||||
}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
case AuthTypeBasic:
|
||||
username, errUsername := envValueRequired(item.UsernameEnv, "username-env")
|
||||
if errUsername != nil {
|
||||
return errUsername
|
||||
}
|
||||
password, errPassword := envValueRequired(item.PasswordEnv, "password-env")
|
||||
if errPassword != nil {
|
||||
return errPassword
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
headers.Set("Authorization", "Basic "+encoded)
|
||||
case AuthTypeHeader:
|
||||
if strings.TrimSpace(item.HeaderName) == "" {
|
||||
return fmt.Errorf("plugin store auth missing header-name")
|
||||
}
|
||||
value, errValue := envValueRequired(item.HeaderValueEnv, "header-value-env")
|
||||
if errValue != nil {
|
||||
return errValue
|
||||
}
|
||||
headers.Set(item.HeaderName, value)
|
||||
case AuthTypeGitHubToken:
|
||||
token := strings.TrimSpace(os.Getenv(strings.TrimSpace(item.TokenEnv)))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN"))
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("plugin store auth missing token-env")
|
||||
}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
default:
|
||||
return fmt.Errorf("unsupported plugin store auth type %q", item.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginStoreRequestURL(auth []AuthConfig, requestURL string, kind string) error {
|
||||
parsed, errParse := url.Parse(strings.TrimSpace(requestURL))
|
||||
if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("invalid plugin store url")
|
||||
}
|
||||
if hasSensitiveQueryParameter(parsed) {
|
||||
return fmt.Errorf("plugin store url contains sensitive query parameter")
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "http") && !allowInsecurePluginStoreURL(auth, requestURL, kind) {
|
||||
return fmt.Errorf("insecure plugin store url requires matching allow-insecure auth rule")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowInsecurePluginStoreURL(auth []AuthConfig, requestURL string, kind string) bool {
|
||||
item, ok := matchingAuthConfig(auth, requestURL, kind)
|
||||
return ok && item.AllowInsecure
|
||||
}
|
||||
|
||||
func matchingAuthConfig(auth []AuthConfig, requestURL string, kind string) (AuthConfig, bool) {
|
||||
requestURL = strings.TrimSpace(requestURL)
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
for _, item := range NormalizeAuthConfigs(auth) {
|
||||
if !pluginStoreURLMatchesAuthRule(requestURL, item.Match) {
|
||||
continue
|
||||
}
|
||||
if !authAppliesTo(item, kind) {
|
||||
continue
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
return AuthConfig{}, false
|
||||
}
|
||||
|
||||
func pluginStoreURLMatchesAuthRule(requestURL string, matchURL string) bool {
|
||||
request, errRequest := url.Parse(strings.TrimSpace(requestURL))
|
||||
if errRequest != nil || request.Scheme == "" || request.Host == "" {
|
||||
return false
|
||||
}
|
||||
rule, errRule := url.Parse(strings.TrimSpace(matchURL))
|
||||
if errRule != nil || rule.Scheme == "" || rule.Host == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(request.Scheme, rule.Scheme) || !strings.EqualFold(request.Host, rule.Host) {
|
||||
return false
|
||||
}
|
||||
return pluginStorePathMatchesAuthRule(request.Path, rule.Path)
|
||||
}
|
||||
|
||||
func pluginStorePathMatchesAuthRule(requestPath string, rulePath string) bool {
|
||||
if rulePath == "" || rulePath == "/" {
|
||||
return true
|
||||
}
|
||||
if requestPath == "" {
|
||||
requestPath = "/"
|
||||
}
|
||||
if requestPath == rulePath {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(rulePath, "/") {
|
||||
return strings.HasPrefix(requestPath, rulePath)
|
||||
}
|
||||
return strings.HasPrefix(requestPath, rulePath+"/")
|
||||
}
|
||||
|
||||
func authAppliesTo(item AuthConfig, kind string) bool {
|
||||
if len(item.ApplyTo) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, value := range item.ApplyTo {
|
||||
if strings.EqualFold(strings.TrimSpace(value), kind) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func envValueRequired(envName string, field string) (string, error) {
|
||||
envName = strings.TrimSpace(envName)
|
||||
if envName == "" {
|
||||
return "", fmt.Errorf("plugin store auth missing %s", field)
|
||||
}
|
||||
value := strings.TrimSpace(os.Getenv(envName))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("plugin store auth env %s is empty", envName)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package pluginstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginStoreAuthMatchesURLHostAndPathBoundaries(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
|
||||
auth := []AuthConfig{{
|
||||
Match: "https://downloads.example/private",
|
||||
ApplyTo: []string{RequestKindArtifact},
|
||||
Type: AuthTypeBearer,
|
||||
TokenEnv: "PLUGIN_STORE_TOKEN",
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantAuth bool
|
||||
}{
|
||||
{name: "exact path", url: "https://downloads.example/private", wantAuth: true},
|
||||
{name: "child path", url: "https://downloads.example/private/plugin.zip", wantAuth: true},
|
||||
{name: "sibling prefix", url: "https://downloads.example/private2/plugin.zip", wantAuth: false},
|
||||
{name: "similar host", url: "https://downloads.example.evil/private/plugin.zip", wantAuth: false},
|
||||
{name: "different scheme", url: "http://downloads.example/private/plugin.zip", wantAuth: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
if errAuth := applyPluginStoreAuth(headers, auth, tt.url, RequestKindArtifact); errAuth != nil {
|
||||
t.Fatalf("applyPluginStoreAuth() error = %v", errAuth)
|
||||
}
|
||||
gotAuth := headers.Get("Authorization") != ""
|
||||
if gotAuth != tt.wantAuth {
|
||||
t.Fatalf("Authorization set = %v, want %v", gotAuth, tt.wantAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStoreAuthHeaderIsReevaluatedAcrossRedirect(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_HEADER", "secret-token")
|
||||
|
||||
var initialHeader string
|
||||
var redirectedHeader string
|
||||
artifactData := []byte("artifact-data")
|
||||
sum := sha256.Sum256(artifactData)
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
redirectedHeader = r.Header.Get("X-Plugin-Token")
|
||||
_, _ = w.Write(artifactData)
|
||||
}))
|
||||
t.Cleanup(target.Close)
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
initialHeader = r.Header.Get("X-Plugin-Token")
|
||||
http.Redirect(w, r, target.URL+"/artifact.zip", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(source.Close)
|
||||
|
||||
client := Client{
|
||||
HTTPClient: source.Client(),
|
||||
Auth: []AuthConfig{
|
||||
{
|
||||
Match: source.URL + "/private/",
|
||||
ApplyTo: []string{RequestKindArtifact},
|
||||
Type: AuthTypeHeader,
|
||||
HeaderName: "X-Plugin-Token",
|
||||
HeaderValueEnv: "PLUGIN_STORE_HEADER",
|
||||
AllowInsecure: true,
|
||||
},
|
||||
{
|
||||
Match: target.URL + "/",
|
||||
ApplyTo: []string{RequestKindArtifact},
|
||||
Type: AuthTypeNone,
|
||||
AllowInsecure: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
data, errDownload := client.DownloadArtifact(context.Background(), Artifact{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: source.URL + "/private/artifact.zip",
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
})
|
||||
if errDownload != nil {
|
||||
t.Fatalf("DownloadArtifact() error = %v", errDownload)
|
||||
}
|
||||
if string(data) != string(artifactData) {
|
||||
t.Fatalf("DownloadArtifact() = %q, want %q", data, artifactData)
|
||||
}
|
||||
if initialHeader != "secret-token" {
|
||||
t.Fatalf("initial auth header = %q, want secret-token", initialHeader)
|
||||
}
|
||||
if redirectedHeader != "" {
|
||||
t.Fatalf("redirected auth header = %q, want empty", redirectedHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStoreAuthHeaderIsAppliedToMatchingRedirect(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_HEADER", "secret-token")
|
||||
|
||||
var redirectedHeader string
|
||||
artifactData := []byte("artifact-data")
|
||||
sum := sha256.Sum256(artifactData)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/private/start.zip" {
|
||||
http.Redirect(w, r, "/private/artifact.zip", http.StatusFound)
|
||||
return
|
||||
}
|
||||
redirectedHeader = r.Header.Get("X-Plugin-Token")
|
||||
_, _ = io.WriteString(w, string(artifactData))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
Auth: []AuthConfig{{
|
||||
Match: server.URL + "/private/",
|
||||
ApplyTo: []string{RequestKindArtifact},
|
||||
Type: AuthTypeHeader,
|
||||
HeaderName: "X-Plugin-Token",
|
||||
HeaderValueEnv: "PLUGIN_STORE_HEADER",
|
||||
AllowInsecure: true,
|
||||
}},
|
||||
}
|
||||
if _, errDownload := client.DownloadArtifact(context.Background(), Artifact{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: server.URL + "/private/start.zip",
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
}); errDownload != nil {
|
||||
t.Fatalf("DownloadArtifact() error = %v", errDownload)
|
||||
}
|
||||
if redirectedHeader != "secret-token" {
|
||||
t.Fatalf("redirected auth header = %q, want secret-token", redirectedHeader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pluginstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SelectArtifact(plan InstallPlan, goos string, goarch string) (Artifact, error) {
|
||||
plan = NormalizeInstallPlan(plan)
|
||||
goos = normalizeGOOS(goos)
|
||||
goarch = normalizeGOARCH(goarch)
|
||||
if plan.Type != InstallTypeDirect {
|
||||
return Artifact{}, fmt.Errorf("install type %q is not direct", plan.Type)
|
||||
}
|
||||
for _, artifact := range plan.Artifacts {
|
||||
if artifact.GOOS == goos && artifact.GOARCH == goarch {
|
||||
return artifact, nil
|
||||
}
|
||||
}
|
||||
return Artifact{}, fmt.Errorf("artifact not found for %s/%s", goos, goarch)
|
||||
}
|
||||
|
||||
func (c Client) DownloadArtifact(ctx context.Context, artifact Artifact) ([]byte, error) {
|
||||
artifact = NormalizeInstallPlan(InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{artifact}}).Artifacts[0]
|
||||
if errValidate := ValidateArtifact(artifact); errValidate != nil {
|
||||
return nil, errValidate
|
||||
}
|
||||
maxSize := int64(0)
|
||||
if artifact.Size > 0 {
|
||||
maxSize = artifact.Size
|
||||
}
|
||||
data, errDownload := c.get(ctx, artifact.URL, "application/octet-stream", RequestKindArtifact, maxSize)
|
||||
if errDownload != nil {
|
||||
return nil, errDownload
|
||||
}
|
||||
if maxSize > 0 && int64(len(data)) > maxSize {
|
||||
return nil, fmt.Errorf("artifact exceeds declared size")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func VerifyArtifactChecksum(artifact Artifact, data []byte) error {
|
||||
expected := strings.ToLower(strings.TrimSpace(artifact.SHA256))
|
||||
if expected == "" {
|
||||
return fmt.Errorf("artifact checksum missing")
|
||||
}
|
||||
actualBytes := sha256.Sum256(data)
|
||||
actual := hex.EncodeToString(actualBytes[:])
|
||||
if actual != expected {
|
||||
return fmt.Errorf("artifact checksum mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+125
-12
@@ -4,15 +4,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const userAgent = "CLIProxyAPI"
|
||||
const maxPluginStoreRedirects = 10
|
||||
|
||||
// HTTPDoer abstracts the HTTP client used to execute requests.
|
||||
type HTTPDoer = httpfetch.Doer
|
||||
@@ -21,6 +24,7 @@ type Client struct {
|
||||
HTTPClient HTTPDoer
|
||||
RegistryURL string
|
||||
UserAgent string
|
||||
Auth []AuthConfig
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
@@ -38,7 +42,7 @@ func (c Client) FetchRegistry(ctx context.Context) (Registry, error) {
|
||||
if registryURL == "" {
|
||||
registryURL = DefaultRegistryURL
|
||||
}
|
||||
data, errDownload := c.get(ctx, registryURL, "application/json")
|
||||
data, errDownload := c.get(ctx, registryURL, "application/json", RequestKindRegistry, 0)
|
||||
if errDownload != nil {
|
||||
return Registry{}, errDownload
|
||||
}
|
||||
@@ -61,7 +65,7 @@ func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release,
|
||||
url.PathEscape(owner),
|
||||
url.PathEscape(repo),
|
||||
)
|
||||
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json")
|
||||
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0)
|
||||
if errDownload != nil {
|
||||
return Release{}, errDownload
|
||||
}
|
||||
@@ -88,7 +92,7 @@ func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string
|
||||
url.PathEscape(repo),
|
||||
url.PathEscape(tag),
|
||||
)
|
||||
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json")
|
||||
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0)
|
||||
if errDownload != nil {
|
||||
return Release{}, errDownload
|
||||
}
|
||||
@@ -113,18 +117,47 @@ func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte,
|
||||
if strings.TrimSpace(asset.BrowserDownloadURL) == "" {
|
||||
return nil, fmt.Errorf("asset %q missing browser_download_url", asset.Name)
|
||||
}
|
||||
return c.get(ctx, asset.BrowserDownloadURL, "application/octet-stream")
|
||||
return c.get(ctx, asset.BrowserDownloadURL, "application/octet-stream", RequestKindArtifact, 0)
|
||||
}
|
||||
|
||||
func (c Client) get(ctx context.Context, requestURL string, accept string) ([]byte, error) {
|
||||
headers := map[string]string{
|
||||
"Accept": accept,
|
||||
"User-Agent": c.userAgent(),
|
||||
func (c Client) get(ctx context.Context, requestURL string, accept string, kind string, maxSize int64) ([]byte, error) {
|
||||
currentURL := strings.TrimSpace(requestURL)
|
||||
for redirects := 0; ; redirects++ {
|
||||
if errURL := validatePluginStoreRequestURL(c.Auth, currentURL, kind); errURL != nil {
|
||||
return nil, errURL
|
||||
}
|
||||
headers := http.Header{
|
||||
"Accept": []string{accept},
|
||||
"User-Agent": []string{c.userAgent()},
|
||||
}
|
||||
if errAuth := applyPluginStoreAuth(headers, c.Auth, currentURL, kind); errAuth != nil {
|
||||
return nil, errAuth
|
||||
}
|
||||
if headers.Get("Authorization") == "" {
|
||||
if token := gitHubAPIToken(currentURL); token != "" {
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
resp, errDo := pluginStoreGetNoRedirect(ctx, c.httpClient(), currentURL, headers)
|
||||
if errDo != nil {
|
||||
return nil, errDo
|
||||
}
|
||||
if pluginStoreRedirectStatus(resp.StatusCode) {
|
||||
nextURL, errRedirect := pluginStoreRedirectURL(resp, currentURL)
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("failed to close plugin store redirect body")
|
||||
}
|
||||
if errRedirect != nil {
|
||||
return nil, errRedirect
|
||||
}
|
||||
if redirects >= maxPluginStoreRedirects {
|
||||
return nil, fmt.Errorf("stopped after %d redirects", maxPluginStoreRedirects)
|
||||
}
|
||||
currentURL = nextURL
|
||||
continue
|
||||
}
|
||||
return readPluginStoreResponse(resp, maxSize)
|
||||
}
|
||||
if token := gitHubAPIToken(requestURL); token != "" {
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
}
|
||||
return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, headers, 0)
|
||||
}
|
||||
|
||||
// gitHubAPIToken returns the optional GitHub token for GitHub API requests to
|
||||
@@ -155,6 +188,86 @@ func (c Client) userAgent() string {
|
||||
return userAgent
|
||||
}
|
||||
|
||||
func pluginStoreGetNoRedirect(ctx context.Context, client HTTPDoer, requestURL string, headers http.Header) (*http.Response, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if errRequest != nil {
|
||||
return nil, fmt.Errorf("create request: %w", errRequest)
|
||||
}
|
||||
req.Header = headers.Clone()
|
||||
resp, errDo := pluginStoreNoRedirectClient(client).Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", errDo)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func pluginStoreNoRedirectClient(client HTTPDoer) HTTPDoer {
|
||||
httpClient, ok := client.(*http.Client)
|
||||
if !ok {
|
||||
return client
|
||||
}
|
||||
clone := *httpClient
|
||||
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func pluginStoreRedirectStatus(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pluginStoreRedirectURL(resp *http.Response, requestURL string) (string, error) {
|
||||
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||
if location == "" {
|
||||
return "", fmt.Errorf("redirect missing Location header")
|
||||
}
|
||||
base, errBase := url.Parse(requestURL)
|
||||
if errBase != nil {
|
||||
return "", fmt.Errorf("parse redirect base: %w", errBase)
|
||||
}
|
||||
next, errNext := base.Parse(location)
|
||||
if errNext != nil {
|
||||
return "", fmt.Errorf("parse redirect location: %w", errNext)
|
||||
}
|
||||
if next.Scheme == "" || next.Host == "" {
|
||||
return "", fmt.Errorf("redirect location is not absolute")
|
||||
}
|
||||
return next.String(), nil
|
||||
}
|
||||
|
||||
func readPluginStoreResponse(resp *http.Response, maxSize int64) ([]byte, error) {
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("failed to close plugin store response body")
|
||||
}
|
||||
}()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
reader := io.Reader(resp.Body)
|
||||
if maxSize > 0 {
|
||||
reader = io.LimitReader(resp.Body, maxSize+1)
|
||||
}
|
||||
data, errRead := io.ReadAll(reader)
|
||||
if errRead != nil {
|
||||
return nil, fmt.Errorf("read response: %w", errRead)
|
||||
}
|
||||
if maxSize > 0 && int64(len(data)) > maxSize {
|
||||
return nil, fmt.Errorf("response exceeds maximum allowed size of %d bytes", maxSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func SelectReleaseAssets(release Release, id, version, goos, goarch string) (ReleaseAsset, ReleaseAsset, error) {
|
||||
archiveName := ArchiveName(id, version, goos, goarch)
|
||||
var archiveAsset ReleaseAsset
|
||||
|
||||
+134
-24
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/cpu"
|
||||
)
|
||||
|
||||
type InstallOptions struct {
|
||||
@@ -38,6 +37,8 @@ var ErrLoadedPluginLocked = errors.New("loaded plugin library cannot be overwrit
|
||||
type InstallResult struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
InstallType string `json:"install_type,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Overwritten bool `json:"overwritten"`
|
||||
Skipped bool `json:"skipped"`
|
||||
@@ -48,6 +49,10 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
|
||||
return InstallResult{}, errValidate
|
||||
}
|
||||
options = normalizeInstallOptions(options)
|
||||
if PluginInstallType(plugin) == InstallTypeDirect {
|
||||
plugin.Version = normalizeVersion(plugin.Version)
|
||||
return c.InstallDirect(ctx, plugin, plugin.Install, options)
|
||||
}
|
||||
release, errRelease := c.FetchLatestRelease(ctx, plugin)
|
||||
if errRelease != nil {
|
||||
return InstallResult{}, errRelease
|
||||
@@ -60,6 +65,25 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
|
||||
return c.installRelease(ctx, plugin, release, latestVersion, options)
|
||||
}
|
||||
|
||||
func (c Client) InstallManifest(ctx context.Context, manifest Manifest, options InstallOptions) (InstallResult, error) {
|
||||
if errValidate := manifest.Validate(); errValidate != nil {
|
||||
return InstallResult{}, errValidate
|
||||
}
|
||||
options = normalizeInstallOptions(options)
|
||||
switch manifest.InstallType() {
|
||||
case InstallTypeDirect:
|
||||
plugin, errPlugin := c.directPluginFromManifest(ctx, manifest)
|
||||
if errPlugin != nil {
|
||||
return InstallResult{}, errPlugin
|
||||
}
|
||||
return c.InstallDirect(ctx, plugin, plugin.Install, options)
|
||||
case InstallTypeGitHubRelease:
|
||||
return c.InstallVersion(ctx, manifest.Plugin(), manifest.ReleaseTag, manifest.Version, options)
|
||||
default:
|
||||
return InstallResult{}, fmt.Errorf("unsupported install type %q", manifest.Install.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// InstallVersion installs a plugin artifact from a fixed release tag/version.
|
||||
func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag string, version string, options InstallOptions) (InstallResult, error) {
|
||||
if errValidate := ValidatePlugin(plugin); errValidate != nil {
|
||||
@@ -110,7 +134,110 @@ func (c Client) installRelease(ctx context.Context, plugin Plugin, release Relea
|
||||
return InstallResult{}, errVerify
|
||||
}
|
||||
plugin.Version = version
|
||||
return InstallArchive(archiveData, plugin, options)
|
||||
result, errInstall := InstallArchive(archiveData, plugin, options)
|
||||
if errInstall != nil {
|
||||
return InstallResult{}, errInstall
|
||||
}
|
||||
result.InstallType = InstallTypeGitHubRelease
|
||||
result.ReleaseTag = strings.TrimSpace(release.TagName)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) InstallDirect(ctx context.Context, plugin Plugin, plan InstallPlan, options InstallOptions) (InstallResult, error) {
|
||||
plugin.ID = strings.TrimSpace(plugin.ID)
|
||||
plugin.Version = normalizeVersion(plugin.Version)
|
||||
if !validPluginID(plugin.ID) {
|
||||
return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID)
|
||||
}
|
||||
if !validPluginVersion(plugin.Version) {
|
||||
return InstallResult{}, fmt.Errorf("invalid plugin version %q", plugin.Version)
|
||||
}
|
||||
plan = NormalizeInstallPlan(plan)
|
||||
plan.Type = InstallTypeDirect
|
||||
if errValidate := ValidateInstallPlan(plan); errValidate != nil {
|
||||
return InstallResult{}, errValidate
|
||||
}
|
||||
options = normalizeInstallOptions(options)
|
||||
artifact, errSelect := SelectArtifact(plan, options.GOOS, options.GOARCH)
|
||||
if errSelect != nil {
|
||||
return InstallResult{}, errSelect
|
||||
}
|
||||
archiveData, errDownload := c.DownloadArtifact(ctx, artifact)
|
||||
if errDownload != nil {
|
||||
return InstallResult{}, fmt.Errorf("download artifact: %w", errDownload)
|
||||
}
|
||||
if errVerify := VerifyArtifactChecksum(artifact, archiveData); errVerify != nil {
|
||||
return InstallResult{}, errVerify
|
||||
}
|
||||
result, errInstall := InstallArchive(archiveData, plugin, options)
|
||||
if errInstall != nil {
|
||||
return InstallResult{}, errInstall
|
||||
}
|
||||
result.InstallType = InstallTypeDirect
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) directPluginFromManifest(ctx context.Context, manifest Manifest) (Plugin, error) {
|
||||
plugin := manifest.Plugin()
|
||||
plugin.Version = normalizeVersion(manifest.Version)
|
||||
plugin.Install = NormalizeInstallPlan(plugin.Install)
|
||||
plugin.Install.Type = InstallTypeDirect
|
||||
if len(plugin.Install.Artifacts) > 0 {
|
||||
return plugin, nil
|
||||
}
|
||||
sourceURL := strings.TrimSpace(manifest.SourceURL)
|
||||
if sourceURL == "" {
|
||||
sourceURL = strings.TrimSpace(c.RegistryURL)
|
||||
}
|
||||
if sourceURL == "" {
|
||||
return Plugin{}, fmt.Errorf("direct install manifest missing source-url")
|
||||
}
|
||||
sourceClient := c
|
||||
sourceClient.RegistryURL = sourceURL
|
||||
registry, errRegistry := sourceClient.FetchRegistry(ctx)
|
||||
if errRegistry != nil {
|
||||
return Plugin{}, fmt.Errorf("fetch direct install source: %w", errRegistry)
|
||||
}
|
||||
resolved, okPlugin := registry.PluginByID(manifest.ID)
|
||||
if !okPlugin {
|
||||
return Plugin{}, fmt.Errorf("direct install plugin %q not found in source", strings.TrimSpace(manifest.ID))
|
||||
}
|
||||
if PluginInstallType(resolved) != InstallTypeDirect {
|
||||
return Plugin{}, fmt.Errorf("direct install plugin %q resolved as %q", strings.TrimSpace(manifest.ID), PluginInstallType(resolved))
|
||||
}
|
||||
return directPluginVersion(resolved, manifest.ID, manifest.Version)
|
||||
}
|
||||
|
||||
func directPluginVersion(plugin Plugin, id string, version string) (Plugin, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
version = normalizeVersion(version)
|
||||
if normalizeVersion(plugin.Version) == version {
|
||||
plugin.Version = version
|
||||
plugin.Install = NormalizeInstallPlan(plugin.Install)
|
||||
plugin.Install.Type = InstallTypeDirect
|
||||
if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil {
|
||||
return Plugin{}, fmt.Errorf("direct install plugin %q version %q: %w", id, version, errPlan)
|
||||
}
|
||||
return plugin, nil
|
||||
}
|
||||
for _, candidate := range plugin.Versions {
|
||||
if normalizeVersion(candidate.Version) != version {
|
||||
continue
|
||||
}
|
||||
plugin.Version = version
|
||||
plugin.Install = NormalizeInstallPlan(candidate.Install)
|
||||
if plugin.Install.Type == "" {
|
||||
plugin.Install.Type = InstallTypeDirect
|
||||
}
|
||||
if plugin.Install.Type != InstallTypeDirect {
|
||||
return Plugin{}, fmt.Errorf("direct install plugin %q version %q resolved as %q", id, version, plugin.Install.Type)
|
||||
}
|
||||
if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil {
|
||||
return Plugin{}, fmt.Errorf("direct install plugin %q version %q: %w", id, version, errPlan)
|
||||
}
|
||||
return plugin, nil
|
||||
}
|
||||
return Plugin{}, fmt.Errorf("direct install plugin %q version %q not found in source", id, version)
|
||||
}
|
||||
|
||||
func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (InstallResult, error) {
|
||||
@@ -283,7 +410,7 @@ func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) {
|
||||
if root == "" {
|
||||
root = "plugins"
|
||||
}
|
||||
candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant())
|
||||
candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH)
|
||||
extension := pluginExtension(runtime.GOOS)
|
||||
selected := make([]pluginFileInfo, 0)
|
||||
seen := make(map[string]struct{})
|
||||
@@ -320,11 +447,8 @@ func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) {
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string {
|
||||
dirs := make([]string, 0, 3)
|
||||
if variant != "" {
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant))
|
||||
}
|
||||
func pluginCandidateDirs(root string, goos string, goarch string) []string {
|
||||
dirs := make([]string, 0, 2)
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch))
|
||||
dirs = append(dirs, root)
|
||||
return dirs
|
||||
@@ -392,22 +516,6 @@ func pluginExtension(goos string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func cpuVariant() string {
|
||||
if runtime.GOARCH != "amd64" {
|
||||
return ""
|
||||
}
|
||||
if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL {
|
||||
return "v4"
|
||||
}
|
||||
if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA {
|
||||
return "v3"
|
||||
}
|
||||
if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT {
|
||||
return "v2"
|
||||
}
|
||||
return "v1"
|
||||
}
|
||||
|
||||
func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error {
|
||||
targetDir := filepath.Dir(targetPath)
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
@@ -482,5 +590,7 @@ func normalizeInstallOptions(options InstallOptions) InstallOptions {
|
||||
if options.GOARCH == "" {
|
||||
options.GOARCH = runtime.GOARCH
|
||||
}
|
||||
options.GOOS = normalizeGOOS(options.GOOS)
|
||||
options.GOARCH = normalizeGOARCH(options.GOARCH)
|
||||
return options
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) {
|
||||
loaded bool
|
||||
wantBlocked bool
|
||||
}{
|
||||
{name: "windows loaded", goos: "windows", loaded: true, wantBlocked: true},
|
||||
{name: "windows loaded", goos: "windows", loaded: true, wantBlocked: false},
|
||||
{name: "windows not loaded", goos: "windows", loaded: false, wantBlocked: false},
|
||||
{name: "linux loaded", goos: "linux", loaded: true, wantBlocked: false},
|
||||
{name: "darwin loaded", goos: "darwin", loaded: true, wantBlocked: false},
|
||||
@@ -53,10 +53,18 @@ func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) {
|
||||
func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
targetDir := filepath.Join(root, "windows", "amd64")
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider-v0.1.0.dll"), []byte("old"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
_, errInstall := InstallArchive(makeZip(t, map[string]string{
|
||||
"sample-provider.dll": "library-data",
|
||||
}), testPlugin(), InstallOptions{
|
||||
PluginsDir: t.TempDir(),
|
||||
PluginsDir: root,
|
||||
GOOS: "windows",
|
||||
GOARCH: "amd64",
|
||||
PluginLoaded: func() bool { return true },
|
||||
@@ -74,7 +82,7 @@ func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) {
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
targetPath := filepath.Join(targetDir, "sample-provider.dll")
|
||||
targetPath := filepath.Join(targetDir, "sample-provider-v0.1.0.dll")
|
||||
if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
@@ -120,7 +128,7 @@ func TestInstallArchiveSkipsIdenticalLoadedWindowsPlugin(t *testing.T) {
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
targetPath := filepath.Join(targetDir, "sample-provider.dll")
|
||||
targetPath := filepath.Join(targetDir, "sample-provider-v0.1.0.dll")
|
||||
if errWrite := os.WriteFile(targetPath, []byte("same"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
@@ -170,7 +178,7 @@ func TestInstallArchiveWritesPlatformPlugin(t *testing.T) {
|
||||
if errInstall != nil {
|
||||
t.Fatalf("InstallArchive() error = %v", errInstall)
|
||||
}
|
||||
wantPath := filepath.Join(root, "darwin", "arm64", "sample-provider.dylib")
|
||||
wantPath := filepath.Join(root, "darwin", "arm64", "sample-provider-v0.1.0.dylib")
|
||||
if result.Path != wantPath {
|
||||
t.Fatalf("Path = %q, want %q", result.Path, wantPath)
|
||||
}
|
||||
@@ -191,7 +199,7 @@ func TestInstallArchiveReportsOverwrite(t *testing.T) {
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider.dylib"), []byte("old"), 0o644); errWrite != nil {
|
||||
if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider-v0.1.0.dylib"), []byte("old"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
result, errInstall := InstallArchive(makeZip(t, map[string]string{
|
||||
@@ -209,7 +217,10 @@ func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
existingPath := filepath.Join(root, "sample-provider"+pluginExtension(runtime.GOOS))
|
||||
existingPath := filepath.Join(root, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.1.0"+pluginExtension(runtime.GOOS))
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(existingPath), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(existingPath, []byte("old"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
@@ -327,7 +338,7 @@ func TestInstallUsesLatestReleaseVersion(t *testing.T) {
|
||||
if result.Version != "0.2.0" {
|
||||
t.Fatalf("Version = %q, want 0.2.0 from latest release tag", result.Version)
|
||||
}
|
||||
data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider.dylib"))
|
||||
data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider-v0.2.0.dylib"))
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile() error = %v", errRead)
|
||||
}
|
||||
@@ -366,7 +377,7 @@ func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) {
|
||||
if result.Version != "0.3.0" {
|
||||
t.Fatalf("Version = %q, want 0.3.0", result.Version)
|
||||
}
|
||||
data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider.so"))
|
||||
data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider-v0.3.0.so"))
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile() error = %v", errRead)
|
||||
}
|
||||
@@ -375,6 +386,182 @@ func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManifestResolvesDirectArtifactsFromSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"})
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
registryURL := "https://registry.example/registry.json"
|
||||
artifactURL := "https://downloads.example/sample-provider_0.4.0_linux_amd64.zip"
|
||||
latestArtifactURL := "https://downloads.example/sample-provider_0.5.0_linux_amd64.zip"
|
||||
client := Client{HTTPClient: mapHTTPDoer{
|
||||
registryURL: []byte(`{
|
||||
"schema_version": 2,
|
||||
"plugins": [{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"description": "Adds sample provider support.",
|
||||
"author": "author-name",
|
||||
"version": "0.5.0",
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "linux",
|
||||
"goarch": "amd64",
|
||||
"url": "` + latestArtifactURL + `",
|
||||
"sha256": "` + hex.EncodeToString(checksum[:]) + `"
|
||||
}]
|
||||
},
|
||||
"versions": [{
|
||||
"version": "0.4.0",
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "linux",
|
||||
"goarch": "amd64",
|
||||
"url": "` + artifactURL + `",
|
||||
"sha256": "` + hex.EncodeToString(checksum[:]) + `"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}`),
|
||||
artifactURL: archiveData,
|
||||
}}
|
||||
|
||||
result, errInstall := client.InstallManifest(context.Background(), Manifest{
|
||||
SchemaVersion: SchemaVersionV2,
|
||||
ID: "sample-provider",
|
||||
Version: "0.4.0",
|
||||
SourceURL: registryURL,
|
||||
Install: InstallPlan{Type: InstallTypeDirect},
|
||||
}, InstallOptions{
|
||||
PluginsDir: root,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
})
|
||||
if errInstall != nil {
|
||||
t.Fatalf("InstallManifest() error = %v", errInstall)
|
||||
}
|
||||
if result.InstallType != InstallTypeDirect || result.Version != "0.4.0" {
|
||||
t.Fatalf("result = %#v, want direct 0.4.0", result)
|
||||
}
|
||||
data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider-v0.4.0.so"))
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile() error = %v", errRead)
|
||||
}
|
||||
if string(data) != "library-data" {
|
||||
t.Fatalf("installed data = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallDirectDownloadsMatchingArtifactWithBearerAuth(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
|
||||
root := t.TempDir()
|
||||
archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"})
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
artifactURL := "https://downloads.example/private/sample-provider_0.4.0_linux_amd64.zip"
|
||||
client := Client{
|
||||
HTTPClient: authCheckingHTTPDoer{
|
||||
url: artifactURL,
|
||||
wantAuth: "Bearer secret-token",
|
||||
responseBytes: archiveData,
|
||||
},
|
||||
Auth: []AuthConfig{{
|
||||
Match: "https://downloads.example/private/",
|
||||
ApplyTo: []string{RequestKindArtifact},
|
||||
Type: AuthTypeBearer,
|
||||
TokenEnv: "PLUGIN_STORE_TOKEN",
|
||||
}},
|
||||
}
|
||||
|
||||
plugin := testPlugin()
|
||||
plugin.Version = "0.4.0"
|
||||
plugin.Install = InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: artifactURL,
|
||||
SHA256: hex.EncodeToString(checksum[:]),
|
||||
}},
|
||||
}
|
||||
result, errInstall := client.Install(context.Background(), plugin, InstallOptions{
|
||||
PluginsDir: root,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
})
|
||||
if errInstall != nil {
|
||||
t.Fatalf("Install() error = %v", errInstall)
|
||||
}
|
||||
if result.InstallType != InstallTypeDirect || result.Version != "0.4.0" {
|
||||
t.Fatalf("result = %#v, want direct 0.4.0", result)
|
||||
}
|
||||
data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider-v0.4.0.so"))
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile() error = %v", errRead)
|
||||
}
|
||||
if string(data) != "library-data" {
|
||||
t.Fatalf("installed data = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallDirectRejectsChecksumMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"})
|
||||
client := Client{HTTPClient: mapHTTPDoer{
|
||||
"https://downloads.example/sample-provider.zip": archiveData,
|
||||
}}
|
||||
plugin := testPlugin()
|
||||
plugin.Version = "0.4.0"
|
||||
plugin.Install = InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: "https://downloads.example/sample-provider.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}},
|
||||
}
|
||||
_, errInstall := client.Install(context.Background(), plugin, InstallOptions{
|
||||
PluginsDir: t.TempDir(),
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
})
|
||||
if errInstall == nil {
|
||||
t.Fatal("Install() error = nil")
|
||||
}
|
||||
if !strings.Contains(errInstall.Error(), "checksum mismatch") {
|
||||
t.Fatalf("Install() error = %v, want checksum mismatch", errInstall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArtifactEnforcesDeclaredSizeDuringRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := &trackingReadCloser{data: []byte("0123456789")}
|
||||
sum := sha256.Sum256(body.data)
|
||||
client := Client{HTTPClient: singleResponseHTTPDoer{body: body}}
|
||||
_, errDownload := client.DownloadArtifact(context.Background(), Artifact{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: "https://downloads.example/sample-provider.zip",
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Size: 4,
|
||||
})
|
||||
if errDownload == nil {
|
||||
t.Fatal("DownloadArtifact() error = nil")
|
||||
}
|
||||
if !strings.Contains(errDownload.Error(), "maximum allowed size") {
|
||||
t.Fatalf("DownloadArtifact() error = %v, want size limit", errDownload)
|
||||
}
|
||||
if body.offset > 5 {
|
||||
t.Fatalf("download read %d bytes, want at most size+1", body.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -440,6 +627,68 @@ func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
type authCheckingHTTPDoer struct {
|
||||
url string
|
||||
wantAuth string
|
||||
responseBytes []byte
|
||||
}
|
||||
|
||||
type singleResponseHTTPDoer struct {
|
||||
body io.ReadCloser
|
||||
}
|
||||
|
||||
func (c singleResponseHTTPDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: c.body,
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type trackingReadCloser struct {
|
||||
data []byte
|
||||
offset int
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Read(p []byte) (int, error) {
|
||||
if r.offset >= len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.data[r.offset:])
|
||||
r.offset += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c authCheckingHTTPDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() != c.url {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("not found")),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
if gotAuth := req.Header.Get("Authorization"); gotAuth != c.wantAuth {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Body: io.NopCloser(strings.NewReader("bad auth")),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(c.responseBytes)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func testPlugin() Plugin {
|
||||
return Plugin{
|
||||
ID: "sample-provider",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package pluginstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `yaml:"schema-version,omitempty" json:"schema_version,omitempty"`
|
||||
ID string `yaml:"id,omitempty" json:"id,omitempty"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Author string `yaml:"author,omitempty" json:"author,omitempty"`
|
||||
Version string `yaml:"version,omitempty" json:"version,omitempty"`
|
||||
ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"`
|
||||
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
|
||||
Logo string `yaml:"logo,omitempty" json:"logo,omitempty"`
|
||||
Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
|
||||
License string `yaml:"license,omitempty" json:"license,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||
SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"`
|
||||
SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"`
|
||||
SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"`
|
||||
Install InstallPlan `yaml:"install,omitempty" json:"install,omitempty"`
|
||||
}
|
||||
|
||||
func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) {
|
||||
version, errVersion := ReleaseVersion(release)
|
||||
if errVersion != nil {
|
||||
return Manifest{}, errVersion
|
||||
}
|
||||
return manifestFromPlugin(source, plugin, Manifest{
|
||||
Version: version,
|
||||
ReleaseTag: strings.TrimSpace(release.TagName),
|
||||
Repository: strings.TrimSpace(plugin.Repository),
|
||||
Install: InstallPlan{Type: InstallTypeGitHubRelease},
|
||||
}), nil
|
||||
}
|
||||
|
||||
func ManifestFromPlugin(source Source, plugin Plugin) (Manifest, error) {
|
||||
if errValidate := ValidatePlugin(plugin); errValidate != nil {
|
||||
return Manifest{}, errValidate
|
||||
}
|
||||
switch PluginInstallType(plugin) {
|
||||
case InstallTypeDirect:
|
||||
return Manifest{
|
||||
SchemaVersion: SchemaVersionV2,
|
||||
ID: strings.TrimSpace(plugin.ID),
|
||||
Version: strings.TrimSpace(plugin.Version),
|
||||
SourceID: strings.TrimSpace(source.ID),
|
||||
SourceName: strings.TrimSpace(source.Name),
|
||||
SourceURL: strings.TrimSpace(source.URL),
|
||||
Install: InstallPlan{Type: InstallTypeDirect},
|
||||
}, nil
|
||||
case InstallTypeGitHubRelease:
|
||||
return Manifest{}, fmt.Errorf("github-release manifest requires a resolved release")
|
||||
default:
|
||||
return Manifest{}, fmt.Errorf("unsupported install type %q", plugin.Install.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func manifestFromPlugin(source Source, plugin Plugin, base Manifest) Manifest {
|
||||
base.ID = strings.TrimSpace(plugin.ID)
|
||||
base.Name = strings.TrimSpace(plugin.Name)
|
||||
base.Description = strings.TrimSpace(plugin.Description)
|
||||
base.Author = strings.TrimSpace(plugin.Author)
|
||||
base.Logo = strings.TrimSpace(plugin.Logo)
|
||||
base.Homepage = strings.TrimSpace(plugin.Homepage)
|
||||
base.License = strings.TrimSpace(plugin.License)
|
||||
base.Tags = append([]string(nil), plugin.Tags...)
|
||||
base.SourceID = strings.TrimSpace(source.ID)
|
||||
base.SourceName = strings.TrimSpace(source.Name)
|
||||
base.SourceURL = strings.TrimSpace(source.URL)
|
||||
return base
|
||||
}
|
||||
|
||||
func (m Manifest) Plugin() Plugin {
|
||||
return Plugin{
|
||||
ID: strings.TrimSpace(m.ID),
|
||||
Name: strings.TrimSpace(m.Name),
|
||||
Description: strings.TrimSpace(m.Description),
|
||||
Author: strings.TrimSpace(m.Author),
|
||||
Version: strings.TrimSpace(m.Version),
|
||||
Repository: strings.TrimSpace(m.Repository),
|
||||
Logo: strings.TrimSpace(m.Logo),
|
||||
Homepage: strings.TrimSpace(m.Homepage),
|
||||
License: strings.TrimSpace(m.License),
|
||||
Tags: append([]string(nil), m.Tags...),
|
||||
Install: NormalizeInstallPlan(m.Install),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manifest) InstallType() string {
|
||||
installType := strings.ToLower(strings.TrimSpace(m.Install.Type))
|
||||
if installType == "" {
|
||||
return InstallTypeGitHubRelease
|
||||
}
|
||||
return installType
|
||||
}
|
||||
|
||||
func (m Manifest) Validate() error {
|
||||
version := strings.TrimSpace(m.Version)
|
||||
if version == "" {
|
||||
return fmt.Errorf("missing required field version")
|
||||
}
|
||||
if !validPluginVersion(normalizeVersion(version)) {
|
||||
return fmt.Errorf("invalid plugin version %q", m.Version)
|
||||
}
|
||||
switch m.InstallType() {
|
||||
case InstallTypeDirect:
|
||||
if m.SchemaVersion != 0 && m.SchemaVersion != SchemaVersionV2 {
|
||||
return fmt.Errorf("unsupported schema-version %d", m.SchemaVersion)
|
||||
}
|
||||
if errID := validateManifestPluginID(m.ID); errID != nil {
|
||||
return errID
|
||||
}
|
||||
plan := NormalizeInstallPlan(m.Install)
|
||||
plan.Type = InstallTypeDirect
|
||||
if len(plan.Artifacts) > 0 {
|
||||
return ValidateInstallPlan(plan)
|
||||
}
|
||||
return validateManifestSourceURL(m.SourceURL)
|
||||
case InstallTypeGitHubRelease:
|
||||
releaseTag := strings.TrimSpace(m.ReleaseTag)
|
||||
if releaseTag == "" {
|
||||
return fmt.Errorf("missing required field release-tag")
|
||||
}
|
||||
plugin := m.Plugin()
|
||||
plugin.Install = InstallPlan{Type: InstallTypeGitHubRelease}
|
||||
if errValidate := ValidatePlugin(plugin); errValidate != nil {
|
||||
return errValidate
|
||||
}
|
||||
releaseVersion, errVersion := ReleaseVersion(Release{TagName: releaseTag})
|
||||
if errVersion != nil {
|
||||
return errVersion
|
||||
}
|
||||
if releaseVersion != normalizeVersion(version) {
|
||||
return fmt.Errorf("release-tag %q resolves version %q, want %q", releaseTag, releaseVersion, normalizeVersion(version))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported install type %q", m.Install.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func validateManifestPluginID(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("missing required field id")
|
||||
}
|
||||
if !validPluginID(id) {
|
||||
return fmt.Errorf("invalid plugin id %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManifestSourceURL(sourceURL string) error {
|
||||
sourceURL = strings.TrimSpace(sourceURL)
|
||||
if sourceURL == "" {
|
||||
return fmt.Errorf("missing required field source-url")
|
||||
}
|
||||
parsed, errParse := url.Parse(sourceURL)
|
||||
if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("invalid source-url")
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return fmt.Errorf("source-url must use http or https")
|
||||
}
|
||||
if hasSensitiveQueryParameter(parsed) {
|
||||
return fmt.Errorf("source-url contains sensitive query parameter")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,6 +16,10 @@ const (
|
||||
DefaultSourceID = "official"
|
||||
DefaultSourceName = "Official"
|
||||
SchemaVersion = 1
|
||||
SchemaVersionV2 = 2
|
||||
|
||||
InstallTypeGitHubRelease = "github-release"
|
||||
InstallTypeDirect = "direct"
|
||||
)
|
||||
|
||||
var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)
|
||||
@@ -33,16 +37,42 @@ type Registry struct {
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Repository string `json:"repository"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Versions []Version `json:"versions,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Install InstallPlan `json:"install,omitempty"`
|
||||
AuthRequired bool `json:"auth_required,omitempty"`
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
Version string `json:"version"`
|
||||
Install InstallPlan `json:"install,omitempty"`
|
||||
}
|
||||
|
||||
type InstallPlan struct {
|
||||
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||
Artifacts []Artifact `yaml:"artifacts,omitempty" json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
type Artifact struct {
|
||||
GOOS string `yaml:"goos,omitempty" json:"goos,omitempty"`
|
||||
GOARCH string `yaml:"goarch,omitempty" json:"goarch,omitempty"`
|
||||
URL string `yaml:"url,omitempty" json:"url,omitempty"`
|
||||
SHA256 string `yaml:"sha256,omitempty" json:"sha256,omitempty"`
|
||||
Size int64 `yaml:"size,omitempty" json:"size,omitempty"`
|
||||
}
|
||||
|
||||
type Platform struct {
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
}
|
||||
|
||||
func DefaultSource() Source {
|
||||
@@ -121,6 +151,12 @@ func normalizeRegistry(registry *Registry) {
|
||||
plugin.Logo = strings.TrimSpace(plugin.Logo)
|
||||
plugin.Homepage = strings.TrimSpace(plugin.Homepage)
|
||||
plugin.License = strings.TrimSpace(plugin.License)
|
||||
plugin.Install = NormalizeInstallPlan(plugin.Install)
|
||||
for versionIndex := range plugin.Versions {
|
||||
version := &plugin.Versions[versionIndex]
|
||||
version.Version = normalizeVersion(version.Version)
|
||||
version.Install = NormalizeInstallPlan(version.Install)
|
||||
}
|
||||
for tagIndex := range plugin.Tags {
|
||||
plugin.Tags[tagIndex] = strings.TrimSpace(plugin.Tags[tagIndex])
|
||||
}
|
||||
@@ -128,11 +164,14 @@ func normalizeRegistry(registry *Registry) {
|
||||
}
|
||||
|
||||
func ValidateRegistry(registry Registry) error {
|
||||
if registry.SchemaVersion != SchemaVersion {
|
||||
if registry.SchemaVersion != SchemaVersion && registry.SchemaVersion != SchemaVersionV2 {
|
||||
return fmt.Errorf("unsupported schema_version %d", registry.SchemaVersion)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(registry.Plugins))
|
||||
for index, plugin := range registry.Plugins {
|
||||
if registry.SchemaVersion == SchemaVersion && PluginInstallType(plugin) == InstallTypeDirect {
|
||||
return fmt.Errorf("plugins[%d]: direct install requires schema_version %d", index, SchemaVersionV2)
|
||||
}
|
||||
if errValidate := ValidatePlugin(plugin); errValidate != nil {
|
||||
return fmt.Errorf("plugins[%d]: %w", index, errValidate)
|
||||
}
|
||||
@@ -151,7 +190,10 @@ func ValidatePlugin(plugin Plugin) error {
|
||||
"name": plugin.Name,
|
||||
"description": plugin.Description,
|
||||
"author": plugin.Author,
|
||||
"repository": plugin.Repository,
|
||||
}
|
||||
installType := PluginInstallType(plugin)
|
||||
if installType == InstallTypeGitHubRelease {
|
||||
required["repository"] = plugin.Repository
|
||||
}
|
||||
for field, value := range required {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
@@ -166,12 +208,202 @@ func ValidatePlugin(plugin Plugin) error {
|
||||
if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) {
|
||||
return fmt.Errorf("invalid plugin version %q", plugin.Version)
|
||||
}
|
||||
if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil {
|
||||
return errRepository
|
||||
switch installType {
|
||||
case InstallTypeGitHubRelease:
|
||||
if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil {
|
||||
return errRepository
|
||||
}
|
||||
case InstallTypeDirect:
|
||||
if strings.TrimSpace(plugin.Version) == "" {
|
||||
return fmt.Errorf("missing required field version")
|
||||
}
|
||||
if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil {
|
||||
return errPlan
|
||||
}
|
||||
if errVersions := ValidatePluginVersions(plugin); errVersions != nil {
|
||||
return errVersions
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported install type %q", plugin.Install.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePluginVersions(plugin Plugin) error {
|
||||
if len(plugin.Versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(plugin.Versions))
|
||||
for index, version := range plugin.Versions {
|
||||
version.Version = normalizeVersion(version.Version)
|
||||
if !validPluginVersion(version.Version) {
|
||||
return fmt.Errorf("versions[%d]: invalid plugin version %q", index, version.Version)
|
||||
}
|
||||
if _, exists := seen[version.Version]; exists {
|
||||
return fmt.Errorf("versions[%d]: duplicate plugin version %q", index, version.Version)
|
||||
}
|
||||
seen[version.Version] = struct{}{}
|
||||
installType := strings.ToLower(strings.TrimSpace(version.Install.Type))
|
||||
if installType == "" {
|
||||
installType = PluginInstallType(plugin)
|
||||
version.Install.Type = installType
|
||||
}
|
||||
if installType != PluginInstallType(plugin) {
|
||||
return fmt.Errorf("versions[%d]: install type %q does not match plugin install type %q", index, installType, PluginInstallType(plugin))
|
||||
}
|
||||
if errPlan := ValidateInstallPlan(version.Install); errPlan != nil {
|
||||
return fmt.Errorf("versions[%d]: %w", index, errPlan)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PluginInstallType(plugin Plugin) string {
|
||||
installType := strings.ToLower(strings.TrimSpace(plugin.Install.Type))
|
||||
if installType == "" {
|
||||
return InstallTypeGitHubRelease
|
||||
}
|
||||
return installType
|
||||
}
|
||||
|
||||
func NormalizeInstallPlan(plan InstallPlan) InstallPlan {
|
||||
plan.Type = strings.ToLower(strings.TrimSpace(plan.Type))
|
||||
for index := range plan.Artifacts {
|
||||
artifact := &plan.Artifacts[index]
|
||||
artifact.GOOS = normalizeGOOS(artifact.GOOS)
|
||||
artifact.GOARCH = normalizeGOARCH(artifact.GOARCH)
|
||||
artifact.URL = strings.TrimSpace(artifact.URL)
|
||||
artifact.SHA256 = strings.ToLower(strings.TrimSpace(artifact.SHA256))
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func ValidateInstallPlan(plan InstallPlan) error {
|
||||
plan = NormalizeInstallPlan(plan)
|
||||
if plan.Type == "" {
|
||||
return fmt.Errorf("missing install type")
|
||||
}
|
||||
if plan.Type != InstallTypeDirect && plan.Type != InstallTypeGitHubRelease {
|
||||
return fmt.Errorf("unsupported install type %q", plan.Type)
|
||||
}
|
||||
if plan.Type != InstallTypeDirect {
|
||||
return nil
|
||||
}
|
||||
if len(plan.Artifacts) == 0 {
|
||||
return fmt.Errorf("direct install requires at least one artifact")
|
||||
}
|
||||
for index, artifact := range plan.Artifacts {
|
||||
if errArtifact := ValidateArtifact(artifact); errArtifact != nil {
|
||||
return fmt.Errorf("artifacts[%d]: %w", index, errArtifact)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateArtifact(artifact Artifact) error {
|
||||
artifact.GOOS = normalizeGOOS(artifact.GOOS)
|
||||
artifact.GOARCH = normalizeGOARCH(artifact.GOARCH)
|
||||
artifact.URL = strings.TrimSpace(artifact.URL)
|
||||
artifact.SHA256 = strings.ToLower(strings.TrimSpace(artifact.SHA256))
|
||||
if artifact.GOOS == "" {
|
||||
return fmt.Errorf("missing goos")
|
||||
}
|
||||
if artifact.GOARCH == "" {
|
||||
return fmt.Errorf("missing goarch")
|
||||
}
|
||||
if artifact.URL == "" {
|
||||
return fmt.Errorf("missing url")
|
||||
}
|
||||
parsed, errParse := url.Parse(artifact.URL)
|
||||
if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("invalid artifact url")
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return fmt.Errorf("artifact url must use http or https")
|
||||
}
|
||||
if hasSensitiveQueryParameter(parsed) {
|
||||
return fmt.Errorf("artifact url contains sensitive query parameter")
|
||||
}
|
||||
if artifact.SHA256 == "" {
|
||||
return fmt.Errorf("missing sha256")
|
||||
}
|
||||
if len(artifact.SHA256) != sha256.Size*2 {
|
||||
return fmt.Errorf("invalid sha256 length")
|
||||
}
|
||||
if _, errDecode := hex.DecodeString(artifact.SHA256); errDecode != nil {
|
||||
return fmt.Errorf("invalid sha256: %w", errDecode)
|
||||
}
|
||||
if artifact.Size < 0 {
|
||||
return fmt.Errorf("invalid size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PluginPlatforms(plugin Plugin) []Platform {
|
||||
if PluginInstallType(plugin) != InstallTypeDirect {
|
||||
return nil
|
||||
}
|
||||
artifacts := PluginArtifacts(plugin)
|
||||
seen := make(map[Platform]struct{}, len(artifacts))
|
||||
platforms := make([]Platform, 0, len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
platform := Platform{GOOS: artifact.GOOS, GOARCH: artifact.GOARCH}
|
||||
if platform.GOOS == "" || platform.GOARCH == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[platform]; exists {
|
||||
continue
|
||||
}
|
||||
seen[platform] = struct{}{}
|
||||
platforms = append(platforms, platform)
|
||||
}
|
||||
return platforms
|
||||
}
|
||||
|
||||
func PluginArtifacts(plugin Plugin) []Artifact {
|
||||
if PluginInstallType(plugin) != InstallTypeDirect {
|
||||
return nil
|
||||
}
|
||||
artifacts := append([]Artifact(nil), NormalizeInstallPlan(plugin.Install).Artifacts...)
|
||||
for _, version := range plugin.Versions {
|
||||
artifacts = append(artifacts, NormalizeInstallPlan(version.Install).Artifacts...)
|
||||
}
|
||||
return artifacts
|
||||
}
|
||||
|
||||
func normalizeGOOS(goos string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(goos)) {
|
||||
case "mac", "macos", "osx":
|
||||
return "darwin"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(goos))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGOARCH(goarch string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(goarch)) {
|
||||
case "x64", "x86_64":
|
||||
return "amd64"
|
||||
case "aarch64":
|
||||
return "arm64"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(goarch))
|
||||
}
|
||||
}
|
||||
|
||||
func hasSensitiveQueryParameter(parsed *url.URL) bool {
|
||||
if parsed == nil || parsed.RawQuery == "" {
|
||||
return false
|
||||
}
|
||||
for key := range parsed.Query() {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "token", "access_token", "access_key", "secret", "secret_key", "api_key":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validPluginVersion(version string) bool {
|
||||
return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,127 @@ func TestValidateRegistryAllowsMissingVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistrySupportsDirectInstall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry, errParse := ParseRegistry([]byte(`{
|
||||
"schema_version": 2,
|
||||
"plugins": [{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"description": "Adds sample provider support.",
|
||||
"author": "author-name",
|
||||
"version": "0.2.0",
|
||||
"auth_required": true,
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "windows",
|
||||
"goarch": "x64",
|
||||
"url": "https://downloads.example/sample-provider.zip",
|
||||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}]
|
||||
},
|
||||
"versions": [{
|
||||
"version": "0.1.0",
|
||||
"install": {
|
||||
"type": "direct",
|
||||
"artifacts": [{
|
||||
"goos": "linux",
|
||||
"goarch": "aarch64",
|
||||
"url": "https://downloads.example/sample-provider-0.1.0.zip",
|
||||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}`))
|
||||
if errParse != nil {
|
||||
t.Fatalf("ParseRegistry() error = %v", errParse)
|
||||
}
|
||||
plugin, ok := registry.PluginByID("sample-provider")
|
||||
if !ok {
|
||||
t.Fatal("PluginByID(sample-provider) missing")
|
||||
}
|
||||
if PluginInstallType(plugin) != InstallTypeDirect {
|
||||
t.Fatalf("install type = %q, want direct", PluginInstallType(plugin))
|
||||
}
|
||||
if !plugin.AuthRequired {
|
||||
t.Fatal("AuthRequired = false, want true")
|
||||
}
|
||||
if len(plugin.Versions) != 1 || plugin.Versions[0].Version != "0.1.0" {
|
||||
t.Fatalf("versions = %#v, want normalized 0.1.0 entry", plugin.Versions)
|
||||
}
|
||||
platforms := PluginPlatforms(plugin)
|
||||
if len(platforms) != 2 ||
|
||||
platforms[0].GOOS != "windows" || platforms[0].GOARCH != "amd64" ||
|
||||
platforms[1].GOOS != "linux" || platforms[1].GOARCH != "arm64" {
|
||||
t.Fatalf("platforms = %#v, want normalized windows/amd64 and linux/arm64", platforms)
|
||||
}
|
||||
artifacts := PluginArtifacts(plugin)
|
||||
if len(artifacts) != 2 ||
|
||||
artifacts[0].GOOS != "windows" || artifacts[0].GOARCH != "amd64" ||
|
||||
artifacts[1].GOOS != "linux" || artifacts[1].GOARCH != "arm64" {
|
||||
t.Fatalf("artifacts = %#v, want normalized top-level and version artifacts", artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistryRejectsInvalidDirectInstall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := Registry{SchemaVersion: SchemaVersionV2, Plugins: []Plugin{{
|
||||
ID: "sample-provider",
|
||||
Name: "Sample Provider",
|
||||
Description: "Adds sample provider support.",
|
||||
Author: "author-name",
|
||||
Version: "0.2.0",
|
||||
Install: InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: "https://downloads.example/sample.zip?token=secret",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}},
|
||||
},
|
||||
}}}
|
||||
errValidate := ValidateRegistry(registry)
|
||||
if errValidate == nil {
|
||||
t.Fatal("ValidateRegistry() error = nil")
|
||||
}
|
||||
if !strings.Contains(errValidate.Error(), "sensitive query") {
|
||||
t.Fatalf("ValidateRegistry() error = %v, want sensitive query", errValidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistryRejectsDirectInstallInSchemaV1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := Registry{SchemaVersion: SchemaVersion, Plugins: []Plugin{{
|
||||
ID: "sample-provider",
|
||||
Name: "Sample Provider",
|
||||
Description: "Adds sample provider support.",
|
||||
Author: "author-name",
|
||||
Version: "0.2.0",
|
||||
Install: InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: "https://downloads.example/sample.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}},
|
||||
},
|
||||
}}}
|
||||
errValidate := ValidateRegistry(registry)
|
||||
if errValidate == nil {
|
||||
t.Fatal("ValidateRegistry() error = nil")
|
||||
}
|
||||
if !strings.Contains(errValidate.Error(), "schema_version 2") {
|
||||
t.Fatalf("ValidateRegistry() error = %v, want schema_version 2", errValidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistryRejectsInvalidEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -102,7 +223,7 @@ func TestValidateRegistryRejectsInvalidEntries(t *testing.T) {
|
||||
{
|
||||
name: "schema version",
|
||||
mutate: func(registry *Registry) {
|
||||
registry.SchemaVersion = 2
|
||||
registry.SchemaVersion = 3
|
||||
},
|
||||
wantErr: "unsupported schema_version",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ package pluginstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -16,15 +15,35 @@ const (
|
||||
DefaultSourceID = internalpluginstore.DefaultSourceID
|
||||
DefaultSourceName = internalpluginstore.DefaultSourceName
|
||||
SchemaVersion = internalpluginstore.SchemaVersion
|
||||
SchemaVersionV2 = internalpluginstore.SchemaVersionV2
|
||||
|
||||
InstallTypeGitHubRelease = internalpluginstore.InstallTypeGitHubRelease
|
||||
InstallTypeDirect = internalpluginstore.InstallTypeDirect
|
||||
|
||||
RequestKindRegistry = internalpluginstore.RequestKindRegistry
|
||||
RequestKindMetadata = internalpluginstore.RequestKindMetadata
|
||||
RequestKindArtifact = internalpluginstore.RequestKindArtifact
|
||||
|
||||
AuthTypeNone = internalpluginstore.AuthTypeNone
|
||||
AuthTypeBearer = internalpluginstore.AuthTypeBearer
|
||||
AuthTypeBasic = internalpluginstore.AuthTypeBasic
|
||||
AuthTypeHeader = internalpluginstore.AuthTypeHeader
|
||||
AuthTypeGitHubToken = internalpluginstore.AuthTypeGitHubToken
|
||||
)
|
||||
|
||||
type Source = internalpluginstore.Source
|
||||
type Registry = internalpluginstore.Registry
|
||||
type Plugin = internalpluginstore.Plugin
|
||||
type Version = internalpluginstore.Version
|
||||
type Release = internalpluginstore.Release
|
||||
type ReleaseAsset = internalpluginstore.ReleaseAsset
|
||||
type InstallOptions = internalpluginstore.InstallOptions
|
||||
type InstallResult = internalpluginstore.InstallResult
|
||||
type InstallPlan = internalpluginstore.InstallPlan
|
||||
type Artifact = internalpluginstore.Artifact
|
||||
type Platform = internalpluginstore.Platform
|
||||
type Manifest = internalpluginstore.Manifest
|
||||
type AuthConfig = internalpluginstore.AuthConfig
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
@@ -36,23 +55,6 @@ type Client struct {
|
||||
inner internalpluginstore.Client
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
ID string `yaml:"id,omitempty" json:"id,omitempty"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Author string `yaml:"author,omitempty" json:"author,omitempty"`
|
||||
Version string `yaml:"version,omitempty" json:"version,omitempty"`
|
||||
ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"`
|
||||
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
|
||||
Logo string `yaml:"logo,omitempty" json:"logo,omitempty"`
|
||||
Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
|
||||
License string `yaml:"license,omitempty" json:"license,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||
SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"`
|
||||
SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"`
|
||||
SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"`
|
||||
}
|
||||
|
||||
func NewClient(httpClient HTTPDoer, registryURL string) Client {
|
||||
return Client{inner: internalpluginstore.Client{
|
||||
HTTPClient: httpClient,
|
||||
@@ -60,6 +62,14 @@ func NewClient(httpClient HTTPDoer, registryURL string) Client {
|
||||
}}
|
||||
}
|
||||
|
||||
func NewClientWithAuth(httpClient HTTPDoer, registryURL string, auth []AuthConfig) Client {
|
||||
return Client{inner: internalpluginstore.Client{
|
||||
HTTPClient: httpClient,
|
||||
RegistryURL: strings.TrimSpace(registryURL),
|
||||
Auth: internalpluginstore.NormalizeAuthConfigs(auth),
|
||||
}}
|
||||
}
|
||||
|
||||
func DefaultSource() Source {
|
||||
return internalpluginstore.DefaultSource()
|
||||
}
|
||||
@@ -76,6 +86,26 @@ func ValidatePlugin(plugin Plugin) error {
|
||||
return internalpluginstore.ValidatePlugin(plugin)
|
||||
}
|
||||
|
||||
func PluginInstallType(plugin Plugin) string {
|
||||
return internalpluginstore.PluginInstallType(plugin)
|
||||
}
|
||||
|
||||
func PluginPlatforms(plugin Plugin) []Platform {
|
||||
return internalpluginstore.PluginPlatforms(plugin)
|
||||
}
|
||||
|
||||
func PluginArtifacts(plugin Plugin) []Artifact {
|
||||
return internalpluginstore.PluginArtifacts(plugin)
|
||||
}
|
||||
|
||||
func NormalizeAuthConfigs(auth []AuthConfig) []AuthConfig {
|
||||
return internalpluginstore.NormalizeAuthConfigs(auth)
|
||||
}
|
||||
|
||||
func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool {
|
||||
return internalpluginstore.AuthConfigured(auth, requestURL, kind)
|
||||
}
|
||||
|
||||
func UpdateAvailable(installed, latest string) bool {
|
||||
return internalpluginstore.UpdateAvailable(installed, latest)
|
||||
}
|
||||
@@ -85,63 +115,11 @@ func ReleaseVersion(release Release) (string, error) {
|
||||
}
|
||||
|
||||
func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) {
|
||||
version, errVersion := internalpluginstore.ReleaseVersion(release)
|
||||
if errVersion != nil {
|
||||
return Manifest{}, errVersion
|
||||
}
|
||||
return Manifest{
|
||||
ID: strings.TrimSpace(plugin.ID),
|
||||
Name: strings.TrimSpace(plugin.Name),
|
||||
Description: strings.TrimSpace(plugin.Description),
|
||||
Author: strings.TrimSpace(plugin.Author),
|
||||
Version: version,
|
||||
ReleaseTag: strings.TrimSpace(release.TagName),
|
||||
Repository: strings.TrimSpace(plugin.Repository),
|
||||
Logo: strings.TrimSpace(plugin.Logo),
|
||||
Homepage: strings.TrimSpace(plugin.Homepage),
|
||||
License: strings.TrimSpace(plugin.License),
|
||||
Tags: append([]string(nil), plugin.Tags...),
|
||||
SourceID: strings.TrimSpace(source.ID),
|
||||
SourceName: strings.TrimSpace(source.Name),
|
||||
SourceURL: strings.TrimSpace(source.URL),
|
||||
}, nil
|
||||
return internalpluginstore.ManifestFromRelease(source, plugin, release)
|
||||
}
|
||||
|
||||
func (m Manifest) Plugin() Plugin {
|
||||
return Plugin{
|
||||
ID: strings.TrimSpace(m.ID),
|
||||
Name: strings.TrimSpace(m.Name),
|
||||
Description: strings.TrimSpace(m.Description),
|
||||
Author: strings.TrimSpace(m.Author),
|
||||
Version: strings.TrimSpace(m.Version),
|
||||
Repository: strings.TrimSpace(m.Repository),
|
||||
Logo: strings.TrimSpace(m.Logo),
|
||||
Homepage: strings.TrimSpace(m.Homepage),
|
||||
License: strings.TrimSpace(m.License),
|
||||
Tags: append([]string(nil), m.Tags...),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manifest) Validate() error {
|
||||
version := strings.TrimSpace(m.Version)
|
||||
if version == "" {
|
||||
return fmt.Errorf("missing required field version")
|
||||
}
|
||||
releaseTag := strings.TrimSpace(m.ReleaseTag)
|
||||
if releaseTag == "" {
|
||||
return fmt.Errorf("missing required field release-tag")
|
||||
}
|
||||
if errValidate := internalpluginstore.ValidatePlugin(m.Plugin()); errValidate != nil {
|
||||
return errValidate
|
||||
}
|
||||
releaseVersion, errVersion := internalpluginstore.ReleaseVersion(internalpluginstore.Release{TagName: releaseTag})
|
||||
if errVersion != nil {
|
||||
return errVersion
|
||||
}
|
||||
if releaseVersion != version {
|
||||
return fmt.Errorf("release-tag %q resolves version %q, want %q", releaseTag, releaseVersion, version)
|
||||
}
|
||||
return nil
|
||||
func ManifestFromPlugin(source Source, plugin Plugin) (Manifest, error) {
|
||||
return internalpluginstore.ManifestFromPlugin(source, plugin)
|
||||
}
|
||||
|
||||
func (c Client) FetchRegistry(ctx context.Context) (Registry, error) {
|
||||
@@ -165,8 +143,5 @@ func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag st
|
||||
}
|
||||
|
||||
func (c Client) InstallManifest(ctx context.Context, manifest Manifest, options InstallOptions) (InstallResult, error) {
|
||||
if errValidate := manifest.Validate(); errValidate != nil {
|
||||
return InstallResult{}, errValidate
|
||||
}
|
||||
return c.InstallVersion(ctx, manifest.Plugin(), manifest.ReleaseTag, manifest.Version, options)
|
||||
return c.inner.InstallManifest(ctx, manifest, options)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,78 @@ func TestManifestFromReleaseBuildsPinnedManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestFromPluginBuildsDirectManifest(t *testing.T) {
|
||||
manifest, errManifest := ManifestFromPlugin(
|
||||
DefaultSource(),
|
||||
Plugin{
|
||||
ID: "sample-provider",
|
||||
Name: "Sample Provider",
|
||||
Description: "Adds sample provider support.",
|
||||
Author: "author-name",
|
||||
Version: "0.4.0",
|
||||
Install: InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
URL: "https://downloads.example/sample-provider.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}},
|
||||
},
|
||||
},
|
||||
)
|
||||
if errManifest != nil {
|
||||
t.Fatalf("ManifestFromPlugin() error = %v", errManifest)
|
||||
}
|
||||
if errValidate := manifest.Validate(); errValidate != nil {
|
||||
t.Fatalf("Validate() error = %v", errValidate)
|
||||
}
|
||||
if manifest.SchemaVersion != SchemaVersionV2 || manifest.InstallType() != InstallTypeDirect || manifest.ReleaseTag != "" {
|
||||
t.Fatalf("manifest = %#v, want v2 direct without release tag", manifest)
|
||||
}
|
||||
if manifest.SourceURL != DefaultRegistryURL || len(manifest.Install.Artifacts) != 0 {
|
||||
t.Fatalf("manifest source/artifacts = %q/%d, want source URL without artifacts", manifest.SourceURL, len(manifest.Install.Artifacts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginArtifactsIncludesVersionArtifacts(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
ID: "sample-provider",
|
||||
Name: "Sample Provider",
|
||||
Description: "Adds sample provider support.",
|
||||
Author: "author-name",
|
||||
Version: "0.4.0",
|
||||
Install: InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "windows",
|
||||
GOARCH: "x64",
|
||||
URL: "https://downloads.example/sample-provider.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}},
|
||||
},
|
||||
Versions: []Version{{
|
||||
Version: "0.3.0",
|
||||
Install: InstallPlan{
|
||||
Type: InstallTypeDirect,
|
||||
Artifacts: []Artifact{{
|
||||
GOOS: "linux",
|
||||
GOARCH: "aarch64",
|
||||
URL: "https://downloads.example/sample-provider-0.3.0.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
artifacts := PluginArtifacts(plugin)
|
||||
if len(artifacts) != 2 ||
|
||||
artifacts[0].GOARCH != "amd64" ||
|
||||
artifacts[1].GOARCH != "arm64" {
|
||||
t.Fatalf("PluginArtifacts() = %#v, want normalized top-level and version artifacts", artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
func validTestManifest() Manifest {
|
||||
return Manifest{
|
||||
ID: "sample-provider",
|
||||
|
||||
Reference in New Issue
Block a user