fetch_ml/internal/api/plugins/handlers.go
Jeremie Fraeys c18a8619fe
feat(api): add structured error package and refactor handlers
New error handling:
- Add internal/api/errors/errors.go with structured API error types
- Standardize error codes across all API endpoints
- Add user-facing error messages vs internal error details separation

Handler improvements:
- jupyter/handlers.go: better workspace lifecycle and error handling
- plugins/handlers.go: plugin management with validation
- groups/handlers.go: group CRUD with capability metadata
- jobs/handlers.go: job submission and monitoring improvements
- datasets/handlers.go: dataset upload/download with progress
- validate/handlers.go: manifest validation with detailed errors
- audit/handlers.go: audit log querying with filters

Server configuration:
- server_config.go: refined config loading with validation
- server_gen.go: improved code generation for OpenAPI specs
2026-03-12 12:04:46 -04:00

309 lines
9.3 KiB
Go

// 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: "1.0.0", // Placeholder
}
// 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)
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": "1.0.0",
"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) string {
// In production, this should query the actual plugin binary/container
// For now, return a default version based on plugin name hash
_ = pluginName // Unused until actual implementation
return "1.0.0"
}