// Package plugins provides HTTP handlers for plugin management package plugins import ( "encoding/json" "net/http" "slices" "time" "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/tracking" ) // Handler provides plugin-related HTTP handlers type Handler struct { logger *logging.Logger registry *tracking.Registry config map[string]PluginConfig // Plugin configurations } // PluginConfig represents the configuration for a plugin type PluginConfig struct { Enabled bool `json:"enabled"` Mode string `json:"mode"` // sidecar, remote, disabled Image string `json:"image,omitempty"` Settings map[string]any `json:"settings,omitempty"` LogBasePath string `json:"log_base_path,omitempty"` ArtifactPath string `json:"artifact_path,omitempty"` } // PluginInfo represents plugin information returned by the API type PluginInfo struct { Name string `json:"name"` Enabled bool `json:"enabled"` Mode string `json:"mode"` Status string `json:"status"` // healthy, unhealthy, starting, stopped Config PluginConfig `json:"config"` RequiresRestart bool `json:"requires_restart"` Version string `json:"version,omitempty"` } // PluginStatus represents the status of a plugin instance type PluginStatus struct { Name string `json:"name"` Status string `json:"status"` URL string `json:"url,omitempty"` LastCheck time.Time `json:"last_check,omitempty"` } // NewHandler creates a new plugins handler func NewHandler( logger *logging.Logger, registry *tracking.Registry, config map[string]PluginConfig, ) *Handler { return &Handler{ logger: logger, registry: registry, config: config, } } // GetV1Plugins handles GET /v1/plugins func (h *Handler) GetV1Plugins(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } plugins := []PluginInfo{} // If registry is available, get plugin status if h.registry != nil { for name := range h.config { cfg := h.config[name] info := PluginInfo{ Name: name, Enabled: cfg.Enabled, Mode: cfg.Mode, Status: "unknown", Config: cfg, RequiresRestart: false, // Default: plugins support hot-reload Version: h.getPluginVersion(name, cfg), } // Check if plugin is registered if plugin, ok := h.registry.Get(name); ok { // Check plugin health if available if cfg.Enabled && cfg.Mode != "disabled" { info.Status = "healthy" // Default, would check actual health _ = plugin // Use plugin for actual health check } } plugins = append(plugins, info) } } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(plugins); err != nil { h.logger.Warn("failed to encode plugins response", "error", err) } } // GetV1PluginsPluginName handles GET /v1/plugins/{pluginName} func (h *Handler) GetV1PluginsPluginName(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } info := PluginInfo{ Name: pluginName, Enabled: cfg.Enabled, Mode: cfg.Mode, Status: "unknown", Config: cfg, RequiresRestart: false, Version: "1.0.0", } if cfg.Enabled && cfg.Mode != "disabled" { info.Status = "healthy" } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(info); err != nil { h.logger.Warn("failed to encode plugin info", "error", err) } } // GetV1PluginsPluginNameConfig handles GET /v1/plugins/{pluginName}/config func (h *Handler) GetV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(cfg); err != nil { h.logger.Warn("failed to encode plugin config", "error", err) } } // PutV1PluginsPluginNameConfig handles PUT /v1/plugins/{pluginName}/config func (h *Handler) PutV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:write") { errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } var newConfig PluginConfig if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil { errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Invalid request body", err.Error()) return } // Update config h.config[pluginName] = newConfig h.logger.Info("updated plugin config", "plugin", pluginName, "user", user.Name) // Return updated plugin info with actual version version := h.getPluginVersion(pluginName, newConfig) info := PluginInfo{ Name: pluginName, Enabled: newConfig.Enabled, Mode: newConfig.Mode, Status: "healthy", Config: newConfig, RequiresRestart: false, Version: version, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(info); err != nil { h.logger.Warn("failed to encode plugin info", "error", err) } } // DeleteV1PluginsPluginNameConfig handles DELETE /v1/plugins/{pluginName}/config func (h *Handler) DeleteV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:write") { errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } // Disable the plugin cfg.Enabled = false h.config[pluginName] = cfg h.logger.Info("disabled plugin", "plugin", pluginName, "user", user.Name) w.WriteHeader(http.StatusNoContent) } // GetV1PluginsPluginNameHealth handles GET /v1/plugins/{pluginName}/health func (h *Handler) GetV1PluginsPluginNameHealth(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } status := "healthy" if !cfg.Enabled || cfg.Mode == "disabled" { status = "stopped" } response := map[string]any{ "status": status, "version": h.getPluginVersion(pluginName, cfg), "timestamp": time.Now().UTC(), } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { h.logger.Warn("failed to encode health response", "error", err) } } // checkPermission checks if the user has the required permission func (h *Handler) checkPermission(user *auth.User, permission string) bool { if user == nil { return false } // Admin has all permissions if slices.Contains(user.Roles, "admin") { return true } // Check specific permission for perm, hasPerm := range user.Permissions { if hasPerm && perm == permission { return true } } return false } // getPluginVersion retrieves the version for a plugin // TODO: Implement actual version query from plugin binary/container func (h *Handler) getPluginVersion(pluginName string, cfg PluginConfig) string { // In production, this should query the actual plugin binary/container // For now, return a default version _ = pluginName _ = cfg return "1.0.0" }