feat: market

This commit is contained in:
Yoan.liu
2026-08-01 19:27:22 +08:00
parent 926b29dea6
commit 7285f7a845
2 changed files with 255 additions and 68 deletions

View File

@@ -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 {

View 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)
}
}