feat: add controlled plugin marketplace lifecycle
Business Plugins CI / check (plugin-admin) (push) Successful in 1m35s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m29s

This commit is contained in:
Qiufeng
2026-08-28 00:51:34 +08:00
parent dc1c799b98
commit 028b505c36
16 changed files with 1149 additions and 45 deletions
+272 -9
View File
@@ -229,6 +229,7 @@ type operation struct {
RequestHash string `json:"request_hash,omitempty"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Warning string `json:"warning,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -480,12 +481,14 @@ type app struct {
pending map[string]pendingLogin
processes map[string]*exec.Cmd
pluginLocks map[string]*sync.Mutex
marketplaceConfig marketplaceService
mu sync.Mutex
}
func newApp(core *coreClient, r *registry, root string) *app {
key := sha256.Sum256([]byte(token(32)))
return &app{core: core, registry: r, root: root, cookiePath: "/", cookieSameSite: http.SameSiteLaxMode, frameAncestors: []string{"'self'"}, configKey: key[:], sessions: map[string]session{}, sessionLocks: map[string]*sync.Mutex{}, pending: map[string]pendingLogin{}, processes: map[string]*exec.Cmd{}, pluginLocks: map[string]*sync.Mutex{}}
marketplace, _ := newMarketplaceService(filepath.Join(root, "marketplace", "index.json"), "", true)
return &app{core: core, registry: r, root: root, cookiePath: "/", cookieSameSite: http.SameSiteLaxMode, frameAncestors: []string{"'self'"}, configKey: key[:], sessions: map[string]session{}, sessionLocks: map[string]*sync.Mutex{}, pending: map[string]pendingLogin{}, processes: map[string]*exec.Cmd{}, pluginLocks: map[string]*sync.Mutex{}, marketplaceConfig: marketplace}
}
func (a *app) lockPlugin(id string) func() {
@@ -899,6 +902,160 @@ func (a *app) apiPlugins(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *app) marketplace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if _, _, ok := a.authenticate(w, r); !ok {
return
}
index, _, err := a.marketplaceConfig.loadIndex(r.Context())
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "marketplace is unavailable"})
return
}
a.registry.mu.Lock()
installed := make(map[string]pluginRecord, len(a.registry.data.Plugins))
for id, plugin := range a.registry.data.Plugins {
installed[id] = clonePluginRecord(plugin)
}
a.registry.mu.Unlock()
items := make([]map[string]any, 0, len(index.Entries))
coreVersion := a.currentCoreVersion(r.Context())
for _, entry := range index.Entries {
var plugin *pluginRecord
if value, ok := installed[entry.PluginID]; ok {
copy := value
plugin = &copy
}
item := publicMarketplaceEntry(entry, plugin)
compatibility := manifest.Manifest{CoreAPIBaseline: entry.CoreAPIBaseline, TestedCoreVersions: entry.TestedCoreVersions}.EvaluateCompatibility(coreVersion)
item["compatibility"] = compatibility
items = append(items, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"schema_version": index.SchemaVersion,
"source": index.Source,
"issued_at": index.IssuedAt,
"expires_at": index.ExpiresAt,
"items": items,
})
}
func publicMarketplaceEntry(entry marketplaceEntry, installed *pluginRecord) map[string]any {
item := map[string]any{
"plugin_id": entry.PluginID,
"name": entry.Name,
"version": entry.Version,
"description": entry.Description,
"publisher_key_id": entry.PublisherKeyID,
"core_api_baseline": entry.CoreAPIBaseline,
"tested_core_versions": entry.TestedCoreVersions,
"capabilities": entry.Capabilities,
"archive_sha256": entry.ArchiveSHA256,
"archive_size": entry.ArchiveSize,
"release_notes": entry.ReleaseNotes,
"published_at": entry.PublishedAt,
"installed": installed != nil,
"installed_state": "",
"installed_status": "",
"installed_version": "",
"active_revision": "",
}
if installed != nil {
item["installed_state"] = installed.State
item["installed_status"] = installationStatus(*installed)
item["installed_version"] = installed.Manifest.Version
item["active_revision"] = installed.ActiveRevision
}
return item
}
func (a *app) marketplaceInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
raw, err := captureRequestBody(r, 32<<10)
if err != nil {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body exceeds size limit"})
return
}
var input struct {
PluginID string `json:"plugin_id"`
Version string `json:"version"`
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id and version are required"})
return
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF || !marketplaceIDPattern.MatchString(strings.TrimSpace(input.PluginID)) || !marketplaceVersionPattern.MatchString(marketplaceEntryVersion(marketplaceEntry{Version: input.Version})) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id and version are invalid"})
return
}
input.PluginID = strings.TrimSpace(input.PluginID)
input.Version = marketplaceEntryVersion(marketplaceEntry{Version: input.Version})
r.Body = io.NopCloser(bytes.NewReader(raw))
op, s, ok := a.mutationAuthWithHash(w, r, "marketplace_install", input.PluginID, operationHashWithBody(r, raw))
if !ok {
return
}
var installed pluginRecord
if index, origin, loadErr := a.marketplaceConfig.loadIndex(r.Context()); loadErr != nil {
err = loadErr
} else {
var entry *marketplaceEntry
for i := range index.Entries {
candidate := &index.Entries[i]
if candidate.PluginID == input.PluginID && marketplaceEntryVersion(*candidate) == input.Version {
entry = candidate
break
}
}
if entry == nil {
err = errors.New("plugin version is not available in the marketplace")
} else {
var archive []byte
archive, err = a.marketplaceConfig.archiveBytes(r.Context(), *entry, origin)
if err == nil {
var info packageInfo
info, err = a.inspectPackage(archive)
if err == nil {
if info.Manifest.PluginID != entry.PluginID || strings.TrimPrefix(info.Manifest.Version, "v") != input.Version {
err = errors.New("marketplace package metadata does not match the catalog")
} else if info.Manifest.Name != entry.Name || !sameCapabilities(info.Manifest.Capabilities, entry.Capabilities) {
err = errors.New("marketplace package metadata does not match the catalog")
} else if !sameCoreVersions(info.Manifest.TestedCoreVersions, entry.TestedCoreVersions) {
err = errors.New("marketplace package Core test metadata does not match the catalog")
} else if info.Manifest.Publisher.KeyID != entry.PublisherKeyID {
err = errors.New("marketplace package publisher does not match the catalog")
} else if strings.TrimPrefix(strings.TrimPrefix(info.Manifest.CoreAPIBaseline, "sub2api-"), "v") != strings.TrimPrefix(strings.TrimPrefix(entry.CoreAPIBaseline, "sub2api-"), "v") {
err = errors.New("marketplace package Core baseline does not match the catalog")
} else if compat := info.Manifest.EvaluateCompatibility(a.currentCoreVersion(r.Context())); !compat.Compatible {
err = errors.New("marketplace package is incompatible with the current Core")
}
}
if err == nil {
installed, err = a.installPackage(info)
}
}
}
}
if err == nil {
op.Revision = installed.ActiveRevision
}
finished, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: "marketplace_install", PluginID: input.PluginID, ActorID: s.User["id"], RequestID: requestID(r)})
if persistErr != nil {
writeOperationPersistenceError(w, finished)
return
}
a.operationResponse(w, finished)
}
func (a *app) operationByID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@@ -929,7 +1086,27 @@ func (a *app) publicPlugin(p pluginRecord) map[string]any {
revisions = append(revisions, map[string]any{"id": rev.ID, "version": rev.Version, "archive_sha256": rev.ArchiveSHA, "verified_at": rev.VerifiedAt, "healthy_at": rev.HealthyAt})
}
compat := p.Manifest.EvaluateCompatibility(a.currentCoreVersion(context.Background()))
return map[string]any{"plugin_id": p.Manifest.PluginID, "name": p.Manifest.Name, "version": p.Manifest.Version, "capabilities": p.Manifest.SortedCapabilities(), "state": p.State, "active_revision": p.ActiveRevision, "pending_revision": p.PendingRevision, "revisions": revisions, "compatibility": compat, "endpoint": p.Endpoint, "last_error": p.LastError, "updated_at": p.UpdatedAt, "menu": p.Manifest.UI.Menu}
return map[string]any{"plugin_id": p.Manifest.PluginID, "name": p.Manifest.Name, "version": p.Manifest.Version, "capabilities": p.Manifest.SortedCapabilities(), "state": p.State, "installation_status": installationStatus(p), "active_revision": p.ActiveRevision, "pending_revision": p.PendingRevision, "revisions": revisions, "compatibility": compat, "endpoint": p.Endpoint, "last_error": p.LastError, "updated_at": p.UpdatedAt, "menu": p.Manifest.UI.Menu}
}
func installationStatus(p pluginRecord) string {
switch p.State {
case "healthy", "enabled":
return "installed"
case "disabled":
for _, revision := range p.Revisions {
if !revision.HealthyAt.IsZero() {
return "stopped"
}
}
return "staged"
case "incompatible":
return "staged"
case "starting", "draining", "upgrading", "rollback_pending":
return "transitioning"
default:
return "failed"
}
}
func (a *app) currentCoreVersion(ctx context.Context) string {
@@ -986,7 +1163,7 @@ func (a *app) getPlugin(w http.ResponseWriter, r *http.Request) {
}
func (a *app) operationResponse(w http.ResponseWriter, op operation) {
writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": op.ID, "state": op.State, "error": op.Error})
writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": op.ID, "state": op.State, "error": op.Error, "warning": op.Warning})
}
func (a *app) finalizeOperation(op operation, operationErr error, event auditEvent) (operation, error) {
@@ -1621,6 +1798,7 @@ func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn
}
old := clonePluginRecord(p)
var err error
var warning string
if !found {
err = errors.New("plugin not found")
} else {
@@ -1669,15 +1847,16 @@ func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn
}
_ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r))
} else if cleanupErr := removeStagedRevisionPaths(moves); cleanupErr != nil {
// The registry commit is already complete; keep the failed cleanup
// visible without restoring a record that may point at partially
// removed files. The private tombstones are safe to clean manually.
err = fmt.Errorf("plugin uninstalled but resource cleanup failed: %w", cleanupErr)
// The registry commit is already complete. Report a warning while
// keeping the deletion completed; a later startup pass removes the
// private tombstones without requiring a second uninstall operation.
warning = sanitizeError(fmt.Errorf("plugin files remain pending cleanup: %w", cleanupErr))
}
} else {
_ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r))
}
}
op.Warning = warning
op, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: kind, PluginID: id, ActorID: s.User["id"], RequestID: requestID(r)})
if persistErr != nil {
writeOperationPersistenceError(w, op)
@@ -1845,7 +2024,7 @@ func (a *app) rollback(w http.ResponseWriter, r *http.Request) {
}
func (a *app) uninstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
@@ -2244,6 +2423,11 @@ func (a *app) promoteProcess(from, to string) {
// healthy with no corresponding runtime.
func (a *app) recoverPlugins() error {
a.registry.mu.Lock()
// A prior delete may have committed the registry before the filesystem
// cleanup failed. Remove only tombstones whose original revision is no
// longer referenced; an interrupted delete still needs its tombstone to
// recover the registry record safely.
_ = a.cleanupUninstallTombstonesLocked()
ids := make([]string, 0, len(a.registry.data.Plugins))
for id := range a.registry.data.Plugins {
ids = append(ids, id)
@@ -2318,6 +2502,66 @@ func (a *app) recoverPlugins() error {
return nil
}
func (a *app) cleanupUninstallTombstonesLocked() error {
live := map[string]struct{}{}
for _, plugin := range a.registry.data.Plugins {
for _, revision := range plugin.Revisions {
if revision.Path == "" {
continue
}
if absolute, err := filepath.Abs(revision.Path); err == nil {
live[filepath.Clean(absolute)] = struct{}{}
}
}
}
installedRoot := filepath.Join(a.root, "installed")
pluginDirs, err := os.ReadDir(installedRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var firstErr error
for _, pluginDir := range pluginDirs {
if !pluginDir.IsDir() {
continue
}
entries, readErr := os.ReadDir(filepath.Join(installedRoot, pluginDir.Name()))
if readErr != nil {
if firstErr == nil {
firstErr = readErr
}
continue
}
for _, entry := range entries {
if !entry.IsDir() || !strings.Contains(entry.Name(), ".uninstall-") {
continue
}
originalName := strings.SplitN(entry.Name(), ".uninstall-", 2)[0]
originalPath, pathErr := filepath.Abs(filepath.Join(installedRoot, pluginDir.Name(), originalName))
if pathErr == nil {
if _, referenced := live[filepath.Clean(originalPath)]; referenced {
if _, statErr := os.Lstat(originalPath); errors.Is(statErr, os.ErrNotExist) {
if restoreErr := os.Rename(filepath.Join(installedRoot, pluginDir.Name(), entry.Name()), originalPath); restoreErr != nil && firstErr == nil {
firstErr = restoreErr
}
} else if statErr == nil {
if removeErr := os.RemoveAll(filepath.Join(installedRoot, pluginDir.Name(), entry.Name())); removeErr != nil && firstErr == nil {
firstErr = removeErr
}
}
continue
}
}
if removeErr := os.RemoveAll(filepath.Join(installedRoot, pluginDir.Name(), entry.Name())); removeErr != nil && firstErr == nil {
firstErr = removeErr
}
}
}
return firstErr
}
func (a *app) audit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@@ -2500,6 +2744,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/audit", a.audit)
mux.HandleFunc("/api/marketplace", a.marketplace)
mux.HandleFunc("/api/marketplace/install", a.marketplaceInstall)
mux.HandleFunc("/api/menu-items/preview", func(w http.ResponseWriter, r *http.Request) { a.menuGlobal(w, r, false) })
mux.HandleFunc("/api/menu-items/apply", func(w http.ResponseWriter, r *http.Request) { a.menuGlobal(w, r, true) })
mux.HandleFunc("/api/operations/", a.operationByID)
@@ -2512,7 +2758,11 @@ func (a *app) routes() http.Handler {
return
}
if action == "" {
a.getPlugin(w, r)
if r.Method == http.MethodDelete {
a.uninstall(w, r)
} else {
a.getPlugin(w, r)
}
return
}
switch action {
@@ -2528,6 +2778,8 @@ func (a *app) routes() http.Handler {
a.rollback(w, r)
case "uninstall":
a.uninstall(w, r)
case "delete":
a.uninstall(w, r)
case "config":
a.config(w, r)
case "menu-preview":
@@ -2682,6 +2934,17 @@ func main() {
}
a.frameAncestors = parseFrameAncestors(os.Getenv("PLUGIN_FRAME_ANCESTORS"))
a.trustedPublishers = loadTrustedPublishers(os.Getenv("PLUGIN_TRUSTED_PUBLISHERS"))
marketplaceSource := strings.TrimSpace(os.Getenv("PLUGIN_MARKETPLACE_INDEX"))
if marketplaceSource == "" {
marketplaceSource = filepath.Join(registryDir, "marketplace", "index.json")
}
allowLoopbackMarketplace := environment == "development" && isLoopbackHost(host)
marketplace, marketplaceErr := newMarketplaceService(marketplaceSource, os.Getenv("PLUGIN_MARKETPLACE_ALLOWED_HOSTS"), allowLoopbackMarketplace)
if marketplaceErr != nil {
slog.Error("invalid marketplace configuration", "error", marketplaceErr)
os.Exit(2)
}
a.marketplaceConfig = marketplace
if err := a.recoverPlugins(); err != nil {
slog.Error("recover plugins", "error", err)
os.Exit(2)