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.
This commit is contained in:
rcourtman 2025-10-28 22:56:53 +00:00
parent 0d2bd7a304
commit 99b11760ac
3 changed files with 364 additions and 1 deletions

View file

@ -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)
}

View file

@ -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)))

View file

@ -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
}