mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
55f63d5e96
This implements full remote configuration for the AI command execution setting:
Backend:
- Add CommandsEnabled field to HostMetadata for persistent storage
- Add GetHostAgentConfig/UpdateHostAgentConfig methods to Monitor
- Add /api/agents/host/{id}/config endpoint (GET for agents, PATCH for UI)
- Server includes config in report response for immediate agent application
- Agent parses response and dynamically enables/disables command client
Frontend:
- Add 'AI Commands' toggle column in Managed Agents table
- Toggle immediately updates server config; agent applies on next heartbeat
- Add 'Enable AI command execution' checkbox in agent installer wizard
- Checkbox adds --enable-commands flag to generated install commands
This allows users to:
1. Enable at install time via checkbox in the wizard
2. Toggle remotely via the Managed Agents UI for existing agents
3. Agents apply changes automatically on their next report cycle
181 lines
4.5 KiB
Go
181 lines
4.5 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// HostMetadata holds additional metadata for a host
|
|
type HostMetadata struct {
|
|
ID string `json:"id"` // Host ID
|
|
CustomURL string `json:"customUrl"` // Custom URL for the host
|
|
Description string `json:"description"` // Optional description
|
|
Tags []string `json:"tags"` // Optional tags for categorization
|
|
Notes []string `json:"notes"` // User annotations for AI context
|
|
CommandsEnabled *bool `json:"commandsEnabled"` // Remote override for AI command execution (nil = use agent default)
|
|
}
|
|
|
|
// HostMetadataStore manages host metadata
|
|
type HostMetadataStore struct {
|
|
mu sync.RWMutex
|
|
metadata map[string]*HostMetadata // keyed by host ID
|
|
dataPath string
|
|
}
|
|
|
|
// NewHostMetadataStore creates a new host metadata store
|
|
func NewHostMetadataStore(dataPath string) *HostMetadataStore {
|
|
store := &HostMetadataStore{
|
|
metadata: make(map[string]*HostMetadata),
|
|
dataPath: dataPath,
|
|
}
|
|
|
|
// Load existing metadata
|
|
if err := store.Load(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to load host metadata")
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
// Get retrieves metadata for a host
|
|
func (s *HostMetadataStore) Get(hostID string) *HostMetadata {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if meta, exists := s.metadata[hostID]; exists {
|
|
return meta
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAll retrieves all host metadata
|
|
func (s *HostMetadataStore) GetAll() map[string]*HostMetadata {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Return a copy to prevent external modifications
|
|
result := make(map[string]*HostMetadata)
|
|
for k, v := range s.metadata {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Set updates or creates metadata for a host
|
|
func (s *HostMetadataStore) Set(hostID string, meta *HostMetadata) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if meta == nil {
|
|
return fmt.Errorf("metadata cannot be nil")
|
|
}
|
|
|
|
meta.ID = hostID
|
|
s.metadata[hostID] = meta
|
|
|
|
// Save to disk
|
|
return s.save()
|
|
}
|
|
|
|
// Delete removes metadata for a host
|
|
func (s *HostMetadataStore) Delete(hostID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.metadata, hostID)
|
|
|
|
// Save to disk
|
|
return s.save()
|
|
}
|
|
|
|
// ReplaceAll replaces all metadata entries and persists them to disk.
|
|
func (s *HostMetadataStore) ReplaceAll(metadata map[string]*HostMetadata) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.metadata = make(map[string]*HostMetadata)
|
|
|
|
for hostID, meta := range metadata {
|
|
if meta == nil {
|
|
continue
|
|
}
|
|
|
|
clone := *meta
|
|
clone.ID = hostID
|
|
// Ensure slice copy is not nil to allow JSON marshalling of empty tags
|
|
if clone.Tags == nil {
|
|
clone.Tags = []string{}
|
|
}
|
|
s.metadata[hostID] = &clone
|
|
}
|
|
|
|
return s.save()
|
|
}
|
|
|
|
// Load reads metadata from disk
|
|
func (s *HostMetadataStore) Load() error {
|
|
filePath := filepath.Join(s.dataPath, "host_metadata.json")
|
|
|
|
log.Debug().Str("path", filePath).Msg("Loading host 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("Host 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("hostCount", len(s.metadata)).
|
|
Msg("Loaded host metadata")
|
|
|
|
return nil
|
|
}
|
|
|
|
// save writes metadata to disk (must be called with lock held)
|
|
func (s *HostMetadataStore) save() error {
|
|
filePath := filepath.Join(s.dataPath, "host_metadata.json")
|
|
|
|
log.Debug().Str("path", filePath).Msg("Saving host 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("hosts", len(s.metadata)).Msg("Host metadata saved successfully")
|
|
|
|
return nil
|
|
}
|