mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-03 06:23:54 +08:00
feat: market
This commit is contained in:
@@ -28,6 +28,7 @@ type MarketEntry struct {
|
||||
|
||||
type MarketService struct {
|
||||
marketRepo string
|
||||
indexURL string
|
||||
pluginDir string
|
||||
cache []MarketEntry
|
||||
cachedAt time.Time
|
||||
@@ -39,6 +40,7 @@ type MarketService struct {
|
||||
|
||||
type MarketConfig struct {
|
||||
MarketRepo string
|
||||
IndexURL string
|
||||
PluginDir string
|
||||
CacheTTL time.Duration
|
||||
Logger *slog.Logger
|
||||
@@ -54,8 +56,12 @@ func NewMarketService(cfg MarketConfig) *MarketService {
|
||||
if cfg.MarketRepo == "" {
|
||||
cfg.MarketRepo = "certimate-go/plugins"
|
||||
}
|
||||
if cfg.IndexURL == "" {
|
||||
cfg.IndexURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/main/index.json", cfg.MarketRepo)
|
||||
}
|
||||
return &MarketService{
|
||||
marketRepo: cfg.MarketRepo,
|
||||
indexURL: cfg.IndexURL,
|
||||
pluginDir: cfg.PluginDir,
|
||||
cacheTTL: cfg.CacheTTL,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
@@ -100,87 +106,43 @@ func (s *MarketService) fetchAndCache(ctx context.Context) ([]MarketEntry, error
|
||||
}
|
||||
|
||||
func (s *MarketService) fetchMarketListing(ctx context.Context) ([]MarketEntry, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/contents/", s.marketRepo)
|
||||
url := s.indexURL
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
s.setAuth(req)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: fetch directory listing: %w", err)
|
||||
return nil, fmt.Errorf("market: fetch index: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, fmt.Errorf("market: GitHub API rate limit reached (status %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("market: index not available at %s", url)
|
||||
case http.StatusForbidden, http.StatusTooManyRequests:
|
||||
return nil, fmt.Errorf("market: rate limited (status %d)", resp.StatusCode)
|
||||
default:
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("market: GitHub API returned status %d: %s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("market: index fetch returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var dirEntries []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
var idx struct {
|
||||
Plugins []*plugin.MarketManifest `json:"plugins"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dirEntries); err != nil {
|
||||
return nil, fmt.Errorf("market: decode directory listing: %w", err)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
|
||||
return nil, fmt.Errorf("market: decode index: %w", err)
|
||||
}
|
||||
|
||||
var entries []MarketEntry
|
||||
for _, de := range dirEntries {
|
||||
if de.Type != "dir" {
|
||||
continue
|
||||
}
|
||||
entry, err := s.fetchPluginManifest(ctx, de.Name)
|
||||
if err != nil {
|
||||
s.logger.Warn("market: skipping plugin directory",
|
||||
slog.String("dir", de.Name),
|
||||
slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
entries = append(entries, *entry)
|
||||
entries := make([]MarketEntry, 0, len(idx.Plugins))
|
||||
for _, mm := range idx.Plugins {
|
||||
entries = append(entries, MarketEntry{MarketManifest: mm, Status: s.computeStatus(mm)})
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *MarketService) fetchPluginManifest(ctx context.Context, dirName string) (*MarketEntry, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s/manifest.json", s.marketRepo, dirName)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: create manifest request for %s: %w", dirName, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.raw+json")
|
||||
s.setAuth(req)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: fetch manifest for %s: %w", dirName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("market: manifest fetch for %s returned status %d", dirName, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: read manifest for %s: %w", dirName, err)
|
||||
}
|
||||
|
||||
mm, err := plugin.ParseMarketManifest(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: parse manifest for %s: %w", dirName, err)
|
||||
}
|
||||
|
||||
status := s.computeStatus(mm)
|
||||
return &MarketEntry{MarketManifest: mm, Status: status}, nil
|
||||
}
|
||||
|
||||
func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string {
|
||||
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
|
||||
if mm.Release == nil || mm.Release.Assets[key] == "" {
|
||||
@@ -204,13 +166,6 @@ func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string {
|
||||
return "installed"
|
||||
}
|
||||
|
||||
func (s *MarketService) setAuth(req *http.Request) {
|
||||
token := os.Getenv("CERTIMATE_GITHUB_TOKEN")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MarketService) GetMarketManifest(ctx context.Context, providerType string) (*plugin.MarketManifest, error) {
|
||||
entries, err := s.ListMarket(ctx)
|
||||
if err != nil {
|
||||
|
||||
232
internal/pluginhost/market_test.go
Normal file
232
internal/pluginhost/market_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package pluginhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/pkg/plugin"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T, handler http.HandlerFunc) *MarketService {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
return NewMarketService(MarketConfig{
|
||||
IndexURL: srv.URL,
|
||||
PluginDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
})
|
||||
}
|
||||
|
||||
func indexJSON(t *testing.T, plugins ...map[string]any) string {
|
||||
t.Helper()
|
||||
buf, err := json.Marshal(map[string]any{"plugins": plugins})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func entry(providerType, version string, release map[string]any) map[string]any {
|
||||
m := map[string]any{"provider_type": providerType, "version": version, "binary": providerType}
|
||||
if release != nil {
|
||||
m["release"] = release
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func releaseWithCurrentAsset() map[string]any {
|
||||
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
|
||||
return map[string]any{
|
||||
"repo": "certimate-go/plugins",
|
||||
"tag": "v1.0.0",
|
||||
"assets": map[string]any{key: "bin-" + key},
|
||||
"checksums": map[string]any{key: "deadbeef"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_HappyPath(t *testing.T) {
|
||||
body := indexJSON(t,
|
||||
entry("beta", "2.0.0", nil),
|
||||
entry("alpha", "1.0.0", releaseWithCurrentAsset()),
|
||||
)
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
|
||||
got, err := svc.ListMarket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 entries, got %d", len(got))
|
||||
}
|
||||
if got[0].ProviderType != "beta" || got[0].Version != "2.0.0" {
|
||||
t.Fatalf("entry 0 mismatch: %+v", got[0])
|
||||
}
|
||||
if got[1].ProviderType != "alpha" || got[1].Version != "1.0.0" {
|
||||
t.Fatalf("entry 1 mismatch: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_Empty(t *testing.T) {
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"plugins":[]}`)
|
||||
})
|
||||
|
||||
got, err := svc.ListMarket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("want 0 entries, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_UnsupportedPlatform(t *testing.T) {
|
||||
body := indexJSON(t,
|
||||
entry("noRelease", "1.0.0", nil),
|
||||
entry("wrongAsset", "1.0.0", map[string]any{
|
||||
"repo": "x",
|
||||
"tag": "v1",
|
||||
"assets": map[string]any{"none/none": "bin"},
|
||||
"checksums": map[string]any{"none/none": "x"},
|
||||
}),
|
||||
)
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
|
||||
got, err := svc.ListMarket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 entries, got %d", len(got))
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Status != "unsupported_platform" {
|
||||
t.Fatalf("want unsupported_platform for %q, got %q", e.ProviderType, e.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_StatusNotInstalled(t *testing.T) {
|
||||
body := indexJSON(t, entry("alpha", "1.0.0", releaseWithCurrentAsset()))
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
|
||||
got, err := svc.ListMarket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Status != "not_installed" {
|
||||
t.Fatalf("want one not_installed entry, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_CacheNoRefetch(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
body := indexJSON(t, entry("alpha", "1.0.0", nil))
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
|
||||
if _, err := svc.ListMarket(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ListMarket(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hits.Load(); got != 1 {
|
||||
t.Fatalf("want 1 server hit (cached second call), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_StaleCacheOnError(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
body := indexJSON(t, entry("alpha", "1.0.0", nil))
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail.Load() {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
|
||||
first, err := svc.ListMarket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.cachedAt = time.Now().Add(-2 * time.Hour)
|
||||
fail.Store(true)
|
||||
|
||||
second, err := svc.ListMarket(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("want stale cache on fetch failure, got error: %v", err)
|
||||
}
|
||||
if len(second) != len(first) {
|
||||
t.Fatalf("stale cache mismatch: first %d, second %d", len(first), len(second))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_404(t *testing.T) {
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
_, err := svc.ListMarket(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "not available") {
|
||||
t.Fatalf("want 'not available' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_RateLimitedNoCache(t *testing.T) {
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
})
|
||||
|
||||
_, err := svc.ListMarket(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "rate limited") {
|
||||
t.Fatalf("want rate-limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarket_Malformed(t *testing.T) {
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{not valid json`)
|
||||
})
|
||||
|
||||
_, err := svc.ListMarket(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("want parse error for malformed index, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMarketManifest_FromCache(t *testing.T) {
|
||||
body := indexJSON(t, entry("alpha", "1.0.0", releaseWithCurrentAsset()))
|
||||
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
|
||||
if _, err := svc.ListMarket(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mm, err := svc.GetMarketManifest(context.Background(), "alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mm.ProviderType != "alpha" || mm.Release == nil {
|
||||
t.Fatalf("unexpected manifest: %+v", mm)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user