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:
parent
0d2bd7a304
commit
99b11760ac
3 changed files with 364 additions and 1 deletions
161
internal/api/docker_metadata.go
Normal file
161
internal/api/docker_metadata.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
177
internal/config/docker_metadata.go
Normal file
177
internal/config/docker_metadata.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue