From 99b11760ac9c464c6c7d82540f84f2341709364b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 28 Oct 2025 22:56:53 +0000 Subject: [PATCH] Implement Docker metadata API endpoints Add backend support for storing and managing Docker resource metadata: - Create DockerMetadataStore for managing Docker container/service metadata - Implement DockerMetadataHandler with GET/PUT/DELETE operations - Register /api/docker/metadata routes with proper authentication - Store metadata in docker_metadata.json file - Validate custom URLs (http/https scheme, valid host) - Supports resource IDs in format: {hostId}:container:{containerId} Enables the frontend Docker URL editing feature to persist data. --- internal/api/docker_metadata.go | 161 ++++++++++++++++++++++++++ internal/api/router.go | 27 ++++- internal/config/docker_metadata.go | 177 +++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 internal/api/docker_metadata.go create mode 100644 internal/config/docker_metadata.go diff --git a/internal/api/docker_metadata.go b/internal/api/docker_metadata.go new file mode 100644 index 0000000..8111306 --- /dev/null +++ b/internal/api/docker_metadata.go @@ -0,0 +1,161 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rs/zerolog/log" +) + +// DockerMetadataHandler handles Docker resource metadata operations +type DockerMetadataHandler struct { + store *config.DockerMetadataStore +} + +// NewDockerMetadataHandler creates a new Docker metadata handler +func NewDockerMetadataHandler(dataPath string) *DockerMetadataHandler { + return &DockerMetadataHandler{ + store: config.NewDockerMetadataStore(dataPath), + } +} + +// Reload reloads the Docker metadata from disk +func (h *DockerMetadataHandler) Reload() error { + return h.store.Load() +} + +// HandleGetMetadata retrieves metadata for a specific Docker resource or all resources +func (h *DockerMetadataHandler) HandleGetMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check if requesting specific resource + path := r.URL.Path + // Handle both /api/docker/metadata and /api/docker/metadata/ + if path == "/api/docker/metadata" || path == "/api/docker/metadata/" { + // Get all metadata + w.Header().Set("Content-Type", "application/json") + allMeta := h.store.GetAll() + if allMeta == nil { + // Return empty object instead of null + json.NewEncoder(w).Encode(make(map[string]*config.DockerMetadata)) + } else { + json.NewEncoder(w).Encode(allMeta) + } + return + } + + // Get specific resource ID from path + resourceID := strings.TrimPrefix(path, "/api/docker/metadata/") + + w.Header().Set("Content-Type", "application/json") + + if resourceID != "" { + // Get specific Docker resource metadata + meta := h.store.Get(resourceID) + if meta == nil { + // Return empty metadata instead of 404 + json.NewEncoder(w).Encode(&config.DockerMetadata{ID: resourceID}) + } else { + json.NewEncoder(w).Encode(meta) + } + } else { + // This shouldn't happen with current routing, but handle it anyway + http.Error(w, "Invalid request path", http.StatusBadRequest) + } +} + +// HandleUpdateMetadata updates metadata for a Docker resource +func (h *DockerMetadataHandler) HandleUpdateMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + resourceID := strings.TrimPrefix(r.URL.Path, "/api/docker/metadata/") + if resourceID == "" || resourceID == "metadata" { + http.Error(w, "Resource ID required", http.StatusBadRequest) + return + } + + var meta config.DockerMetadata + if err := json.NewDecoder(r.Body).Decode(&meta); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate URL if provided + if meta.CustomURL != "" { + // Parse and validate the URL + parsedURL, err := url.Parse(meta.CustomURL) + if err != nil { + http.Error(w, "Invalid URL format: "+err.Error(), http.StatusBadRequest) + return + } + + // Check scheme + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + http.Error(w, "URL must use http:// or https:// scheme", http.StatusBadRequest) + return + } + + // Check host is present and valid + if parsedURL.Host == "" { + http.Error(w, "Invalid URL: missing host/domain (e.g., use https://192.168.1.100:8006 or https://emby.local)", http.StatusBadRequest) + return + } + + // Check for incomplete URLs like "https://emby." + if strings.HasSuffix(parsedURL.Host, ".") && !strings.Contains(parsedURL.Host, "..") { + http.Error(w, "Incomplete URL: '"+meta.CustomURL+"' - please enter a complete domain or IP address", http.StatusBadRequest) + return + } + } + + if err := h.store.Set(resourceID, &meta); err != nil { + log.Error().Err(err).Str("resourceID", resourceID).Msg("Failed to save Docker metadata") + // Provide more specific error message + errMsg := "Failed to save metadata" + if strings.Contains(err.Error(), "permission") { + errMsg = "Permission denied - check file permissions" + } else if strings.Contains(err.Error(), "no space") { + errMsg = "Disk full - cannot save metadata" + } + http.Error(w, errMsg, http.StatusInternalServerError) + return + } + + log.Info().Str("resourceID", resourceID).Str("url", meta.CustomURL).Msg("Updated Docker metadata") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&meta) +} + +// HandleDeleteMetadata removes metadata for a Docker resource +func (h *DockerMetadataHandler) HandleDeleteMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + resourceID := strings.TrimPrefix(r.URL.Path, "/api/docker/metadata/") + if resourceID == "" || resourceID == "metadata" { + http.Error(w, "Resource ID required", http.StatusBadRequest) + return + } + + if err := h.store.Delete(resourceID); err != nil { + log.Error().Err(err).Str("resourceID", resourceID).Msg("Failed to delete Docker metadata") + http.Error(w, "Failed to delete metadata", http.StatusInternalServerError) + return + } + + log.Info().Str("resourceID", resourceID).Msg("Deleted Docker metadata") + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/router.go b/internal/api/router.go index 7372ab4..f72c15f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -121,6 +121,7 @@ func (r *Router) setupRoutes() { r.alertHandlers = NewAlertHandlers(r.monitor, r.wsHub) r.notificationHandlers = NewNotificationHandlers(r.monitor) guestMetadataHandler := NewGuestMetadataHandler(r.config.DataPath) + dockerMetadataHandler := NewDockerMetadataHandler(r.config.DataPath) r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings) updateHandlers := NewUpdateHandlers(r.updateManager, r.config.DataPath) r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub) @@ -134,7 +135,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup))) r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost))) - r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck))) + r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) r.mux.HandleFunc("/api/version", r.handleVersion) r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage))) @@ -178,6 +179,30 @@ func (r *Router) setupRoutes() { } })) + // Docker metadata routes + r.mux.HandleFunc("/api/docker/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, dockerMetadataHandler.HandleGetMetadata))) + r.mux.HandleFunc("/api/docker/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + if !ensureScope(w, req, config.ScopeMonitoringRead) { + return + } + dockerMetadataHandler.HandleGetMetadata(w, req) + case http.MethodPut, http.MethodPost: + if !ensureScope(w, req, config.ScopeMonitoringWrite) { + return + } + dockerMetadataHandler.HandleUpdateMetadata(w, req) + case http.MethodDelete: + if !ensureScope(w, req, config.ScopeMonitoringWrite) { + return + } + dockerMetadataHandler.HandleDeleteMetadata(w, req) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + })) + // Update routes r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates))) r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate))) diff --git a/internal/config/docker_metadata.go b/internal/config/docker_metadata.go new file mode 100644 index 0000000..81c5876 --- /dev/null +++ b/internal/config/docker_metadata.go @@ -0,0 +1,177 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/rs/zerolog/log" +) + +// DockerMetadata holds additional metadata for a Docker resource (container/service) +type DockerMetadata struct { + ID string `json:"id"` // Resource ID (e.g., "hostid:container:containerid" or "hostid:service:serviceid") + CustomURL string `json:"customUrl"` // Custom URL for the resource + Description string `json:"description"` // Optional description + Tags []string `json:"tags"` // Optional tags for categorization +} + +// DockerMetadataStore manages Docker resource metadata +type DockerMetadataStore struct { + mu sync.RWMutex + metadata map[string]*DockerMetadata // keyed by resource ID + dataPath string +} + +// NewDockerMetadataStore creates a new metadata store +func NewDockerMetadataStore(dataPath string) *DockerMetadataStore { + store := &DockerMetadataStore{ + metadata: make(map[string]*DockerMetadata), + dataPath: dataPath, + } + + // Load existing metadata + if err := store.Load(); err != nil { + log.Warn().Err(err).Msg("Failed to load Docker metadata") + } + + return store +} + +// Get retrieves metadata for a Docker resource +func (s *DockerMetadataStore) Get(resourceID string) *DockerMetadata { + s.mu.RLock() + defer s.mu.RUnlock() + + if meta, exists := s.metadata[resourceID]; exists { + return meta + } + return nil +} + +// GetAll retrieves all Docker resource metadata +func (s *DockerMetadataStore) GetAll() map[string]*DockerMetadata { + s.mu.RLock() + defer s.mu.RUnlock() + + // Return a copy to prevent external modifications + result := make(map[string]*DockerMetadata) + for k, v := range s.metadata { + result[k] = v + } + return result +} + +// Set updates or creates metadata for a Docker resource +func (s *DockerMetadataStore) Set(resourceID string, meta *DockerMetadata) error { + s.mu.Lock() + defer s.mu.Unlock() + + if meta == nil { + return fmt.Errorf("metadata cannot be nil") + } + + meta.ID = resourceID + s.metadata[resourceID] = meta + + // Save to disk + return s.save() +} + +// Delete removes metadata for a Docker resource +func (s *DockerMetadataStore) Delete(resourceID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.metadata, resourceID) + + // Save to disk + return s.save() +} + +// ReplaceAll replaces all metadata entries and persists them to disk. +func (s *DockerMetadataStore) ReplaceAll(metadata map[string]*DockerMetadata) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.metadata = make(map[string]*DockerMetadata) + + if metadata != nil { + for resourceID, meta := range metadata { + if meta == nil { + continue + } + + clone := *meta + clone.ID = resourceID + // Ensure slice copy is not nil to allow JSON marshalling of empty tags + if clone.Tags == nil { + clone.Tags = []string{} + } + s.metadata[resourceID] = &clone + } + } + + return s.save() +} + +// Load reads metadata from disk +func (s *DockerMetadataStore) Load() error { + filePath := filepath.Join(s.dataPath, "docker_metadata.json") + + log.Debug().Str("path", filePath).Msg("Loading Docker metadata from disk") + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist yet, not an error + log.Debug().Str("path", filePath).Msg("Docker metadata file does not exist yet") + return nil + } + return fmt.Errorf("failed to read metadata file: %w", err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := json.Unmarshal(data, &s.metadata); err != nil { + return fmt.Errorf("failed to unmarshal metadata: %w", err) + } + + log.Info().Int("count", len(s.metadata)).Msg("Loaded Docker metadata") + return nil +} + +// save writes metadata to disk (must be called with lock held) +func (s *DockerMetadataStore) save() error { + filePath := filepath.Join(s.dataPath, "docker_metadata.json") + + log.Debug().Str("path", filePath).Msg("Saving Docker metadata to disk") + + data, err := json.Marshal(s.metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + // Ensure directory exists + if err := os.MkdirAll(s.dataPath, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + // Write to temp file first for atomic operation + tempFile := filePath + ".tmp" + if err := os.WriteFile(tempFile, data, 0644); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + // Rename temp file to actual file (atomic on most systems) + if err := os.Rename(tempFile, filePath); err != nil { + return fmt.Errorf("failed to rename metadata file: %w", err) + } + + log.Debug().Str("path", filePath).Int("entries", len(s.metadata)).Msg("Docker metadata saved successfully") + + return nil +}