mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
- Fix deadlock and race conditions in internal/alerts - Add comprehensive error path tests for internal/config - Fix 401 handling in internal/api - Fix Docker Swarm task filtering test logic
275 lines
7.8 KiB
Go
275 lines
7.8 KiB
Go
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
|
|
Notes []string `json:"notes"` // User annotations for AI context
|
|
}
|
|
|
|
// DockerHostMetadata holds additional metadata for a Docker host
|
|
type DockerHostMetadata struct {
|
|
CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom display name
|
|
CustomURL string `json:"customUrl,omitempty"` // Custom URL for administration (e.g., Portainer)
|
|
Notes []string `json:"notes,omitempty"` // User annotations for AI context
|
|
}
|
|
|
|
// dockerMetadataFile represents the on-disk format for Docker metadata
|
|
type dockerMetadataFile struct {
|
|
Containers map[string]*DockerMetadata `json:"containers,omitempty"` // Container/service metadata (legacy: may be top-level)
|
|
Hosts map[string]*DockerHostMetadata `json:"hosts,omitempty"` // Host-level metadata
|
|
}
|
|
|
|
// DockerMetadataStore manages Docker resource metadata
|
|
type DockerMetadataStore struct {
|
|
mu sync.RWMutex
|
|
metadata map[string]*DockerMetadata // keyed by resource ID (containers/services)
|
|
hostMetadata map[string]*DockerHostMetadata // keyed by host ID
|
|
dataPath string
|
|
fs FileSystem
|
|
}
|
|
|
|
// NewDockerMetadataStore creates a new metadata store
|
|
func NewDockerMetadataStore(dataPath string, fs FileSystem) *DockerMetadataStore {
|
|
store := &DockerMetadataStore{
|
|
metadata: make(map[string]*DockerMetadata),
|
|
hostMetadata: make(map[string]*DockerHostMetadata),
|
|
dataPath: dataPath,
|
|
fs: fs,
|
|
}
|
|
|
|
if store.fs == nil {
|
|
store.fs = defaultFileSystem{}
|
|
}
|
|
|
|
// Load existing metadata
|
|
if err := store.Load(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to load Docker metadata")
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
// ... Get/GetAll/GetHostMetadata/GetAllHostMetadata/SetHostMetadata/Set/Delete/ReplaceAll ... (unchanged)
|
|
|
|
// 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 := s.fs.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()
|
|
|
|
// Try to load as versioned format first
|
|
var fileData dockerMetadataFile
|
|
if err := json.Unmarshal(data, &fileData); err != nil {
|
|
return fmt.Errorf("failed to unmarshal metadata: %w", err)
|
|
}
|
|
|
|
// Check if this is the new format (has "hosts" or "containers" keys)
|
|
if fileData.Hosts != nil || fileData.Containers != nil {
|
|
// New versioned format
|
|
if fileData.Containers != nil {
|
|
s.metadata = fileData.Containers
|
|
} else {
|
|
s.metadata = make(map[string]*DockerMetadata)
|
|
}
|
|
if fileData.Hosts != nil {
|
|
s.hostMetadata = fileData.Hosts
|
|
} else {
|
|
s.hostMetadata = make(map[string]*DockerHostMetadata)
|
|
}
|
|
log.Info().
|
|
Int("containerCount", len(s.metadata)).
|
|
Int("hostCount", len(s.hostMetadata)).
|
|
Msg("Loaded Docker metadata (versioned format)")
|
|
} else {
|
|
// Legacy format: top-level map is container metadata
|
|
if err := json.Unmarshal(data, &s.metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal legacy metadata: %w", err)
|
|
}
|
|
s.hostMetadata = make(map[string]*DockerHostMetadata)
|
|
log.Info().
|
|
Int("containerCount", len(s.metadata)).
|
|
Msg("Loaded Docker metadata (legacy format)")
|
|
}
|
|
|
|
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")
|
|
|
|
// Use versioned format
|
|
fileData := dockerMetadataFile{
|
|
Containers: s.metadata,
|
|
Hosts: s.hostMetadata,
|
|
}
|
|
|
|
data, err := json.Marshal(fileData)
|
|
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 := s.fs.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 := s.fs.Rename(tempFile, filePath); err != nil {
|
|
return fmt.Errorf("failed to rename metadata file: %w", err)
|
|
}
|
|
|
|
log.Debug().Str("path", filePath).Int("containers", len(s.metadata)).Int("hosts", len(s.hostMetadata)).Msg("Docker metadata saved successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetHostMetadata retrieves metadata for a Docker host
|
|
func (s *DockerMetadataStore) GetHostMetadata(hostID string) *DockerHostMetadata {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if meta, exists := s.hostMetadata[hostID]; exists {
|
|
return meta
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAllHostMetadata retrieves all Docker host metadata
|
|
func (s *DockerMetadataStore) GetAllHostMetadata() map[string]*DockerHostMetadata {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Return a copy to prevent external modifications
|
|
result := make(map[string]*DockerHostMetadata)
|
|
for k, v := range s.hostMetadata {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetHostMetadata updates or creates metadata for a Docker host
|
|
func (s *DockerMetadataStore) SetHostMetadata(hostID string, meta *DockerHostMetadata) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// If metadata is nil or all fields are empty, delete the entry
|
|
if meta == nil || (meta.CustomDisplayName == "" && meta.CustomURL == "" && len(meta.Notes) == 0) {
|
|
delete(s.hostMetadata, hostID)
|
|
} else {
|
|
s.hostMetadata[hostID] = meta
|
|
}
|
|
|
|
// Save to disk
|
|
return s.save()
|
|
|
|
}
|
|
|
|
// 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)
|
|
|
|
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()
|
|
}
|