From 22e01e2244169315c95ae4e1adcd7b04655bd38c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 8 Jan 2026 12:03:55 +0000 Subject: [PATCH] feat: Add centralized agent configuration management (Pro) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows administrators to create configuration profiles and assign them to agents for centralized fleet management. - Configuration profiles with customizable settings (Docker, K8s, Proxmox monitoring, log level, reporting interval) - Profile assignment to agents by ID - Agent-side remote config client to fetch settings on startup - Full CRUD API at /api/admin/profiles - Settings UI panel in Settings → Agents → Agent Profiles - Automatic cleanup of assignments when profiles are deleted --- docs/CENTRALIZED_MANAGEMENT.md | 112 ++++ frontend-modern/src/App.tsx | 23 +- frontend-modern/src/api/agentProfiles.ts | 154 +++++ .../Settings/AgentProfilesPanel.tsx | 583 ++++++++++++++++++ internal/api/config_profiles.go | 316 ++++++++++ internal/api/config_profiles_test.go | 236 +++++++ internal/models/profiles.go | 25 + internal/monitoring/monitor_profiles_test.go | 93 +++ internal/remoteconfig/client.go | 156 +++++ internal/remoteconfig/client_test.go | 80 +++ 10 files changed, 1771 insertions(+), 7 deletions(-) create mode 100644 docs/CENTRALIZED_MANAGEMENT.md create mode 100644 frontend-modern/src/api/agentProfiles.ts create mode 100644 frontend-modern/src/components/Settings/AgentProfilesPanel.tsx create mode 100644 internal/api/config_profiles.go create mode 100644 internal/api/config_profiles_test.go create mode 100644 internal/models/profiles.go create mode 100644 internal/monitoring/monitor_profiles_test.go create mode 100644 internal/remoteconfig/client.go create mode 100644 internal/remoteconfig/client_test.go diff --git a/docs/CENTRALIZED_MANAGEMENT.md b/docs/CENTRALIZED_MANAGEMENT.md new file mode 100644 index 000000000..92ec51ca0 --- /dev/null +++ b/docs/CENTRALIZED_MANAGEMENT.md @@ -0,0 +1,112 @@ +# Centralized Agent Management (Pulse Pro) + +Pulse Pro supports centralized management of agent configurations, allowing administrators to define "Configuration Profiles" and assign them to specific agents. This enables bulk updates and consistent configuration across your fleet without manually editing configuration files on each host. + +Profiles are managed in the UI: **Settings → Agents → Agent Profiles**. + +## Concepts + +- **Agent Profile**: A named collection of configuration settings (e.g., "Production Servers", "Debug Mode"). +- **Assignment**: A link between a specific Agent ID and an Agent Profile. +- **Precedence**: Server-side profile settings override local agent flags/environment for supported keys. + +## Supported Configuration Keys + +The following settings can be controlled remotely via profiles: + +| Key | Type | Description | +| :--- | :--- | :--- | +| `enable_docker` | boolean | Enable/Disable Docker monitoring | +| `enable_kubernetes` | boolean | Enable/Disable Kubernetes monitoring | +| `enable_proxmox` | boolean | Enable/Disable Proxmox monitoring | +| `proxmox_type` | string | Set Proxmox type (`pve` or `pbs`) | +| `log_level` | string | Set agent log level (`debug`, `info`, `warn`, `error`) | +| `interval` | string | Set reporting interval (e.g., "30s", "1m") | + +Notes: +- `interval` accepts a duration string. If you send a JSON number, it is interpreted as seconds. +- Docker auto-detection can still enable Docker monitoring if the agent is not explicitly configured. To force-disable Docker, set `PULSE_ENABLE_DOCKER=false` or install with `--disable-docker` on the host. +- `commandsEnabled` (AI command execution) is controlled separately per agent in **Settings → Agents → Unified Agents** and is applied live on report. It is not part of profile settings. + +## API Usage + +All endpoints require **Admin** authentication and a **Pulse Pro** license. + +### 1. Create a Profile + +```http +POST /api/admin/profiles/ +Authorization: Bearer +Content-Type: application/json + +{ + "name": "Production Servers", + "config": { + "enable_docker": true, + "log_level": "info", + "interval": "60s" + } +} +``` + +### 2. Assign Profile to Agent + +You need the Agent ID (typically the machine ID, visible in the Pulse UI or agent logs). + +```http +POST /api/admin/profiles/assignments +Authorization: Bearer +Content-Type: application/json + +{ + "agent_id": "01234567-89ab-cdef-0123-456789abcdef", + "profile_id": "prod-servers" +} +``` + +### 3. List Profiles + +```http +GET /api/admin/profiles/ +Authorization: Bearer +``` + +### 4. List Assignments + +```http +GET /api/admin/profiles/assignments +Authorization: Bearer +``` + +### 5. Unassign Profile + +```http +DELETE /api/admin/profiles/assignments/{agent_id} +Authorization: Bearer +``` + +### 6. Get Agent Config (Debugging) + +To see what configuration an agent receives: + +```http +GET /api/agents/host/{agent_id}/config +Authorization: Bearer +``` + +## Agent Behavior + +1. On startup, the agent computes its Agent ID. +2. It contacts the Pulse server to fetch its configuration profile. +3. If successful, it applies the remote settings, overriding local flags/env for supported keys. +4. If the server is unreachable or returns an error, the agent proceeds with its local configuration. +5. Profile changes take effect on the next agent restart. Command execution toggles are applied dynamically. + +## Storage + +Profiles and assignments are stored in the Pulse config directory: + +- `agent_profiles.json` +- `agent_profile_assignments.json` + +Deleting a profile automatically removes its assignments. diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index f095616a7..5e57eed5f 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -1176,13 +1176,22 @@ function AppLayout(props: { const hasSettingsAccess = !scopes || scopes.length === 0 || scopes.includes('*') || scopes.includes('settings:read'); - const tabs = [ + const tabs: Array<{ + id: 'alerts' | 'settings'; + label: string; + route: string; + tooltip: string; + badge: 'update' | null; + count: number | undefined; + breakdown: { warning: number; critical: number } | undefined; + icon: JSX.Element; + }> = [ { - id: 'alerts' as const, + id: 'alerts', label: 'Alerts', route: '/alerts', tooltip: 'Review active alerts and automation rules', - badge: null as 'update' | null, + badge: null, count: activeAlertCount, breakdown, icon: , @@ -1192,11 +1201,11 @@ function AppLayout(props: { // Only show settings tab if user has access if (hasSettingsAccess) { tabs.push({ - id: 'settings' as const, + id: 'settings', label: 'Settings', route: '/settings', tooltip: 'Configure Pulse preferences and integrations', - badge: updateStore.isUpdateVisible() ? ('update' as const) : null, + badge: updateStore.isUpdateVisible() ? 'update' : null, count: undefined, breakdown: undefined, icon: , @@ -1385,12 +1394,12 @@ function AppLayout(props: { } return ( - {tab.breakdown?.critical > 0 && ( + {tab.breakdown && tab.breakdown.critical > 0 && ( {tab.breakdown.critical} )} - {tab.breakdown?.warning > 0 && ( + {tab.breakdown && tab.breakdown.warning > 0 && ( {tab.breakdown.warning} diff --git a/frontend-modern/src/api/agentProfiles.ts b/frontend-modern/src/api/agentProfiles.ts new file mode 100644 index 000000000..33eb02919 --- /dev/null +++ b/frontend-modern/src/api/agentProfiles.ts @@ -0,0 +1,154 @@ +import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; + +/** + * Agent profile for centralized configuration management. + */ +export interface AgentProfile { + id: string; + name: string; + config: Record; + created_at: string; + updated_at: string; +} + +/** + * Assignment linking an agent to a profile. + */ +export interface AgentProfileAssignment { + agent_id: string; + profile_id: string; + updated_at: string; +} + +/** + * API client for agent profiles (Pro feature). + * Endpoints are gated behind license - returns 402 if not licensed. + */ +export class AgentProfilesAPI { + private static baseUrl = '/api/admin/profiles'; + + /** + * List all agent profiles. + */ + static async listProfiles(): Promise { + try { + const response = await apiFetchJSON(this.baseUrl); + return response || []; + } catch (err) { + // Handle 402 gracefully - means not licensed + if (err instanceof Error && err.message.includes('402')) { + return []; + } + throw err; + } + } + + /** + * Get a single profile by ID. + */ + static async getProfile(id: string): Promise { + try { + return await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(id)}`); + } catch (err) { + if (err instanceof Error && err.message.includes('404')) { + return null; + } + throw err; + } + } + + /** + * Create a new profile. + */ + static async createProfile(name: string, config: Record): Promise { + const response = await apiFetch(this.baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, config }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Failed to create profile: ${response.status}`); + } + + return response.json(); + } + + /** + * Update an existing profile. + */ + static async updateProfile(id: string, name: string, config: Record): Promise { + const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name, config }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Failed to update profile: ${response.status}`); + } + + return response.json(); + } + + /** + * Delete a profile. + */ + static async deleteProfile(id: string): Promise { + const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + const text = await response.text(); + throw new Error(text || `Failed to delete profile: ${response.status}`); + } + } + + /** + * List all profile assignments. + */ + static async listAssignments(): Promise { + try { + const response = await apiFetchJSON(`${this.baseUrl}/assignments`); + return response || []; + } catch (err) { + if (err instanceof Error && err.message.includes('402')) { + return []; + } + throw err; + } + } + + /** + * Assign a profile to an agent. + */ + static async assignProfile(agentId: string, profileId: string): Promise { + const response = await apiFetch(`${this.baseUrl}/assignments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent_id: agentId, profile_id: profileId }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Failed to assign profile: ${response.status}`); + } + } + + /** + * Remove profile assignment from an agent. + */ + static async unassignProfile(agentId: string): Promise { + const response = await apiFetch(`${this.baseUrl}/assignments/${encodeURIComponent(agentId)}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + const text = await response.text(); + throw new Error(text || `Failed to unassign profile: ${response.status}`); + } + } +} diff --git a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx new file mode 100644 index 000000000..3b6d561d2 --- /dev/null +++ b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx @@ -0,0 +1,583 @@ +import { Component, createSignal, createMemo, onMount, Show, For } from 'solid-js'; +import { useWebSocket } from '@/App'; +import { Card } from '@/components/shared/Card'; +import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment } from '@/api/agentProfiles'; +import { LicenseAPI } from '@/api/license'; +import { notificationStore } from '@/stores/notifications'; +import { logger } from '@/utils/logger'; +import { formatRelativeTime } from '@/utils/format'; +import Plus from 'lucide-solid/icons/plus'; +import Pencil from 'lucide-solid/icons/pencil'; +import Trash2 from 'lucide-solid/icons/trash-2'; +import Crown from 'lucide-solid/icons/crown'; +import Users from 'lucide-solid/icons/users'; +import Settings from 'lucide-solid/icons/settings'; + +// Known agent settings with their types and descriptions +interface BooleanSetting { + key: string; + type: 'boolean'; + label: string; + description: string; +} + +interface SelectSetting { + key: string; + type: 'select'; + label: string; + description: string; + options: string[]; +} + +interface DurationSetting { + key: string; + type: 'duration'; + label: string; + description: string; +} + +type KnownSetting = BooleanSetting | SelectSetting | DurationSetting; + +const KNOWN_SETTINGS: KnownSetting[] = [ + { key: 'enable_docker', type: 'boolean', label: 'Enable Docker Monitoring', description: 'Monitor Docker containers on this agent' }, + { key: 'enable_kubernetes', type: 'boolean', label: 'Enable Kubernetes Monitoring', description: 'Monitor Kubernetes workloads' }, + { key: 'enable_proxmox', type: 'boolean', label: 'Enable Proxmox Mode', description: 'Auto-detect and configure Proxmox API access' }, + { key: 'log_level', type: 'select', label: 'Log Level', description: 'Agent logging verbosity', options: ['debug', 'info', 'warn', 'error'] }, + { key: 'interval', type: 'duration', label: 'Reporting Interval', description: 'How often the agent reports metrics (e.g., 30s, 1m)' }, +]; + +export const AgentProfilesPanel: Component = () => { + const { state } = useWebSocket(); + + // License state + const [hasFeature, setHasFeature] = createSignal(false); + const [checkingLicense, setCheckingLicense] = createSignal(true); + + // Data state + const [profiles, setProfiles] = createSignal([]); + const [assignments, setAssignments] = createSignal([]); + const [loading, setLoading] = createSignal(true); + + // Modal state + const [showModal, setShowModal] = createSignal(false); + const [editingProfile, setEditingProfile] = createSignal(null); + const [saving, setSaving] = createSignal(false); + + // Form state + const [formName, setFormName] = createSignal(''); + const [formSettings, setFormSettings] = createSignal>({}); + + // Connected agents from WebSocket state + const connectedAgents = createMemo(() => { + const hosts = state.hosts || []; + return hosts.map(h => ({ + id: h.id, + hostname: h.hostname || 'Unknown', + displayName: h.displayName, + status: h.status || 'unknown', + lastSeen: h.lastSeen, + })); + }); + + // Get assignment for a specific agent + const getAgentAssignment = (agentId: string) => { + return assignments().find(a => a.agent_id === agentId); + }; + + // Get profile by ID + const getProfileById = (profileId: string) => { + return profiles().find(p => p.id === profileId); + }; + + // Count agents assigned to a profile + const getAssignmentCount = (profileId: string) => { + return assignments().filter(a => a.profile_id === profileId).length; + }; + + // Count settings in a profile + const getSettingsCount = (profile: AgentProfile) => { + return Object.keys(profile.config || {}).length; + }; + + // Load data + const loadData = async () => { + setLoading(true); + try { + const [profilesData, assignmentsData] = await Promise.all([ + AgentProfilesAPI.listProfiles(), + AgentProfilesAPI.listAssignments(), + ]); + setProfiles(profilesData); + setAssignments(assignmentsData); + } catch (err) { + logger.error('Failed to load agent profiles', err); + notificationStore.error('Failed to load agent profiles'); + } finally { + setLoading(false); + } + }; + + // Check license on mount + onMount(async () => { + try { + const features = await LicenseAPI.getFeatures(); + setHasFeature(features.features?.['agent_profiles'] === true); + } catch (err) { + logger.error('Failed to check license', err); + setHasFeature(false); + } finally { + setCheckingLicense(false); + } + + if (hasFeature()) { + await loadData(); + } else { + setLoading(false); + } + }); + + // Open modal for creating a new profile + const handleCreate = () => { + setEditingProfile(null); + setFormName(''); + setFormSettings({}); + setShowModal(true); + }; + + // Open modal for editing a profile + const handleEdit = (profile: AgentProfile) => { + setEditingProfile(profile); + setFormName(profile.name); + setFormSettings({ ...profile.config }); + setShowModal(true); + }; + + // Delete a profile + const handleDelete = async (profile: AgentProfile) => { + const assignedCount = getAssignmentCount(profile.id); + const confirmMsg = assignedCount > 0 + ? `Delete "${profile.name}"? ${assignedCount} agent(s) will be unassigned.` + : `Delete "${profile.name}"?`; + + if (!confirm(confirmMsg)) return; + + try { + await AgentProfilesAPI.deleteProfile(profile.id); + notificationStore.success(`Profile "${profile.name}" deleted`); + await loadData(); + } catch (err) { + logger.error('Failed to delete profile', err); + notificationStore.error('Failed to delete profile'); + } + }; + + // Save profile (create or update) + const handleSave = async () => { + const name = formName().trim(); + if (!name) { + notificationStore.error('Profile name is required'); + return; + } + + setSaving(true); + try { + const config = formSettings(); + const existing = editingProfile(); + + if (existing) { + await AgentProfilesAPI.updateProfile(existing.id, name, config); + notificationStore.success(`Profile "${name}" updated`); + } else { + await AgentProfilesAPI.createProfile(name, config); + notificationStore.success(`Profile "${name}" created`); + } + + setShowModal(false); + await loadData(); + } catch (err) { + logger.error('Failed to save profile', err); + notificationStore.error('Failed to save profile'); + } finally { + setSaving(false); + } + }; + + // Assign profile to agent + const handleAssign = async (agentId: string, profileId: string) => { + try { + if (profileId === '') { + await AgentProfilesAPI.unassignProfile(agentId); + notificationStore.success('Profile unassigned'); + } else { + await AgentProfilesAPI.assignProfile(agentId, profileId); + const profile = getProfileById(profileId); + notificationStore.success(`Assigned "${profile?.name || profileId}"`); + } + await loadData(); + } catch (err) { + logger.error('Failed to assign profile', err); + notificationStore.error('Failed to assign profile'); + } + }; + + // Update a setting in the form + const updateSetting = (key: string, value: unknown) => { + if (value === '' || value === undefined) { + const updated = { ...formSettings() }; + delete updated[key]; + setFormSettings(updated); + } else { + setFormSettings({ ...formSettings(), [key]: value }); + } + }; + + // Render license gate + if (checkingLicense()) { + return ( + +
+
+ Checking license... +
+ + ); + } + + if (!hasFeature()) { + return ( + +
+
+ +
+
+

Agent Profiles

+

Pro feature

+
+
+

+ Create reusable configuration profiles for your agents. Manage settings like Docker monitoring, + logging levels, and reporting intervals from a central location. +

+ + + Upgrade to Pro + +
+ ); + } + + return ( +
+ {/* Profiles Section */} + +
+
+
+ +
+
+

Configuration Profiles

+

Reusable agent configurations

+
+
+ +
+ + +
+
+
+ + + +
+ +

No profiles yet. Create one to get started.

+
+
+ + 0}> +
+ + + + + + + + + + + + {(profile) => ( + + + + + + + )} + + +
NameSettingsAgentsActions
+ {profile.name} + + {getSettingsCount(profile)} + + + + {getAssignmentCount(profile.id)} + + +
+ + +
+
+
+
+ + + {/* Assignments Section */} + +
+
+ +
+
+

