Files
multica/server/internal/handler/plugin.go
T
5ed57170bc [MUL-6099] Ship official Plugin V1 product vertical slice (#6869)
* Add official software delivery plugin vertical slice (MUL-6099)

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): encode empty catalog diagnostics as array

Co-authored-by: multica-agent <[email protected]>

* feat(plugin): gate management behind feature flag

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): honor semver precedence and fail closed

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 15:08:11 +08:00

479 lines
17 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/featureflags"
"github.com/multica-ai/multica/server/internal/pluginbundled"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func (h *Handler) pluginsV1Enabled(ctx context.Context) bool {
return featureflags.PluginsV1Enabled(ctx, h.FeatureFlags)
}
func (h *Handler) requirePluginsV1(w http.ResponseWriter, r *http.Request) bool {
if h.pluginsV1Enabled(r.Context()) {
return true
}
writeError(w, http.StatusServiceUnavailable, "Plugin management is not enabled")
return false
}
type pluginBindingResponse struct {
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
}
type pluginInstallationResponse struct {
ID string `json:"id"`
PluginKey string `json:"plugin_key"`
DisplayName string `json:"display_name"`
DesiredVersion string `json:"desired_version"`
ActiveVersion string `json:"active_version,omitempty"`
Enabled bool `json:"enabled"`
DesiredGeneration int64 `json:"desired_generation"`
ActiveGeneration int64 `json:"active_generation"`
LifecycleStatus string `json:"lifecycle_status"`
HealthState string `json:"health_state,omitempty"`
HealthReason string `json:"health_reason,omitempty"`
Contributions []string `json:"contributions"`
Bindings []pluginBindingResponse `json:"bindings"`
}
func (h *Handler) pluginInstallationResponse(r *http.Request, installation db.PluginInstallation, health *db.PluginHealth) (pluginInstallationResponse, error) {
identity, err := h.Queries.GetPluginIdentity(r.Context(), installation.PluginID)
if err != nil {
return pluginInstallationResponse{}, err
}
desired, err := h.Queries.GetPluginRelease(r.Context(), installation.DesiredReleaseID)
if err != nil {
return pluginInstallationResponse{}, err
}
response := pluginInstallationResponse{
ID: uuidToString(installation.ID),
PluginKey: identity.PluginKey,
DisplayName: identity.DisplayName,
DesiredVersion: desired.Version,
Enabled: installation.Enabled,
DesiredGeneration: installation.DesiredGeneration,
ActiveGeneration: installation.ActiveGeneration,
LifecycleStatus: installation.LifecycleStatus,
Contributions: []string{},
Bindings: []pluginBindingResponse{},
}
contributions, err := h.Queries.ListPluginContributionsByRelease(r.Context(), desired.ID)
if err != nil {
return pluginInstallationResponse{}, err
}
for _, contribution := range contributions {
response.Contributions = append(response.Contributions, contribution.ContributionKey)
}
bindings, err := h.Queries.ListLatestPluginBindings(r.Context(), installation.ID)
if err != nil {
return pluginInstallationResponse{}, err
}
for _, binding := range bindings {
response.Bindings = append(response.Bindings, pluginBindingResponse{
ScopeType: binding.ScopeType,
ScopeID: uuidToString(binding.ScopeID),
Enabled: binding.Enabled,
Revision: binding.BindingRevision,
})
}
if installation.ActiveReleaseID.Valid {
active, err := h.Queries.GetPluginRelease(r.Context(), installation.ActiveReleaseID)
if err != nil {
return pluginInstallationResponse{}, err
}
response.ActiveVersion = active.Version
}
if health != nil {
response.HealthState = health.State
response.HealthReason = health.ReasonCode
}
return response, nil
}
func (h *Handler) ListPlugins(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
installations, err := h.Queries.ListWorkspacePluginInstallations(r.Context(), workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list Plugins")
return
}
healthRows, _ := h.Queries.ListWorkspacePluginHealth(r.Context(), workspaceID)
healthByInstallation := make(map[string]db.PluginHealth)
for _, health := range healthRows {
key := uuidToString(health.InstallationID)
if _, exists := healthByInstallation[key]; !exists {
healthByInstallation[key] = health
}
}
responses := make([]pluginInstallationResponse, 0, len(installations))
for _, installation := range installations {
health, hasHealth := healthByInstallation[uuidToString(installation.ID)]
var healthPtr *db.PluginHealth
if hasHealth {
healthPtr = &health
}
response, err := h.pluginInstallationResponse(r, installation, healthPtr)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
responses = append(responses, response)
}
writeJSON(w, http.StatusOK, map[string]any{"plugins": responses})
}
type pluginCatalogContributionResponse struct {
Key string `json:"key"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
EntryPath string `json:"entry_path"`
EntryDigest string `json:"entry_digest"`
}
type pluginCatalogReleaseResponse struct {
PluginKey string `json:"plugin_key"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Publisher string `json:"publisher"`
PublisherType string `json:"publisher_type"`
TrustTier string `json:"trust_tier"`
SourceKind string `json:"source_kind"`
SourceRef string `json:"source_ref"`
RequestedCapabilities []string `json:"requested_capabilities"`
HostAPI string `json:"host_api"`
RequiredDaemonFeatures []string `json:"required_daemon_features"`
SignatureKeyID string `json:"signature_key_id"`
SignatureVerified bool `json:"signature_verified"`
ManifestDigest string `json:"manifest_digest"`
ArchiveDigest string `json:"archive_digest"`
ArtifactDigest string `json:"artifact_digest"`
Compatible bool `json:"compatible"`
CompatibilityReason string `json:"compatibility_reason,omitempty"`
Contributions []pluginCatalogContributionResponse `json:"contributions"`
Installation *pluginInstallationResponse `json:"installation,omitempty"`
}
func (h *Handler) catalogReleaseResponse(r *http.Request, workspaceID pgtype.UUID, entry pluginbundled.CatalogEntry) (pluginCatalogReleaseResponse, error) {
manifest := entry.Release.Manifest
response := pluginCatalogReleaseResponse{
PluginKey: manifest.Metadata.Key,
Name: manifest.Metadata.Name,
Description: manifest.Metadata.Description,
Version: manifest.Metadata.Version,
Publisher: manifest.Metadata.Publisher,
PublisherType: entry.PublisherType,
TrustTier: entry.TrustTier,
SourceKind: entry.Release.SourceKind,
SourceRef: entry.Release.SourceRef,
RequestedCapabilities: append([]string(nil), manifest.RequestedCapabilities...),
HostAPI: manifest.Compatibility.HostAPI,
RequiredDaemonFeatures: append([]string(nil), manifest.Compatibility.RequiredDaemonFeatures...),
SignatureKeyID: entry.Release.SignatureKeyID,
SignatureVerified: true,
ManifestDigest: entry.Release.ManifestDigest,
ArchiveDigest: entry.Release.ArchiveDigest,
ArtifactDigest: entry.Release.ArtifactDigest,
Compatible: entry.Compatible,
CompatibilityReason: entry.CompatibilityWhy,
Contributions: make([]pluginCatalogContributionResponse, 0, len(entry.Release.Contributions)),
}
for _, contribution := range entry.Release.Contributions {
response.Contributions = append(response.Contributions, pluginCatalogContributionResponse{
Key: contribution.Key,
Type: contribution.Type,
Name: contribution.DisplayName,
Description: contribution.Description,
EntryPath: contribution.EntryPath,
EntryDigest: contribution.EntryDigest,
})
}
identity, err := h.Queries.GetPluginIdentityByKey(r.Context(), manifest.Metadata.Key)
if errors.Is(err, pgx.ErrNoRows) {
return response, nil
}
if err != nil {
return pluginCatalogReleaseResponse{}, err
}
installation, err := h.Queries.GetWorkspacePluginInstallation(r.Context(), db.GetWorkspacePluginInstallationParams{
WorkspaceID: workspaceID,
PluginID: identity.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
return response, nil
}
if err != nil {
return pluginCatalogReleaseResponse{}, err
}
installed, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
return pluginCatalogReleaseResponse{}, err
}
response.Installation = &installed
return response, nil
}
func (h *Handler) ListPluginCatalog(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
entries := h.PluginService.CatalogEntries()
responses := make([]pluginCatalogReleaseResponse, 0, len(entries))
for _, entry := range entries {
response, err := h.catalogReleaseResponse(r, workspaceID, entry)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin catalog")
return
}
responses = append(responses, response)
}
diagnostics := h.PluginService.CatalogDiagnostics()
if diagnostics == nil {
diagnostics = []pluginbundled.Diagnostic{}
}
writeJSON(w, http.StatusOK, map[string]any{
"releases": responses,
"diagnostics": diagnostics,
})
}
func (h *Handler) GetPluginCatalogRelease(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
entry, found := h.PluginService.FindCatalogRelease(chi.URLParam(r, "pluginKey"), r.URL.Query().Get("version"))
if !found {
writeError(w, http.StatusNotFound, "Plugin release not found")
return
}
response, err := h.catalogReleaseResponse(r, workspaceID, entry)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin catalog")
return
}
writeJSON(w, http.StatusOK, response)
}
type pluginReleaseRequest struct {
PluginKey string `json:"plugin_key"`
Version string `json:"version"`
}
func (h *Handler) InstallPlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
var request pluginReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.PluginKey == "" || request.Version == "" {
writeError(w, http.StatusBadRequest, "plugin_key and version are required")
return
}
installation, err := h.PluginService.InstallCatalogRelease(r.Context(), workspaceID, actorID, request.PluginKey, request.Version)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusCreated, response)
}
func (h *Handler) UpgradePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
var request pluginReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.PluginKey == "" || request.Version == "" {
writeError(w, http.StatusBadRequest, "plugin_key and version are required")
return
}
installation, err := h.PluginService.UpgradeCatalogRelease(r.Context(), workspaceID, installationID, actorID, request.PluginKey, request.Version)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
type pluginBindingRequest struct {
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
}
func (h *Handler) EnablePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
h.setPluginEnabled(w, r, true)
}
func (h *Handler) DisablePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
h.setPluginEnabled(w, r, false)
}
func (h *Handler) setPluginEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
request := pluginBindingRequest{ScopeType: "workspace", ScopeID: uuidToString(workspaceID)}
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
if request.ScopeType == "workspace" && request.ScopeID == "" {
request.ScopeID = uuidToString(workspaceID)
}
scopeID, ok := parseUUIDOrBadRequest(w, request.ScopeID, "scope_id")
if !ok {
return
}
var installation db.PluginInstallation
var err error
if enabled {
installation, err = h.PluginService.EnablePlugin(r.Context(), workspaceID, installationID, actorID, request.ScopeType, scopeID)
} else {
installation, err = h.PluginService.DisablePlugin(r.Context(), workspaceID, installationID, actorID, request.ScopeType, scopeID)
}
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) RollbackPlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
var request struct {
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Version == "" {
writeError(w, http.StatusBadRequest, "version is required")
return
}
installation, err := h.PluginService.RollbackPlugin(r.Context(), workspaceID, installationID, actorID, request.Version)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
func writePluginError(w http.ResponseWriter, r *http.Request, err error) {
var pluginErr *service.PluginError
if !errors.As(err, &pluginErr) {
slog.Error("Plugin request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeError(w, http.StatusInternalServerError, "Plugin operation failed")
return
}
status := http.StatusInternalServerError
switch pluginErr.Kind {
case service.PluginErrorInvalid:
status = http.StatusBadRequest
case service.PluginErrorNotFound:
status = http.StatusNotFound
case service.PluginErrorConflict:
status = http.StatusConflict
case service.PluginErrorIncompatible:
status = http.StatusUnprocessableEntity
default:
slog.Error("Plugin request returned unknown classified error", "error", err)
writeError(w, http.StatusInternalServerError, "Plugin operation failed")
return
}
writeError(w, status, pluginErr.Message)
}
func pluginRequestIDs(w http.ResponseWriter, r *http.Request) (pgtype.UUID, pgtype.UUID, bool) {
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return pgtype.UUID{}, pgtype.UUID{}, false
}
actorID, err := util.ParseUUID(requestUserID(r))
if err != nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return pgtype.UUID{}, pgtype.UUID{}, false
}
return workspaceID, actorID, true
}