feat: add controlled plugin marketplace lifecycle
This commit is contained in:
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user