Agent Assignments

+

Assign profiles to connected agents

+
+
+ + +
+ +

No agents connected. Install an agent to assign profiles.

+
+
+ + 0}> +
+ + + + + + + + + + + + {(agent) => { + const assignment = () => getAgentAssignment(agent.id); + const isOnline = () => agent.status?.toLowerCase() === 'online' || agent.status?.toLowerCase() === 'running'; + + return ( + + + + + + + ); + }} + + +
AgentProfileStatusLast Seen
+
+ + {agent.displayName || agent.hostname} + + + + ({agent.hostname}) + + +
+
+ + + + {isOnline() ? 'Online' : 'Offline'} + + + {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'} +
+
+
+
+ + {/* Profile Modal */} + +
+
+
+

+ {editingProfile() ? 'Edit Profile' : 'New Profile'} +

+ +
+ +
+ {/* Profile Name */} +
+ + setFormName(e.currentTarget.value)} + placeholder="e.g., Production Servers" + class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-800/60" + /> +
+ + {/* Settings */} +
+ + + + {(setting) => ( +
+
+ + + + + + + + + updateSetting(setting.key, e.currentTarget.value || undefined)} + placeholder="30s" + class="w-24 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> + +
+

{setting.description}

+
+ )} +
+
+
+ +
+ + +
+
+
+
+
+ ); +}; + +export default AgentProfilesPanel; diff --git a/internal/api/config_profiles.go b/internal/api/config_profiles.go new file mode 100644 index 000000000..096e63602 --- /dev/null +++ b/internal/api/config_profiles.go @@ -0,0 +1,316 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rs/zerolog/log" +) + +// ConfigProfileHandler handles configuration profile operations +type ConfigProfileHandler struct { + persistence *config.ConfigPersistence + mu sync.RWMutex +} + +// NewConfigProfileHandler creates a new handler +func NewConfigProfileHandler(persistence *config.ConfigPersistence) *ConfigProfileHandler { + return &ConfigProfileHandler{ + persistence: persistence, + } +} + +// ServeHTTP implements the http.Handler interface +func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Simple routing + path := strings.TrimSuffix(r.URL.Path, "/") + + if path == "" || path == "/" { + if r.Method == http.MethodGet { + h.ListProfiles(w, r) + return + } else if r.Method == http.MethodPost { + h.CreateProfile(w, r) + return + } + } else if path == "/assignments" { + if r.Method == http.MethodGet { + h.ListAssignments(w, r) + return + } else if r.Method == http.MethodPost { + h.AssignProfile(w, r) + return + } + } else if strings.HasPrefix(path, "/assignments/") { + if r.Method == http.MethodDelete { + agentID := strings.TrimPrefix(path, "/assignments/") + h.UnassignProfile(w, r, agentID) + return + } + } else { + // ID parameters + // Expecting /{id} + id := strings.TrimPrefix(path, "/") + if r.Method == http.MethodPut { + h.UpdateProfile(w, r, id) + return + } else if r.Method == http.MethodDelete { + h.DeleteProfile(w, r, id) + return + } + } + + http.Error(w, "Not found", http.StatusNotFound) +} + +// ListProfiles returns all profiles +func (h *ConfigProfileHandler) ListProfiles(w http.ResponseWriter, r *http.Request) { + profiles, err := h.persistence.LoadAgentProfiles() + if err != nil { + log.Error().Err(err).Msg("Failed to load profiles") + http.Error(w, "Failed to load profiles", http.StatusInternalServerError) + return + } + // Return empty array instead of null + if profiles == nil { + profiles = []models.AgentProfile{} + } + json.NewEncoder(w).Encode(profiles) +} + +// CreateProfile creates a new profile +func (h *ConfigProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) { + var input models.AgentProfile + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if input.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + profiles, err := h.persistence.LoadAgentProfiles() + if err != nil { + http.Error(w, "Failed to load profiles", http.StatusInternalServerError) + return + } + + input.ID = uuid.New().String() + input.CreatedAt = time.Now() + input.UpdatedAt = time.Now() + + profiles = append(profiles, input) + + if err := h.persistence.SaveAgentProfiles(profiles); err != nil { + log.Error().Err(err).Msg("Failed to save profiles") + http.Error(w, "Failed to save profile", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(input) +} + +// UpdateProfile updates an existing profile +func (h *ConfigProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request, id string) { + if id == "" { + http.Error(w, "ID is required", http.StatusBadRequest) + return + } + + var input models.AgentProfile + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + profiles, err := h.persistence.LoadAgentProfiles() + if err != nil { + http.Error(w, "Failed to load profiles", http.StatusInternalServerError) + return + } + + found := false + for i, p := range profiles { + if p.ID == id { + profiles[i].Name = input.Name + profiles[i].Config = input.Config + profiles[i].UpdatedAt = time.Now() + input = profiles[i] + found = true + break + } + } + + if !found { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + + if err := h.persistence.SaveAgentProfiles(profiles); err != nil { + log.Error().Err(err).Msg("Failed to save profiles") + http.Error(w, "Failed to save profile", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(input) +} + +// DeleteProfile deletes a profile +func (h *ConfigProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request, id string) { + h.mu.Lock() + defer h.mu.Unlock() + + profiles, err := h.persistence.LoadAgentProfiles() + if err != nil { + http.Error(w, "Failed to load profiles", http.StatusInternalServerError) + return + } + + newProfiles := []models.AgentProfile{} + for _, p := range profiles { + if p.ID != id { + newProfiles = append(newProfiles, p) + } + } + + if len(newProfiles) == len(profiles) { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + + if err := h.persistence.SaveAgentProfiles(newProfiles); err != nil { + log.Error().Err(err).Msg("Failed to save profiles") + http.Error(w, "Failed to delete profile", http.StatusInternalServerError) + return + } + + assignments, err := h.persistence.LoadAgentProfileAssignments() + if err != nil { + log.Error().Err(err).Msg("Failed to load assignments for profile cleanup") + http.Error(w, "Failed to delete profile assignments", http.StatusInternalServerError) + return + } + + cleaned := assignments[:0] + for _, a := range assignments { + if a.ProfileID != id { + cleaned = append(cleaned, a) + } + } + + if len(cleaned) != len(assignments) { + if err := h.persistence.SaveAgentProfileAssignments(cleaned); err != nil { + log.Error().Err(err).Msg("Failed to clean up assignments for deleted profile") + http.Error(w, "Failed to delete profile assignments", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusOK) +} + +// ListAssignments returns all assignments +func (h *ConfigProfileHandler) ListAssignments(w http.ResponseWriter, r *http.Request) { + assignments, err := h.persistence.LoadAgentProfileAssignments() + if err != nil { + log.Error().Err(err).Msg("Failed to load assignments") + http.Error(w, "Failed to load assignments", http.StatusInternalServerError) + return + } + // Return empty array instead of null + if assignments == nil { + assignments = []models.AgentProfileAssignment{} + } + json.NewEncoder(w).Encode(assignments) +} + +// AssignProfile assigns a profile to an agent +func (h *ConfigProfileHandler) AssignProfile(w http.ResponseWriter, r *http.Request) { + var input models.AgentProfileAssignment + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if input.AgentID == "" || input.ProfileID == "" { + http.Error(w, "AgentID and ProfileID are required", http.StatusBadRequest) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + assignments, err := h.persistence.LoadAgentProfileAssignments() + if err != nil { + http.Error(w, "Failed to load assignments", http.StatusInternalServerError) + return + } + + // Remove existing assignment for this agent if exists + newAssignments := []models.AgentProfileAssignment{} + for _, a := range assignments { + if a.AgentID != input.AgentID { + newAssignments = append(newAssignments, a) + } + } + + input.UpdatedAt = time.Now() + newAssignments = append(newAssignments, input) + + if err := h.persistence.SaveAgentProfileAssignments(newAssignments); err != nil { + log.Error().Err(err).Msg("Failed to save assignments") + http.Error(w, "Failed to save assignment", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(input) +} + +// UnassignProfile removes a profile assignment for an agent. +func (h *ConfigProfileHandler) UnassignProfile(w http.ResponseWriter, r *http.Request, agentID string) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + http.Error(w, "AgentID is required", http.StatusBadRequest) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + assignments, err := h.persistence.LoadAgentProfileAssignments() + if err != nil { + http.Error(w, "Failed to load assignments", http.StatusInternalServerError) + return + } + + newAssignments := assignments[:0] + for _, a := range assignments { + if a.AgentID != agentID { + newAssignments = append(newAssignments, a) + } + } + + if len(newAssignments) != len(assignments) { + if err := h.persistence.SaveAgentProfileAssignments(newAssignments); err != nil { + log.Error().Err(err).Msg("Failed to save assignments") + http.Error(w, "Failed to save assignment", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/config_profiles_test.go b/internal/api/config_profiles_test.go new file mode 100644 index 000000000..8d01e711e --- /dev/null +++ b/internal/api/config_profiles_test.go @@ -0,0 +1,236 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestConfigProfileHandlers(t *testing.T) { + tempDir := t.TempDir() + persistence := config.NewConfigPersistence(tempDir) + if err := persistence.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + handler := NewConfigProfileHandler(persistence) + + // 1. List Profiles (Empty) + t.Run("ListProfilesEmpty", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + + var profiles []models.AgentProfile + if err := json.NewDecoder(rec.Body).Decode(&profiles); err != nil { + t.Fatalf("failed to decode: %v", err) + } + if len(profiles) != 0 { + t.Errorf("expected 0 profiles, got %d", len(profiles)) + } + }) + + var profileID string + + // 2. Create Profile + t.Run("CreateProfile", func(t *testing.T) { + profile := models.AgentProfile{ + Name: "Test Profile", + Config: map[string]interface{}{ + "log_level": "debug", + "interval": "30s", + }, + } + body, _ := json.Marshal(profile) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var created models.AgentProfile + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("failed to decode: %v", err) + } + + if created.Name != "Test Profile" { + t.Errorf("expected name 'Test Profile', got %q", created.Name) + } + if created.ID == "" { + t.Error("expected non-empty ID") + } + profileID = created.ID + }) + + // 3. List Profiles (1 Profile) + t.Run("ListProfilesOne", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var profiles []models.AgentProfile + json.NewDecoder(rec.Body).Decode(&profiles) + if len(profiles) != 1 { + t.Errorf("expected 1 profile, got %d", len(profiles)) + } + if profiles[0].ID != profileID { + t.Errorf("expected ID %s, got %s", profileID, profiles[0].ID) + } + }) + + // 4. Update Profile + t.Run("UpdateProfile", func(t *testing.T) { + update := models.AgentProfile{ + Name: "Updated Profile", + Config: map[string]interface{}{ + "log_level": "info", + }, + } + body, _ := json.Marshal(update) + req := httptest.NewRequest(http.MethodPut, "/"+profileID, bytes.NewBuffer(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var updated models.AgentProfile + json.NewDecoder(rec.Body).Decode(&updated) + if updated.Name != "Updated Profile" { + t.Errorf("expected updated name, got %q", updated.Name) + } + }) + + // 5. List Assignments (Empty) + t.Run("ListAssignmentsEmpty", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assignments", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var assignments []models.AgentProfileAssignment + json.NewDecoder(rec.Body).Decode(&assignments) + if len(assignments) != 0 { + t.Errorf("expected 0 assignments, got %d", len(assignments)) + } + }) + + // 6. Assign Profile + t.Run("AssignProfile", func(t *testing.T) { + assignment := models.AgentProfileAssignment{ + AgentID: "test-agent", + ProfileID: profileID, + } + body, _ := json.Marshal(assignment) + req := httptest.NewRequest(http.MethodPost, "/assignments", bytes.NewBuffer(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + var created models.AgentProfileAssignment + json.NewDecoder(rec.Body).Decode(&created) + if created.AgentID != "test-agent" || created.ProfileID != profileID { + t.Errorf("assignment mismatch: %+v", created) + } + }) + + // 7. List Assignments (1 Assignment) + t.Run("ListAssignmentsOne", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assignments", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var assignments []models.AgentProfileAssignment + if err := json.NewDecoder(rec.Body).Decode(&assignments); err != nil { + t.Fatalf("failed to decode: %v", err) + } + if len(assignments) != 1 { + t.Errorf("expected 1 assignment, got %d", len(assignments)) + } + }) + + // 8. Unassign Profile + t.Run("UnassignProfile", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/assignments/test-agent", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d: %s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/assignments", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var assignments []models.AgentProfileAssignment + if err := json.NewDecoder(rec.Body).Decode(&assignments); err != nil { + t.Fatalf("failed to decode: %v", err) + } + if len(assignments) != 0 { + t.Errorf("expected 0 assignments after unassign, got %d", len(assignments)) + } + }) + + // 9. Assign Profile (Cleanup on Delete) + t.Run("AssignProfileForDeleteCleanup", func(t *testing.T) { + assignment := models.AgentProfileAssignment{ + AgentID: "cleanup-agent", + ProfileID: profileID, + } + body, _ := json.Marshal(assignment) + req := httptest.NewRequest(http.MethodPost, "/assignments", bytes.NewBuffer(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + // 10. Delete Profile + t.Run("DeleteProfile", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/"+profileID, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + + // Verify deleted + req = httptest.NewRequest(http.MethodGet, "/", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + var profiles []models.AgentProfile + json.NewDecoder(rec.Body).Decode(&profiles) + if len(profiles) != 0 { + t.Errorf("expected 0 profiles after delete, got %d", len(profiles)) + } + + req = httptest.NewRequest(http.MethodGet, "/assignments", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + var assignments []models.AgentProfileAssignment + if err := json.NewDecoder(rec.Body).Decode(&assignments); err != nil { + t.Fatalf("failed to decode: %v", err) + } + if len(assignments) != 0 { + t.Errorf("expected 0 assignments after profile delete, got %d", len(assignments)) + } + }) +} diff --git a/internal/models/profiles.go b/internal/models/profiles.go new file mode 100644 index 000000000..72d8ed5c7 --- /dev/null +++ b/internal/models/profiles.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" +) + +// AgentProfile represents a reusable configuration profile for agents. +type AgentProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Config AgentConfigMap `json:"config"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentConfigMap represents the key-value configuration overrides +// (e.g., {"interval": "10s", "enable_docker": true}) +type AgentConfigMap map[string]interface{} + +// AgentProfileAssignment maps an agent to a profile +type AgentProfileAssignment struct { + AgentID string `json:"agent_id"` + ProfileID string `json:"profile_id"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/monitoring/monitor_profiles_test.go b/internal/monitoring/monitor_profiles_test.go new file mode 100644 index 000000000..348e5b96a --- /dev/null +++ b/internal/monitoring/monitor_profiles_test.go @@ -0,0 +1,93 @@ +package monitoring + +import ( + "os" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestGetHostAgentConfig_WithProfiles(t *testing.T) { + // Setup temp dir for persistence + tmpDir, err := os.MkdirTemp("", "monitor_profiles_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Initialize persistence + persistence := config.NewConfigPersistence(tmpDir) + + // Create a profile + profile := models.AgentProfile{ + ID: "profile-1", + Name: "Test Profile", + Config: map[string]interface{}{ + "enable_docker": true, + "log_level": "debug", + }, + } + if err := persistence.SaveAgentProfiles([]models.AgentProfile{profile}); err != nil { + t.Fatalf("Failed to save profiles: %v", err) + } + + // Create an assignment + assignment := models.AgentProfileAssignment{ + AgentID: "agent-123", + ProfileID: "profile-1", + } + if err := persistence.SaveAgentProfileAssignments([]models.AgentProfileAssignment{assignment}); err != nil { + t.Fatalf("Failed to save assignments: %v", err) + } + + // Initialize Monitor with persistence + m := &Monitor{ + persistence: persistence, + // minimal dependencies + config: &config.Config{}, + } + + // Test Case 1: Agent with assigned profile + t.Run("Agent with profile assignment", func(t *testing.T) { + cfg := m.GetHostAgentConfig("agent-123") + + if cfg.Settings == nil { + t.Fatal("Expected Settings to be non-nil") + } + + if val, ok := cfg.Settings["enable_docker"]; !ok || val != true { + t.Errorf("Expected enable_docker=true, got %v", val) + } + + if val, ok := cfg.Settings["log_level"]; !ok || val != "debug" { + t.Errorf("Expected log_level='debug', got %v", val) + } + }) + + // Test Case 2: Agent without assignment + t.Run("Agent without assignment", func(t *testing.T) { + cfg := m.GetHostAgentConfig("other-agent") + + if len(cfg.Settings) != 0 { + t.Errorf("Expected empty Settings for unassigned agent, got %v", cfg.Settings) + } + }) + + // Test Case 3: Agent assigned to non-existent profile + t.Run("Agent assigned to missing profile", func(t *testing.T) { + badAssignment := models.AgentProfileAssignment{ + AgentID: "agent-bad", + ProfileID: "missing-profile", + } + if err := persistence.SaveAgentProfileAssignments([]models.AgentProfileAssignment{assignment, badAssignment}); err != nil { + t.Fatalf("Failed to save assignments: %v", err) + } + + cfg := m.GetHostAgentConfig("agent-bad") + + if len(cfg.Settings) != 0 { + t.Errorf("Expected empty Settings for missing profile, got %v", cfg.Settings) + } + }) +} diff --git a/internal/remoteconfig/client.go b/internal/remoteconfig/client.go new file mode 100644 index 000000000..83ad1e66e --- /dev/null +++ b/internal/remoteconfig/client.go @@ -0,0 +1,156 @@ +package remoteconfig + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog" +) + +// Config holds configuration for the remote config client. +type Config struct { + PulseURL string + APIToken string + AgentID string + Hostname string + InsecureSkipVerify bool + Logger zerolog.Logger +} + +// Client handles fetching remote configuration from the Pulse server. +type Client struct { + cfg Config + httpClient *http.Client +} + +// Response represents the JSON response from the config endpoint. +type Response struct { + Success bool `json:"success"` + HostID string `json:"hostId"` + Config struct { + CommandsEnabled *bool `json:"commandsEnabled,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` + } `json:"config"` +} + +// New creates a new remote config client. +func New(cfg Config) *Client { + if cfg.PulseURL == "" { + cfg.PulseURL = "http://localhost:7655" + } + cfg.PulseURL = strings.TrimRight(cfg.PulseURL, "/") + + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if cfg.InsecureSkipVerify { + //nolint:gosec // Insecure mode is explicitly user-controlled. + tlsConfig.InsecureSkipVerify = true + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsConfig, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return fmt.Errorf("server returned redirect to %s", req.URL) + }, + } + + return &Client{ + cfg: cfg, + httpClient: httpClient, + } +} + +// Fetch retrieves the remote configuration for this agent. +// It returns a map of settings to apply, or an error if the fetch fails. +// Returns (settings, commandsEnabled, error) +func (c *Client) Fetch(ctx context.Context) (map[string]interface{}, *bool, error) { + if c.cfg.AgentID == "" { + return nil, nil, fmt.Errorf("agent ID is required to fetch remote config") + } + + hostID := c.cfg.AgentID + if resolved, err := c.resolveHostID(ctx); err != nil { + return nil, nil, err + } else if resolved != "" { + hostID = resolved + } + + url := fmt.Sprintf("%s/api/agents/host/%s/config", c.cfg.PulseURL, hostID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.cfg.APIToken) + req.Header.Set("X-API-Token", c.cfg.APIToken) + req.Header.Set("User-Agent", "pulse-agent-config-client") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, nil, fmt.Errorf("server responded with status %s", resp.Status) + } + + var configResp Response + if err := json.NewDecoder(resp.Body).Decode(&configResp); err != nil { + return nil, nil, fmt.Errorf("decode response: %w", err) + } + + return configResp.Config.Settings, configResp.Config.CommandsEnabled, nil +} + +func (c *Client) resolveHostID(ctx context.Context) (string, error) { + hostname := strings.TrimSpace(c.cfg.Hostname) + if hostname == "" { + return "", nil + } + + url := fmt.Sprintf("%s/api/agents/host/lookup?hostname=%s", c.cfg.PulseURL, hostname) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("create host lookup request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.cfg.APIToken) + req.Header.Set("X-API-Token", c.cfg.APIToken) + req.Header.Set("User-Agent", "pulse-agent-config-client") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("host lookup request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", nil + } + if resp.StatusCode >= 300 { + return "", fmt.Errorf("host lookup responded with status %s", resp.Status) + } + + var payload struct { + Success bool `json:"success"` + Host struct { + ID string `json:"id"` + } `json:"host"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("decode host lookup response: %w", err) + } + if !payload.Success { + return "", nil + } + return strings.TrimSpace(payload.Host.ID), nil +} diff --git a/internal/remoteconfig/client_test.go b/internal/remoteconfig/client_test.go new file mode 100644 index 000000000..6c55ab081 --- /dev/null +++ b/internal/remoteconfig/client_test.go @@ -0,0 +1,80 @@ +package remoteconfig + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_Fetch(t *testing.T) { + t.Run("successful fetch with full config", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/agents/host/agent-1/config" { + t.Errorf("Expected path /api/agents/host/agent-1/config, got %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer token-123" { + t.Errorf("Expected Authorization header 'Bearer token-123', got %s", r.Header.Get("Authorization")) + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "success": true, + "hostId": "agent-1", + "config": { + "commandsEnabled": true, + "settings": { + "interval": "1m", + "enable_docker": false + } + } + }`)) + })) + defer ts.Close() + + client := New(Config{ + PulseURL: ts.URL, + APIToken: "token-123", + AgentID: "agent-1", + }) + + settings, commandsEnabled, err := client.Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + + if commandsEnabled == nil || *commandsEnabled != true { + t.Errorf("Expected commandsEnabled=true, got %v", commandsEnabled) + } + + if settings["interval"] != "1m" { + t.Errorf("Expected interval='1m', got %v", settings["interval"]) + } + if settings["enable_docker"] != false { + t.Errorf("Expected enable_docker=false, got %v", settings["enable_docker"]) + } + }) + + t.Run("server error", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + client := New(Config{PulseURL: ts.URL, APIToken: "t", AgentID: "a"}) + _, _, err := client.Fetch(context.Background()) + if err == nil { + t.Fatal("Expected error, got nil") + } + }) + + t.Run("missing agent ID", func(t *testing.T) { + client := New(Config{PulseURL: "http://localhost", APIToken: "t", AgentID: ""}) + _, _, err := client.Fetch(context.Background()) + if err == nil { + t.Fatal("Expected error for missing agent ID, got nil") + } + }) +}