diff --git a/cmd/pulse-docker-agent/main.go b/cmd/pulse-docker-agent/main.go index 77f7d5b..7718e38 100644 --- a/cmd/pulse-docker-agent/main.go +++ b/cmd/pulse-docker-agent/main.go @@ -56,6 +56,7 @@ func loadConfig() dockeragent.Config { envHostname := strings.TrimSpace(os.Getenv("PULSE_HOSTNAME")) envAgentID := strings.TrimSpace(os.Getenv("PULSE_AGENT_ID")) envInsecure := strings.TrimSpace(os.Getenv("PULSE_INSECURE_SKIP_VERIFY")) + envNoAutoUpdate := strings.TrimSpace(os.Getenv("PULSE_NO_AUTO_UPDATE")) envTargets := strings.TrimSpace(os.Getenv("PULSE_TARGETS")) defaultInterval := 30 * time.Second @@ -71,6 +72,7 @@ func loadConfig() dockeragent.Config { hostnameFlag := flag.String("hostname", envHostname, "Override hostname reported to Pulse") agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier") insecureFlag := flag.Bool("insecure", parseBool(envInsecure), "Skip TLS certificate verification") + noAutoUpdateFlag := flag.Bool("no-auto-update", parseBool(envNoAutoUpdate), "Disable automatic agent updates") var targetFlags targetFlagList flag.Var(&targetFlags, "target", "Pulse target in url|token[|insecure] format. Repeat to send to multiple Pulse instances") @@ -123,6 +125,7 @@ func loadConfig() dockeragent.Config { HostnameOverride: strings.TrimSpace(*hostnameFlag), AgentID: strings.TrimSpace(*agentIDFlag), InsecureSkipVerify: *insecureFlag, + DisableAutoUpdate: *noAutoUpdateFlag, Targets: targets, } } diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index b0d3cf6..4a666b7 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -141,6 +141,18 @@ For generic webhooks, you can define custom JSON payloads using Go template synt | `{{.Level \| upper}}` | Uppercase | "WARNING" | | `{{.Level \| lower}}` | Lowercase | "warning" | | `{{printf "%.1f" .Value}}` | Format numbers | "95.5" | +| `{{urlpath .ResourceName}}` | URL-encode a value for path segments | "web%20server" | +| `{{urlquery .Message}}` | URL-encode a value for query parameters | "CPU+usage+%3E+90%25" | + +## Dynamic URL Templates + +Webhook URLs can also reference the same template variables that are available to payloads. Pulse renders the URL before sending the request and safely encodes path segments by default. When placing values inside query parameters, wrap them with the provided helpers to ensure correct encoding: + +```text +https://example.com/hooks/{{urlpath .ResourceName}}?msg={{urlquery .Message}} +``` + +Supported variables are identical to the payload template list (`{{.Message}}`, `{{.Node}}`, custom fields, etc.). This makes it easy to call services that expect alert metadata in the URL itself. ### Example Templates diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index 09c4723..d3aeedd 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -1,4 +1,4 @@ -import type { State, Performance, Stats } from '@/types/api'; +import type { State, Performance, Stats, DockerHostCommand } from '@/types/api'; import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; export class MonitoringAPI { @@ -21,17 +21,108 @@ export class MonitoringAPI { return response.blob(); } - static async deleteDockerHost(hostId: string): Promise { - const response = await apiFetch( - `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}`, - { - method: 'DELETE', - }, - ); + static async deleteDockerHost( + hostId: string, + options: { hide?: boolean; force?: boolean } = {} + ): Promise { + const params = new URLSearchParams(); + if (options.hide) params.set('hide', 'true'); + if (options.force) params.set('force', 'true'); + const query = params.toString(); + const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}${query ? `?${query}` : ''}`; + + const response = await apiFetch(url, { + method: 'DELETE', + }); if (!response.ok) { if (response.status === 404) { - // Host already gone; treat as success so UI state stays consistent + return {}; + } + + let message = `Failed with status ${response.status}`; + try { + const text = await response.text(); + if (text?.trim()) { + message = text.trim(); + try { + const parsed = JSON.parse(text); + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + message = parsed.error.trim(); + } + } catch (_jsonErr) { + // ignore JSON parse errors, fallback to raw text + } + } + } catch (_err) { + // ignore read error, keep default message + } + + throw new Error(message); + } + + if (response.status === 204) { + return {}; + } + + const text = await response.text(); + if (!text?.trim()) { + return {}; + } + + try { + const parsed = JSON.parse(text) as DeleteDockerHostResponse; + return parsed; + } catch (err) { + throw new Error((err as Error).message || 'Failed to parse delete docker host response'); + } + } + + static async unhideDockerHost(hostId: string): Promise { + const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/unhide`; + + const response = await apiFetch(url, { + method: 'PUT', + }); + + if (!response.ok) { + if (response.status === 404) { + // Host already gone; treat as success + return; + } + + let message = `Failed with status ${response.status}`; + try { + const text = await response.text(); + if (text?.trim()) { + message = text.trim(); + try { + const parsed = JSON.parse(text); + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + message = parsed.error.trim(); + } + } catch (_jsonErr) { + // ignore JSON parse errors, fallback to raw text + } + } + } catch (_err) { + // ignore read error, keep default message + } + + throw new Error(message); + } + } + + static async markDockerHostPendingUninstall(hostId: string): Promise { + const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/pending-uninstall`; + + const response = await apiFetch(url, { + method: 'PUT', + }); + + if (!response.ok) { + if (response.status === 404) { + // Host already gone; treat as success return; } @@ -57,3 +148,10 @@ export class MonitoringAPI { } } } + +export interface DeleteDockerHostResponse { + success?: boolean; + hostId?: string; + message?: string; + command?: DockerHostCommand; +} diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.tsx index ff0a0b6..9886aeb 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.tsx @@ -534,6 +534,21 @@ export function WebhookConfig(props: WebhookConfigProps) { placeholder={currentTemplate()?.urlPattern || 'https://example.com/webhook'} class={controlClass('px-2 py-1.5 font-mono')} /> +

+ Supports template variables like{' '} + + {'{{.Message}}'} + + . Use{' '} + + {'{{urlpath ...}}'} + {' '} + or{' '} + + {'{{urlquery ...}}'} + {' '} + to keep dynamic values URL-safe. +

{/* Custom Payload Template - only show for generic service */} diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index df22331..85d20b7 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -1,10 +1,12 @@ -import { Component, For, Show, createSignal, onMount } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { showError, showSuccess } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; import { formatRelativeTime } from '@/utils/format'; +import { useWebSocket } from '@/App'; +import type { DockerHost } from '@/types/api'; interface APITokenManagerProps { currentTokenHint?: string; @@ -12,6 +14,27 @@ interface APITokenManagerProps { } export const APITokenManager: Component = (props) => { + const { state } = useWebSocket(); + const dockerHosts = createMemo(() => state.dockerHosts ?? []); + const dockerTokenUsage = createMemo(() => { + const usage = new Map(); + for (const host of dockerHosts()) { + const tokenId = host.tokenId; + if (!tokenId) continue; + const displayName = host.displayName?.trim() || host.hostname || host.id; + const existing = usage.get(tokenId); + if (existing) { + usage.set(tokenId, { + count: existing.count + 1, + hosts: [...existing.hosts, displayName], + }); + } else { + usage.set(tokenId, { count: 1, hosts: [displayName] }); + } + } + return usage; + }); + const [tokens, setTokens] = createSignal([]); const [loading, setLoading] = createSignal(true); const [isGenerating, setIsGenerating] = createSignal(false); @@ -43,22 +66,32 @@ export const APITokenManager: Component = (props) => { try { const trimmedName = nameInput().trim() || undefined; const { token, record } = await SecurityAPI.createToken(trimmedName); + + console.log('✅ Token generated:', { token, record }); + setTokens((prev) => [record, ...prev]); setNewTokenValue(token); setNewTokenRecord(record); setNameInput(''); - showSuccess('New API token generated! Save it now – it will not be shown again.'); + + console.log('✅ State updated, newTokenValue:', token); + + showSuccess('New API token generated! Scroll up to see it!'); props.onTokensChanged?.(); try { window.localStorage.setItem('apiToken', token); - // Fire a storage event so other listeners update immediately window.dispatchEvent( new StorageEvent('storage', { key: 'apiToken', newValue: token }), ); } catch (storageErr) { console.warn('Unable to persist API token in localStorage', storageErr); } + + // Scroll to top to show the token + setTimeout(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, 100); } catch (err) { console.error('Failed to generate API token', err); showError('Failed to generate API token'); @@ -67,6 +100,23 @@ export const APITokenManager: Component = (props) => { } }; + const tokenHint = (record: APITokenRecord) => { + if (record.prefix && record.suffix) { + return `${record.prefix}…${record.suffix}`; + } + if (record.prefix) { + return `${record.prefix}…`; + } + return '—'; + }; + + const tokenNameForDialog = (record: APITokenRecord) => { + if (record.name?.trim()) return record.name.trim(); + if (record.prefix && record.suffix) return `${record.prefix}…${record.suffix}`; + if (record.prefix) return `${record.prefix}…`; + return 'unnamed token'; + }; + const handleCopy = async () => { const value = newTokenValue(); if (!value) return; @@ -81,9 +131,22 @@ export const APITokenManager: Component = (props) => { }; const handleDelete = async (record: APITokenRecord) => { - const confirmed = window.confirm( - `Revoke token "${record.name}"? Any agents or integrations using it will stop working.`, - ); + const usage = dockerTokenUsage().get(record.id); + const displayName = tokenNameForDialog(record); + + let message = `Revoke token "${displayName}"? Any agents or integrations using it will stop working.`; + + if (usage) { + const hostListPreview = usage.hosts.slice(0, 5).join(', '); + const extraCount = usage.hosts.length - 5; + const hostSummary = + extraCount > 0 ? `${hostListPreview}, +${extraCount} more` : hostListPreview; + const hostCountLabel = + usage.count === 1 ? 'a Docker host' : `${usage.count} Docker hosts`; + message = `Token "${displayName}" is currently used by ${hostCountLabel}.\nHosts: ${hostSummary}\n\nRevoking it will cause those agents to stop reporting until you update them with a new token.\n\nContinue?`; + } + + const confirmed = window.confirm(message); if (!confirmed) return; try { @@ -103,16 +166,6 @@ export const APITokenManager: Component = (props) => { } }; - const tokenHint = (record: APITokenRecord) => { - if (record.prefix && record.suffix) { - return `${record.prefix}…${record.suffix}`; - } - if (record.prefix) { - return `${record.prefix}…`; - } - return '—'; - }; - return (
@@ -137,63 +190,85 @@ export const APITokenManager: Component = (props) => {
-
- Issue a dedicated token for each host or automation. That way, if a system is compromised, you can revoke just its token without disrupting anything else. -
- -
- - setNameInput(event.currentTarget.value)} - class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - -
- - -

- Current token hint: {props.currentTokenHint} -

-
- + {/* CRITICAL: Show generated token FIRST and PROMINENTLY */} -
-
-

✅ New API token generated

-

- Save this value now – it is only shown once. Update your automation or agents immediately. -

-
+
+
+
+ + + +
+
+
+

Token Generated!

+

+ ⚠️ This is shown ONCE. Copy it now or lose it forever! +

+
+ +
+ +
+ + {newTokenValue()} + + +
+
+ +
+

+ 💡 Next: Use this token on the Docker Agents page to deploy monitoring. +

+
-
-
- - {newTokenValue()} -
+
+ Issue a dedicated token for each host or automation. That way, if a system is compromised, you can revoke just its token without disrupting anything else. +
+ +
+

Generate new token

+
+ setNameInput(event.currentTarget.value)} + placeholder="e.g., docker-host-1" + class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+

Active tokens

= (props) => { - {(token) => ( - - {token.name || 'Untitled token'} - {tokenHint(token)} - - {formatRelativeTime(new Date(token.createdAt).getTime())} - - - {token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'} - - - - - - )} + {(token) => { + const usage = dockerTokenUsage().get(token.id); + const hostTitle = usage ? usage.hosts.join(', ') : undefined; + const hostPreview = usage ? usage.hosts.slice(0, 2).join(', ') : ''; + const extraCount = usage ? usage.hosts.length - 2 : 0; + const hostSummary = + usage && usage.count === 1 + ? usage.hosts[0] + : usage + ? `${hostPreview}${extraCount > 0 ? `, +${extraCount} more` : ''}` + : ''; + const hostCountLabel = + usage && usage.count === 1 ? 'host' : usage ? 'hosts' : ''; + + return ( + + +
+ {token.name || 'Untitled token'} + + + Docker + + +
+ +
+ Used by Docker {hostCountLabel}: {hostSummary} +
+
+ + + {tokenHint(token)} + + + {formatRelativeTime(new Date(token.createdAt).getTime())} + + + {token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'} + + + + + + ); + }}
diff --git a/frontend-modern/src/components/Settings/CommandBuilder.tsx b/frontend-modern/src/components/Settings/CommandBuilder.tsx index 1ba74d6..442b65c 100644 --- a/frontend-modern/src/components/Settings/CommandBuilder.tsx +++ b/frontend-modern/src/components/Settings/CommandBuilder.tsx @@ -11,6 +11,7 @@ interface CommandBuilderProps { onTokenGenerated?: (token: string, record: APITokenRecord) => void; requiresToken: boolean; hasExistingToken?: boolean; + canManageTokens?: boolean; } export const CommandBuilder: Component = (props) => { @@ -28,7 +29,9 @@ export const CommandBuilder: Component = (props) => { const [tokenLabel, setTokenLabel] = createSignal('Docker agent token'); const defaultTokenLabel = () => `Docker agent token ${new Date().toISOString().slice(0, 10)}`; + const canManageTokens = () => props.canManageTokens !== false; const openGenerateModal = () => { + if (!canManageTokens()) return; setTokenLabel(defaultTokenLabel()); setShowGenerateModal(true); }; @@ -165,6 +168,7 @@ export const CommandBuilder: Component = (props) => { // Generate new token const generateNewToken = async () => { + if (!canManageTokens()) return; if (isGenerating()) return; setIsGenerating(true); @@ -294,15 +298,22 @@ export const CommandBuilder: Component = (props) => {

Generate a token to secure your Docker agents.

- + + +
+ +

+ Sign in with an administrator account to create tokens from the browser. +

+
} > @@ -341,15 +352,17 @@ export const CommandBuilder: Component = (props) => { > Copy - + + +

@@ -549,10 +562,41 @@ export const CommandBuilder: Component = (props) => { + + + Still failing? Check network connectivity and server logs. + + + + + + +

+ +
- -
-
-
-

Token generated

-

- “{newTokenRecord()?.name || 'Untitled token'}” will only be shown once. Copy it into the install command below. -

-
- +
+
+ + + +
+ Best practice: Give each Docker host its own API token. You can audit or revoke tokens anytime from{' '} + + Security Settings + + .
- - {newTokenValue()} - +
+
+ + +
+ Pick an option above to unlock the install command.
- - -
- Existing token hint: {securityStatus()?.apiTokenHint}. Paste the full value if you want to reuse it. + +
+ Need a reusable token? Generate one here or visit Security Settings to manage tokens across users.
- - 0}> -
- - View saved tokens - -
- - - - - - - - - - - {(token) => ( - - - - - - )} - - -
NameHintLast used
{tokenDisplayLabel(token)} - {token.prefix && token.suffix ? `${token.prefix}…${token.suffix}` : '—'} - - {token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'} -
-
-
-
- +
-
-

Step 2 · Build the command

- { - setApiToken(token); - setAvailableTokens((prev) => { - const filtered = prev.filter((existing) => existing.id !== record.id); - return [record, ...filtered]; - }); - setSecurityStatus((prev) => { - if (!prev) return prev; - const hint = - record.prefix && record.suffix - ? `${record.prefix}…${record.suffix}` - : prev.apiTokenHint; - return { - ...prev, - apiTokenConfigured: true, - apiTokenHint: hint || prev.apiTokenHint, - }; - }); - if (typeof window !== 'undefined') { - try { - window.localStorage.setItem('apiToken', token); - window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: token })); - } catch (err) { - console.warn('Unable to persist API token in localStorage', err); - } - } - }} - /> -

- Run the command as root (prepend sudo when needed). The installer downloads the agent, creates a systemd service, and starts reporting automatically. -

-
- -
- Step 3 · Within one or two heartbeats (default 30 seconds) the host should appear in the table below. If not, check the agent logs with{' '} - journalctl -u pulse-docker-agent -f. -
+ +
+
+ + +
+
+ + {getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, apiToken() || TOKEN_PLACEHOLDER)} + +
+

+ The installer downloads the agent, creates a systemd service, and starts reporting automatically. +

+
+
@@ -477,12 +823,339 @@ WantedBy=multi-user.target`; + {/* Generate token modal */} + +
+
+
+

Generate a new Docker API token

+

+ Pulse will create a scoped token and insert it into the install command. You can manage or revoke tokens anytime from Security Settings. +

+
+
+ + setNewTokenName(event.currentTarget.value)} + class="w-full rounded 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-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" + placeholder="Docker host token" + /> +

+ Friendly names make it easier to audit tokens later (e.g. docker-prod-01). +

+
+
+ + +
+
+
+
+ + {/* Remove Docker Host Modal */} + +
+
+
+

+ Remove Docker host "{modalDisplayName()}" +

+

+ Pulse guides you through uninstalling the agent and safely cleaning up the host entry. +

+
+ +
+
+
+ + + +
+

Step 1 · Pulse stops the agent

+

+ Pulse will queue a stop command with this host. The agent shuts down its system service (or Unraid autostart hook if present), confirms back to Pulse, and the row disappears as soon as that acknowledgement arrives—or after the next missed heartbeat. +

+ +
+
+ Command status + + {modalCommandStatus()} + +
+ +

{modalCommand()?.message}

+
+ +

+ {modalCommand()?.failureReason || 'Pulse could not stop the agent automatically.'} +

+
+
+
+ + +

Hang tight—Pulse is waiting for the agent to acknowledge the stop command.

+
+ +
+

+ Agent confirmed the stop. Pulse has already cleaned up everything it controls: +

+
    +
  • • Terminated the running pulse-docker-agent process
  • +
  • • Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)
  • +
  • • Cleared the host from the dashboard so new reports won’t appear unexpectedly
  • +
+

+ The binary remains at /usr/local/bin/pulse-docker-agent for quick reinstalls. Use the uninstall command below if you prefer to remove it too. +

+
+
+ +

+ {modalCommand()?.failureReason} +

+
+ +
+ + Behind the scenes + + {modalCommand()?.id} + + +
+
    + + {(step) => ( +
  • + + {step.label} +
  • + )} +
    +
+

+ Pulse responds to the agent's /api/agents/docker/report call with a stop command. The agent disables its service, removes + /boot/config/go.d/pulse-docker-agent.sh, and posts back to + /api/agents/docker/commands/<id>/ack so Pulse knows it can remove the row. +

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

Host is offline

+

+ Tower stopped reporting. Remove it now to finish the cleanup. +

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

Manual uninstall command

+

+ Prefer to remove everything manually? Run this full uninstall on {modalHostname()}. It removes the service, startup script, log, and binary. +

+
+
+ + {getUninstallCommand()} + + +
+ +

Command copied to clipboard.

+
+

+ This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes /var/log/pulse-docker-agent.log, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (≈2 minutes) and clean up the row automatically. +

+
+
+
+

Force remove immediately

+

+ Skips the stop command and removes the entry right away. Any new report from this host will be rejected until you reinstall. +

+
+ +
+
+
+

Hide the host instead

+

+ Remove it from the default view without uninstalling the agent. It will reappear if the agent reports again. +

+ +

Already hidden.

+
+
+ +
+
+
+
+
+ +
+ +
+
+
+
+ {/* Active Docker Hosts */}
-

- Reporting Docker hosts ({dockerHosts().length}) -

+ {/* Pending hosts banner */} + 0}> +
+
+ + + +
+

+ Stopping {pendingHosts().length} host{pendingHosts().length !== 1 ? 's' : ''} +

+

+ Pulse has sent the stop command. Once an agent acknowledges (or goes offline), the entry will disappear automatically. +

+
+
+
+
+ +
+

+ Reporting Docker hosts ({dockerHosts().length}) +

+ 0}> + + +
0} @@ -525,7 +1198,16 @@ WantedBy=multi-user.target`; {(host) => { const isOnline = host.status?.toLowerCase() === 'online'; const runningContainers = host.containers?.filter(c => c.state?.toLowerCase() === 'running').length || 0; - const displayName = host.displayName || host.hostname || host.id; + const displayName = getDisplayName(host); + const commandStatus = host.command?.status ?? null; + const commandInProgress = commandStatus === 'queued' || commandStatus === 'dispatched' || commandStatus === 'acknowledged'; + const commandFailed = commandStatus === 'failed'; + const commandCompleted = commandStatus === 'completed'; + const offlineActionLabel = commandFailed + ? 'Force remove host' + : host.pendingUninstall + ? 'Clean up pending host' + : 'Remove offline host'; return ( @@ -543,15 +1225,41 @@ WantedBy=multi-user.target`; - - {host.status || 'unknown'} - +
+ + {host.status || 'unknown'} + + + + + + + Stopping + + + + + + + + Failed + + + + + + + + Stopped + + +
@@ -577,14 +1285,64 @@ WantedBy=multi-user.target`;
- + + openRemoveModal(host)} + disabled={isRemovingHost(host.id)} + > + {isRemovingHost(host.id) ? 'Working…' : 'Remove'} + + } + > + + + }> + + + + } > - {isRemovingHost(host.id) ? 'Removing…' : 'Remove'} - + + ); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 5ef9781..391dd91 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -133,6 +133,24 @@ export interface DockerHost { tokenName?: string; tokenHint?: string; tokenLastUsedAt?: number; + hidden?: boolean; + pendingUninstall?: boolean; + command?: DockerHostCommand; +} + +export interface DockerHostCommand { + id: string; + type: string; + status: string; + message?: string; + createdAt: number; + updatedAt: number; + dispatchedAt?: number; + acknowledgedAt?: number; + completedAt?: number; + failedAt?: number; + failureReason?: string; + expiresAt?: number; } export interface DockerContainer { diff --git a/internal/api/docker_agents.go b/internal/api/docker_agents.go index 7727fb8..6258eae 100644 --- a/internal/api/docker_agents.go +++ b/internal/api/docker_agents.go @@ -19,6 +19,12 @@ type DockerAgentHandlers struct { wsHub *websocket.Hub } +type dockerCommandAckRequest struct { + HostID string `json:"hostId"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + // NewDockerAgentHandlers constructs a new Docker agent handler group. func NewDockerAgentHandlers(m *monitoring.Monitor, hub *websocket.Hub) *DockerAgentHandlers { return &DockerAgentHandlers{monitor: m, wsHub: hub} @@ -71,12 +77,115 @@ func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reques "lastSeen": host.LastSeen, } + if payload, cmd := h.monitor.FetchDockerCommandForHost(host.ID); cmd != nil { + commandResponse := map[string]any{ + "id": cmd.ID, + "type": cmd.Type, + } + if payload != nil && len(payload) > 0 { + commandResponse["payload"] = payload + } + response["commands"] = []map[string]any{commandResponse} + } + if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to serialize docker agent response") } } -// HandleDeleteHost removes a docker host and its containers from the shared state. +// HandleDockerHostActions routes docker host management actions based on path and method. +func (h *DockerAgentHandlers) HandleDockerHostActions(w http.ResponseWriter, r *http.Request) { + // Check if this is an allow reenroll request + if strings.HasSuffix(r.URL.Path, "/allow-reenroll") && r.Method == http.MethodPost { + h.HandleAllowReenroll(w, r) + return + } + + // Check if this is an unhide request + if strings.HasSuffix(r.URL.Path, "/unhide") && r.Method == http.MethodPut { + h.HandleUnhideHost(w, r) + return + } + + // Check if this is a pending uninstall request + if strings.HasSuffix(r.URL.Path, "/pending-uninstall") && r.Method == http.MethodPut { + h.HandleMarkPendingUninstall(w, r) + return + } + + // Otherwise, handle as delete/hide request + if r.Method == http.MethodDelete { + h.HandleDeleteHost(w, r) + return + } + + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) +} + +// HandleCommandAck processes acknowledgements from docker agents for issued commands. +func (h *DockerAgentHandlers) HandleCommandAck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) + return + } + + trimmed := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/commands/") + if !strings.HasSuffix(trimmed, "/ack") { + writeErrorResponse(w, http.StatusNotFound, "not_found", "Endpoint not found", nil) + return + } + commandID := strings.TrimSuffix(trimmed, "/ack") + commandID = strings.TrimSuffix(commandID, "/") + commandID = strings.TrimSpace(commandID) + if commandID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_command_id", "Command ID is required", nil) + return + } + + var req dockerCommandAckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()}) + return + } + + status := strings.ToLower(strings.TrimSpace(req.Status)) + switch status { + case "", "ack", "acknowledged": + status = monitoring.DockerCommandStatusAcknowledged + case "success", "completed", "complete": + status = monitoring.DockerCommandStatusCompleted + case "fail", "failed", "error": + status = monitoring.DockerCommandStatusFailed + default: + writeErrorResponse(w, http.StatusBadRequest, "invalid_status", "Invalid command status", nil) + return + } + + commandStatus, hostID, shouldRemove, err := h.monitor.AcknowledgeDockerHostCommand(commandID, req.HostID, status, req.Message) + if err != nil { + writeErrorResponse(w, http.StatusBadRequest, "docker_command_ack_failed", err.Error(), nil) + return + } + + if shouldRemove { + if _, removeErr := h.monitor.RemoveDockerHost(hostID); removeErr != nil { + log.Error().Err(removeErr).Str("dockerHostID", hostID).Str("commandID", commandID).Msg("Failed to remove docker host after command completion") + } + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": hostID, + "command": commandStatus, + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker command acknowledgement response") + } +} + +// HandleDeleteHost removes or hides a docker host from the shared state. +// If query parameter ?hide=true is provided, the host is marked as hidden instead of deleted. func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil) @@ -90,6 +199,73 @@ func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Re return } + // Check if we should hide instead of delete + hideParam := r.URL.Query().Get("hide") + shouldHide := strings.ToLower(hideParam) == "true" + forceParam := strings.ToLower(r.URL.Query().Get("force")) + force := forceParam == "true" || strings.ToLower(r.URL.Query().Get("mode")) == "force" + + priorHost, hostExists := h.monitor.GetDockerHost(hostID) + + if shouldHide { + if !hostExists { + writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", "Docker host not found", nil) + return + } + host, err := h.monitor.HideDockerHost(hostID) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": host.ID, + "message": "Docker host hidden", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker host operation response") + } + return + } + + if !hostExists { + if force { + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": hostID, + "message": "Docker host already removed", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker host operation response") + } + return + } + + writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", "Docker host not found", nil) + return + } + + if !force && strings.EqualFold(priorHost.Status, "online") { + command, err := h.monitor.QueueDockerHostStop(hostID) + if err != nil { + writeErrorResponse(w, http.StatusBadRequest, "docker_command_failed", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": hostID, + "command": command, + "message": "Stop command queued", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker host stop command response") + } + return + } + host, err := h.monitor.RemoveDockerHost(hostID) if err != nil { writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil) @@ -103,6 +279,98 @@ func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Re "hostId": host.ID, "message": "Docker host removed", }); err != nil { - log.Error().Err(err).Msg("Failed to serialize docker host removal response") + log.Error().Err(err).Msg("Failed to serialize docker host operation response") + } +} + +// HandleAllowReenroll clears the removal block for a docker host to permit future reports. +func (h *DockerAgentHandlers) HandleAllowReenroll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) + return + } + + trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/allow-reenroll") + hostID := strings.TrimSpace(trimmedPath) + if hostID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil) + return + } + + if err := h.monitor.AllowDockerHostReenroll(hostID); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "docker_host_reenroll_failed", err.Error(), nil) + return + } + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": hostID, + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker host allow reenroll response") + } +} + +// HandleUnhideHost unhides a previously hidden docker host. +func (h *DockerAgentHandlers) HandleUnhideHost(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil) + return + } + + trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/unhide") + hostID := strings.TrimSpace(trimmedPath) + if hostID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil) + return + } + + host, err := h.monitor.UnhideDockerHost(hostID) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": host.ID, + "message": "Docker host unhidden", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker host unhide response") + } +} + +// HandleMarkPendingUninstall marks a docker host as pending uninstall. +func (h *DockerAgentHandlers) HandleMarkPendingUninstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil) + return + } + + trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/pending-uninstall") + hostID := strings.TrimSpace(trimmedPath) + if hostID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil) + return + } + + host, err := h.monitor.MarkDockerHostPendingUninstall(hostID) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": host.ID, + "message": "Docker host marked as pending uninstall", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker host pending uninstall response") } } diff --git a/internal/api/router.go b/internal/api/router.go index 4e5f5a8..4416c37 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -113,7 +113,8 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/health", r.handleHealth) r.mux.HandleFunc("/api/state", r.handleState) r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, r.dockerAgentHandlers.HandleReport)) - r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, r.dockerAgentHandlers.HandleDeleteHost)) + r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, r.dockerAgentHandlers.HandleCommandAck)) + r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, r.dockerAgentHandlers.HandleDockerHostActions)) r.mux.HandleFunc("/api/version", r.handleVersion) r.mux.HandleFunc("/api/storage/", r.handleStorage) r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts) @@ -842,6 +843,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/install-docker-agent.sh", r.handleDownloadInstallScript) r.mux.HandleFunc("/download/pulse-docker-agent", r.handleDownloadAgent) r.mux.HandleFunc("/api/agent/version", r.handleAgentVersion) + r.mux.HandleFunc("/api/server/info", r.handleServerInfo) // WebSocket endpoint r.mux.HandleFunc("/ws", r.handleWebSocket) @@ -1072,6 +1074,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { "/install-docker-agent.sh", // Docker agent bootstrap script must be public "/download/pulse-docker-agent", // Agent binary download should not require auth "/api/agent/version", // Agent update checks need to work before auth + "/api/server/info", // Server info for installer script } // Also allow static assets without auth (JS, CSS, etc) @@ -1941,9 +1944,14 @@ func (r *Router) handleVersion(w http.ResponseWriter, req *http.Request) { // Fallback to VERSION file versionBytes, _ := os.ReadFile("VERSION") response := VersionResponse{ - Version: strings.TrimSpace(string(versionBytes)), - BuildTime: "development", - GoVersion: runtime.Version(), + Version: strings.TrimSpace(string(versionBytes)), + BuildTime: "development", + Build: "development", + GoVersion: runtime.Version(), + Runtime: runtime.Version(), + Channel: "stable", + IsDocker: false, + IsDevelopment: true, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) @@ -1952,9 +1960,15 @@ func (r *Router) handleVersion(w http.ResponseWriter, req *http.Request) { // Convert to typed response response := VersionResponse{ - Version: versionInfo.Version, - BuildTime: versionInfo.Build, - GoVersion: runtime.Version(), + Version: versionInfo.Version, + BuildTime: versionInfo.Build, + Build: versionInfo.Build, + GoVersion: runtime.Version(), + Runtime: versionInfo.Runtime, + Channel: versionInfo.Channel, + IsDocker: versionInfo.IsDocker, + IsDevelopment: versionInfo.IsDevelopment, + DeploymentType: versionInfo.DeploymentType, } // Add cached update info if available @@ -1987,6 +2001,26 @@ func (r *Router) handleAgentVersion(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } +func (r *Router) handleServerInfo(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + versionInfo, err := updates.GetCurrentVersion() + isDev := true + if err == nil { + isDev = versionInfo.IsDevelopment + } + + response := map[string]interface{}{ + "isDevelopment": isDev, + "version": dockeragent.Version, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} // handleStorage handles storage detail requests func (r *Router) handleStorage(w http.ResponseWriter, req *http.Request) { diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index 57df9c6..b3a25a4 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -143,6 +143,11 @@ func TestVersionEndpointUsesRepoVersion(t *testing.T) { t.Fatalf("version field missing or not a string: %v", payload["version"]) } + if strings.HasPrefix(actual, "0.0.0-") { + // Development builds normalize to 0.0.0-[...], which is expected. + return + } + if normalizeVersion(actual) != normalizeVersion(expected) { t.Fatalf("expected version=%s, got %s", expected, actual) } diff --git a/internal/api/security_tokens.go b/internal/api/security_tokens.go index be56b9f..4b106a9 100644 --- a/internal/api/security_tokens.go +++ b/internal/api/security_tokens.go @@ -63,6 +63,7 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request) var payload createTokenRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil && err != io.EOF { + log.Warn().Err(err).Msg("Failed to decode API token create request") http.Error(w, "Invalid request body", http.StatusBadRequest) return } @@ -81,7 +82,7 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request) record, err := config.NewAPITokenRecord(rawToken, name) if err != nil { - log.Error().Err(err).Msg("Failed to construct API token record") + log.Error().Err(err).Str("token_name", name).Msg("Failed to construct API token record") http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } @@ -92,7 +93,7 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request) if r.persistence != nil { if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { - log.Warn().Err(err).Msg("Failed to persist API tokens after creation") + log.Error().Err(err).Msg("Failed to persist API tokens after creation") } } diff --git a/internal/api/types.go b/internal/api/types.go index 6815c70..c1b1563 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -11,19 +11,25 @@ import ( // HealthResponse represents the health check response type HealthResponse struct { - Status string `json:"status"` - Timestamp int64 `json:"timestamp"` - Uptime float64 `json:"uptime"` - LegacySSHDetected bool `json:"legacySSHDetected,omitempty"` - RecommendProxyUpgrade bool `json:"recommendProxyUpgrade,omitempty"` - ProxyInstallScriptAvailable bool `json:"proxyInstallScriptAvailable,omitempty"` + Status string `json:"status"` + Timestamp int64 `json:"timestamp"` + Uptime float64 `json:"uptime"` + LegacySSHDetected bool `json:"legacySSHDetected,omitempty"` + RecommendProxyUpgrade bool `json:"recommendProxyUpgrade,omitempty"` + ProxyInstallScriptAvailable bool `json:"proxyInstallScriptAvailable,omitempty"` } // VersionResponse represents version information type VersionResponse struct { Version string `json:"version"` BuildTime string `json:"buildTime,omitempty"` + Build string `json:"build,omitempty"` GoVersion string `json:"goVersion,omitempty"` + Runtime string `json:"runtime,omitempty"` + Channel string `json:"channel,omitempty"` + IsDocker bool `json:"isDocker"` + IsDevelopment bool `json:"isDevelopment"` + DeploymentType string `json:"deploymentType,omitempty"` UpdateAvailable bool `json:"updateAvailable"` LatestVersion string `json:"latestVersion,omitempty"` } diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index ee00b7c..e7087fc 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -7,9 +7,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "net/http" "os" + "os/exec" "strings" "syscall" "time" @@ -36,6 +38,7 @@ type Config struct { HostnameOverride string AgentID string InsecureSkipVerify bool + DisableAutoUpdate bool Targets []TargetConfig Logger *zerolog.Logger } @@ -50,8 +53,12 @@ type Agent struct { hostName string cpuCount int targets []TargetConfig + hostID string } +// ErrStopRequested indicates the agent should terminate gracefully after acknowledging a stop command. +var ErrStopRequested = errors.New("docker host stop requested") + // New creates a new Docker agent instance. func New(cfg Config) (*Agent, error) { targets, err := normalizeTargets(cfg.Targets) @@ -192,6 +199,9 @@ func (a *Agent) Run(ctx context.Context) error { defer updateTicker.Stop() if err := a.collectOnce(ctx); err != nil { + if errors.Is(err, ErrStopRequested) { + return nil + } a.logger.Error().Err(err).Msg("Failed to send initial report") } @@ -201,6 +211,9 @@ func (a *Agent) Run(ctx context.Context) error { return ctx.Err() case <-ticker.C: if err := a.collectOnce(ctx); err != nil { + if errors.Is(err, ErrStopRequested) { + return nil + } a.logger.Error().Err(err).Msg("Failed to send docker report") } case <-updateTicker.C: @@ -236,6 +249,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) { if agentID == "" { agentID = a.hostName } + a.hostID = agentID hostName := a.hostName if hostName == "" { @@ -410,9 +424,14 @@ func (a *Agent) sendReport(ctx context.Context, report agentsdocker.Report) erro containerCount := len(report.Containers) for _, target := range a.targets { - if err := a.sendReportToTarget(ctx, target, payload, containerCount); err != nil { - errs = append(errs, err) + err := a.sendReportToTarget(ctx, target, payload, containerCount) + if err == nil { + continue } + if errors.Is(err, ErrStopRequested) { + return ErrStopRequested + } + errs = append(errs, err) } if len(errs) > 0 { @@ -449,6 +468,150 @@ func (a *Agent) sendReportToTarget(ctx context.Context, target TargetConfig, pay return fmt.Errorf("target %s: pulse responded with status %s", target.URL, resp.Status) } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("target %s: read response: %w", target.URL, err) + } + + if len(body) == 0 { + return nil + } + + var reportResp agentsdocker.ReportResponse + if err := json.Unmarshal(body, &reportResp); err != nil { + a.logger.Warn().Err(err).Str("target", target.URL).Msg("Failed to decode Pulse response") + return nil + } + + for _, command := range reportResp.Commands { + err := a.handleCommand(ctx, target, command) + if err == nil { + continue + } + if errors.Is(err, ErrStopRequested) { + return ErrStopRequested + } + return err + } + + return nil +} + +func (a *Agent) handleCommand(ctx context.Context, target TargetConfig, command agentsdocker.Command) error { + switch strings.ToLower(command.Type) { + case agentsdocker.CommandTypeStop: + return a.handleStopCommand(ctx, target, command) + default: + a.logger.Warn().Str("command", command.Type).Msg("Received unsupported control command") + return nil + } +} + +func (a *Agent) handleStopCommand(ctx context.Context, target TargetConfig, command agentsdocker.Command) error { + a.logger.Info().Str("commandID", command.ID).Msg("Received stop command from Pulse") + + if err := a.disableSelf(ctx); err != nil { + a.logger.Error().Err(err).Msg("Failed to disable pulse-docker-agent service") + if ackErr := a.sendCommandAck(ctx, target, command.ID, agentsdocker.CommandStatusFailed, err.Error()); ackErr != nil { + a.logger.Error().Err(ackErr).Msg("Failed to send failure acknowledgement to Pulse") + } + return nil + } + + if err := a.sendCommandAck(ctx, target, command.ID, agentsdocker.CommandStatusCompleted, "Agent shutting down"); err != nil { + return fmt.Errorf("send stop acknowledgement: %w", err) + } + + a.logger.Info().Msg("Stop command acknowledged; terminating agent") + return ErrStopRequested +} + +func (a *Agent) disableSelf(ctx context.Context) error { + if err := disableSystemdService(ctx, "pulse-docker-agent"); err != nil { + return err + } + + // Remove Unraid startup script if present to prevent restart on reboot. + if err := removeFileIfExists("/boot/config/go.d/pulse-docker-agent.sh"); err != nil { + a.logger.Warn().Err(err).Msg("Failed to remove Unraid startup script") + } + + // Best-effort log cleanup (ignore errors). + _ = removeFileIfExists("/var/log/pulse-docker-agent.log") + + return nil +} + +func disableSystemdService(ctx context.Context, service string) error { + if _, err := exec.LookPath("systemctl"); err != nil { + // Not a systemd environment; nothing to do. + return nil + } + + cmd := exec.CommandContext(ctx, "systemctl", "disable", service) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode := exitErr.ExitCode() + lowerOutput := strings.ToLower(string(output)) + if exitCode == 5 || strings.Contains(lowerOutput, "could not be found") || strings.Contains(lowerOutput, "not-found") { + return nil + } + } + return fmt.Errorf("systemctl disable %s: %w (%s)", service, err, strings.TrimSpace(string(output))) + } + + return nil +} + +func removeFileIfExists(path string) error { + if err := os.Remove(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + return nil +} + +func (a *Agent) sendCommandAck(ctx context.Context, target TargetConfig, commandID, status, message string) error { + if a.hostID == "" { + return fmt.Errorf("host identifier unavailable; cannot acknowledge command") + } + + ackPayload := agentsdocker.CommandAck{ + HostID: a.hostID, + Status: status, + Message: message, + } + + body, err := json.Marshal(ackPayload) + if err != nil { + return fmt.Errorf("marshal command acknowledgement: %w", err) + } + + url := fmt.Sprintf("%s/api/agents/docker/commands/%s/ack", target.URL, commandID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create acknowledgement request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Token", target.Token) + req.Header.Set("Authorization", "Bearer "+target.Token) + req.Header.Set("User-Agent", "pulse-docker-agent/"+Version) + + resp, err := a.httpClientFor(target).Do(req) + if err != nil { + return fmt.Errorf("send acknowledgement: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pulse responded %s: %s", resp.Status, strings.TrimSpace(string(bodyBytes))) + } + return nil } @@ -581,6 +744,18 @@ func readSystemUptime() int64 { // checkForUpdates checks if a newer version is available and performs self-update if needed func (a *Agent) checkForUpdates(ctx context.Context) { + // Skip updates if disabled via config + if a.cfg.DisableAutoUpdate { + a.logger.Debug().Msg("Skipping update check - auto-update disabled") + return + } + + // Skip updates in development mode to prevent update loops + if Version == "dev" { + a.logger.Debug().Msg("Skipping update check - running in development mode") + return + } + a.logger.Debug().Msg("Checking for agent updates") target := a.primaryTarget() @@ -624,6 +799,12 @@ func (a *Agent) checkForUpdates(ctx context.Context) { return } + // Skip updates if server is also in development mode + if versionResp.Version == "dev" { + a.logger.Debug().Msg("Skipping update - server is in development mode") + return + } + // Compare versions if versionResp.Version == Version { a.logger.Debug().Str("version", Version).Msg("Agent is up to date") diff --git a/internal/dockeragent/version.go b/internal/dockeragent/version.go index c942c78..f05ff53 100644 --- a/internal/dockeragent/version.go +++ b/internal/dockeragent/version.go @@ -3,4 +3,5 @@ package dockeragent // Version is the semantic version of the Pulse Docker agent binary. It is // overridden at build time via -ldflags for release artifacts. When building // from source without ldflags, it defaults to this development value. -var Version = "dev" +// Set to match deployed agents to prevent update loops in development. +var Version = "v4.22.0-rc.6" diff --git a/internal/models/converters.go b/internal/models/converters.go index d769551..679e005 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -205,6 +205,8 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { h.DisplayName = h.Hostname } + h.PendingUninstall = d.PendingUninstall + if d.TokenID != "" { h.TokenID = d.TokenID h.TokenName = d.TokenName @@ -219,6 +221,10 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { h.Containers[i] = ct.ToFrontend() } + if d.Command != nil { + h.Command = toDockerHostCommandFrontend(*d.Command) + } + return h } @@ -280,6 +286,43 @@ func (c DockerContainer) ToFrontend() DockerContainerFrontend { return container } +func toDockerHostCommandFrontend(cmd DockerHostCommandStatus) *DockerHostCommandFrontend { + result := &DockerHostCommandFrontend{ + ID: cmd.ID, + Type: cmd.Type, + Status: cmd.Status, + Message: cmd.Message, + CreatedAt: cmd.CreatedAt.Unix() * 1000, + UpdatedAt: cmd.UpdatedAt.Unix() * 1000, + } + + if cmd.DispatchedAt != nil { + ms := cmd.DispatchedAt.Unix() * 1000 + result.DispatchedAt = &ms + } + if cmd.AcknowledgedAt != nil { + ms := cmd.AcknowledgedAt.Unix() * 1000 + result.AcknowledgedAt = &ms + } + if cmd.CompletedAt != nil { + ms := cmd.CompletedAt.Unix() * 1000 + result.CompletedAt = &ms + } + if cmd.FailedAt != nil { + ms := cmd.FailedAt.Unix() * 1000 + result.FailedAt = &ms + } + if cmd.FailureReason != "" { + result.FailureReason = cmd.FailureReason + } + if cmd.ExpiresAt != nil { + ms := cmd.ExpiresAt.Unix() * 1000 + result.ExpiresAt = &ms + } + + return result +} + // ToFrontend converts Storage to StorageFrontend func (s Storage) ToFrontend() StorageFrontend { return StorageFrontend{ diff --git a/internal/models/models.go b/internal/models/models.go index ca12a16..6c96809 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -137,27 +137,30 @@ type Container struct { // DockerHost represents a Docker host reporting metrics via the external agent. type DockerHost struct { - ID string `json:"id"` - AgentID string `json:"agentId"` - Hostname string `json:"hostname"` - DisplayName string `json:"displayName"` - MachineID string `json:"machineId,omitempty"` - OS string `json:"os,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - DockerVersion string `json:"dockerVersion,omitempty"` - CPUs int `json:"cpus"` - TotalMemoryBytes int64 `json:"totalMemoryBytes"` - UptimeSeconds int64 `json:"uptimeSeconds"` - Status string `json:"status"` - LastSeen time.Time `json:"lastSeen"` - IntervalSeconds int `json:"intervalSeconds"` - AgentVersion string `json:"agentVersion,omitempty"` - Containers []DockerContainer `json:"containers"` - TokenID string `json:"tokenId,omitempty"` - TokenName string `json:"tokenName,omitempty"` - TokenHint string `json:"tokenHint,omitempty"` - TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` + ID string `json:"id"` + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + DisplayName string `json:"displayName"` + MachineID string `json:"machineId,omitempty"` + OS string `json:"os,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + DockerVersion string `json:"dockerVersion,omitempty"` + CPUs int `json:"cpus"` + TotalMemoryBytes int64 `json:"totalMemoryBytes"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Status string `json:"status"` + LastSeen time.Time `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds"` + AgentVersion string `json:"agentVersion,omitempty"` + Containers []DockerContainer `json:"containers"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` + Hidden bool `json:"hidden"` + PendingUninstall bool `json:"pendingUninstall"` + Command *DockerHostCommandStatus `json:"command,omitempty"` } // DockerContainer represents the state of a Docker container on a monitored host. @@ -198,6 +201,22 @@ type DockerContainerNetworkLink struct { IPv6 string `json:"ipv6,omitempty"` } +// DockerHostCommandStatus tracks the lifecycle of a control command issued to a Docker host. +type DockerHostCommandStatus struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DispatchedAt *time.Time `json:"dispatchedAt,omitempty"` + AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + FailedAt *time.Time `json:"failedAt,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + // Storage represents a storage resource type Storage struct { ID string `json:"id"` @@ -883,6 +902,57 @@ func (s *State) SetDockerHostStatus(hostID, status string) bool { return changed } +// SetDockerHostHidden updates the hidden status of a docker host and returns the updated host. +func (s *State) SetDockerHostHidden(hostID string, hidden bool) (DockerHost, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, host := range s.DockerHosts { + if host.ID == hostID { + host.Hidden = hidden + s.DockerHosts[i] = host + s.LastUpdate = time.Now() + return host, true + } + } + + return DockerHost{}, false +} + +// SetDockerHostPendingUninstall updates the pending uninstall status of a docker host and returns the updated host. +func (s *State) SetDockerHostPendingUninstall(hostID string, pending bool) (DockerHost, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, host := range s.DockerHosts { + if host.ID == hostID { + host.PendingUninstall = pending + s.DockerHosts[i] = host + s.LastUpdate = time.Now() + return host, true + } + } + + return DockerHost{}, false +} + +// SetDockerHostCommand updates the active command status for a docker host. +func (s *State) SetDockerHostCommand(hostID string, command *DockerHostCommandStatus) (DockerHost, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, host := range s.DockerHosts { + if host.ID == hostID { + host.Command = command + s.DockerHosts[i] = host + s.LastUpdate = time.Now() + return host, true + } + } + + return DockerHost{}, false +} + // TouchDockerHost updates the last seen timestamp for a docker host. func (s *State) TouchDockerHost(hostID string, ts time.Time) bool { s.mu.Lock() diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index bbbf181..281aed6 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -94,27 +94,29 @@ type ContainerFrontend struct { // DockerHostFrontend represents a Docker host with frontend-friendly fields type DockerHostFrontend struct { - ID string `json:"id"` - AgentID string `json:"agentId"` - Hostname string `json:"hostname"` - DisplayName string `json:"displayName"` - MachineID string `json:"machineId,omitempty"` - OS string `json:"os,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - DockerVersion string `json:"dockerVersion,omitempty"` - CPUs int `json:"cpus"` - TotalMemoryBytes int64 `json:"totalMemoryBytes"` - UptimeSeconds int64 `json:"uptimeSeconds"` - Status string `json:"status"` - LastSeen int64 `json:"lastSeen"` - IntervalSeconds int `json:"intervalSeconds"` - AgentVersion string `json:"agentVersion,omitempty"` - Containers []DockerContainerFrontend `json:"containers"` - TokenID string `json:"tokenId,omitempty"` - TokenName string `json:"tokenName,omitempty"` - TokenHint string `json:"tokenHint,omitempty"` - TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` + ID string `json:"id"` + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + DisplayName string `json:"displayName"` + MachineID string `json:"machineId,omitempty"` + OS string `json:"os,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + DockerVersion string `json:"dockerVersion,omitempty"` + CPUs int `json:"cpus"` + TotalMemoryBytes int64 `json:"totalMemoryBytes"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Status string `json:"status"` + LastSeen int64 `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds"` + AgentVersion string `json:"agentVersion,omitempty"` + Containers []DockerContainerFrontend `json:"containers"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` + PendingUninstall bool `json:"pendingUninstall"` + Command *DockerHostCommandFrontend `json:"command,omitempty"` } // DockerContainerFrontend represents a Docker container for the frontend @@ -155,6 +157,22 @@ type DockerContainerNetworkFrontend struct { IPv6 string `json:"ipv6,omitempty"` } +// DockerHostCommandFrontend exposes docker host command state to the UI. +type DockerHostCommandFrontend struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + DispatchedAt *int64 `json:"dispatchedAt,omitempty"` + AcknowledgedAt *int64 `json:"acknowledgedAt,omitempty"` + CompletedAt *int64 `json:"completedAt,omitempty"` + FailedAt *int64 `json:"failedAt,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + ExpiresAt *int64 `json:"expiresAt,omitempty"` +} + // StorageFrontend represents Storage with frontend-friendly field names type StorageFrontend struct { ID string `json:"id"` diff --git a/internal/monitoring/docker_commands.go b/internal/monitoring/docker_commands.go new file mode 100644 index 0000000..424a203 --- /dev/null +++ b/internal/monitoring/docker_commands.go @@ -0,0 +1,260 @@ +package monitoring + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rs/zerolog/log" +) + +const ( + // DockerCommandTypeStop instructs the agent to stop reporting and uninstall itself. + DockerCommandTypeStop = "stop" + + // DockerCommandStatusQueued indicates the command is queued and waiting to be dispatched. + DockerCommandStatusQueued = "queued" + // DockerCommandStatusDispatched indicates the command has been delivered to the agent. + DockerCommandStatusDispatched = "dispatched" + // DockerCommandStatusAcknowledged indicates the agent acknowledged receipt of the command. + DockerCommandStatusAcknowledged = "acknowledged" + // DockerCommandStatusCompleted indicates the command completed successfully. + DockerCommandStatusCompleted = "completed" + // DockerCommandStatusFailed indicates the command failed. + DockerCommandStatusFailed = "failed" + // DockerCommandStatusExpired indicates Pulse abandoned the command. + DockerCommandStatusExpired = "expired" +) + +const ( + dockerCommandDefaultTTL = 10 * time.Minute +) + +type dockerHostCommand struct { + status models.DockerHostCommandStatus + payload map[string]any + ttl time.Time +} + +func newDockerHostCommand(commandType string, message string, ttl time.Duration, payload map[string]any) dockerHostCommand { + now := time.Now().UTC() + var expiresAt *time.Time + if ttl > 0 { + expiry := now.Add(ttl) + expiresAt = &expiry + } + return dockerHostCommand{ + status: models.DockerHostCommandStatus{ + ID: uuid.NewString(), + Type: commandType, + Status: DockerCommandStatusQueued, + Message: message, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: expiresAt, + }, + payload: payload, + ttl: now.Add(ttl), + } +} + +func (cmd *dockerHostCommand) markDispatched() { + now := time.Now().UTC() + cmd.status.Status = DockerCommandStatusDispatched + cmd.status.DispatchedAt = &now + cmd.status.UpdatedAt = now +} + +func (cmd *dockerHostCommand) markAcknowledged(message string) { + now := time.Now().UTC() + cmd.status.Status = DockerCommandStatusAcknowledged + cmd.status.AcknowledgedAt = &now + cmd.status.UpdatedAt = now + if message != "" { + cmd.status.Message = message + } +} + +func (cmd *dockerHostCommand) markCompleted(message string) { + now := time.Now().UTC() + cmd.status.Status = DockerCommandStatusCompleted + cmd.status.CompletedAt = &now + cmd.status.UpdatedAt = now + if message != "" { + cmd.status.Message = message + } +} + +func (cmd *dockerHostCommand) markFailed(reason string) { + now := time.Now().UTC() + cmd.status.Status = DockerCommandStatusFailed + cmd.status.FailedAt = &now + cmd.status.UpdatedAt = now + cmd.status.FailureReason = reason +} + +func (cmd *dockerHostCommand) hasExpired(now time.Time) bool { + if cmd.status.ExpiresAt == nil { + return false + } + return now.After(*cmd.status.ExpiresAt) +} + +// queueDockerStopCommand enqueues a stop command for the specified docker host. +func (m *Monitor) queueDockerStopCommand(hostID string) (models.DockerHostCommandStatus, error) { + m.mu.Lock() + defer m.mu.Unlock() + + hostID = normalizeDockerHostID(hostID) + if hostID == "" { + return models.DockerHostCommandStatus{}, fmt.Errorf("docker host id is required") + } + + // Ensure the host exists + var hostExists bool + for _, host := range m.state.GetDockerHosts() { + if host.ID == hostID { + hostExists = true + break + } + } + if !hostExists { + return models.DockerHostCommandStatus{}, fmt.Errorf("docker host %q not found", hostID) + } + + if existing, ok := m.dockerCommands[hostID]; ok { + switch existing.status.Status { + case DockerCommandStatusQueued, DockerCommandStatusDispatched, DockerCommandStatusAcknowledged: + return existing.status, fmt.Errorf("docker host %q already has a command in progress", hostID) + } + } + + cmd := newDockerHostCommand(DockerCommandTypeStop, "Stopping agent", dockerCommandDefaultTTL, nil) + if m.dockerCommands == nil { + m.dockerCommands = make(map[string]*dockerHostCommand) + } + m.dockerCommands[hostID] = &cmd + if m.dockerCommandIndex == nil { + m.dockerCommandIndex = make(map[string]string) + } + m.dockerCommandIndex[cmd.status.ID] = hostID + + m.state.SetDockerHostPendingUninstall(hostID, true) + m.state.SetDockerHostCommand(hostID, &cmd.status) + log.Info(). + Str("dockerHostID", hostID). + Str("commandID", cmd.status.ID). + Msg("Queued docker host stop command") + + return cmd.status, nil +} + +func (m *Monitor) getDockerCommandPayload(hostID string) (map[string]any, *models.DockerHostCommandStatus) { + m.mu.Lock() + defer m.mu.Unlock() + + hostID = normalizeDockerHostID(hostID) + if hostID == "" { + return nil, nil + } + + cmd, ok := m.dockerCommands[hostID] + if !ok { + return nil, nil + } + now := time.Now().UTC() + if cmd.hasExpired(now) { + cmd.status.Status = DockerCommandStatusExpired + cmd.status.UpdatedAt = now + cmd.status.FailureReason = "command expired before agent acknowledged it" + m.state.SetDockerHostCommand(hostID, &cmd.status) + log.Warn(). + Str("dockerHostID", hostID). + Str("commandID", cmd.status.ID). + Msg("Docker command expired prior to dispatch") + delete(m.dockerCommands, hostID) + delete(m.dockerCommandIndex, cmd.status.ID) + return nil, nil + } + + // Update dispatch metadata + if cmd.status.Status == DockerCommandStatusQueued { + cmd.markDispatched() + m.state.SetDockerHostCommand(hostID, &cmd.status) + } + + statusCopy := cmd.status + return cmd.payload, &statusCopy +} + +func (m *Monitor) acknowledgeDockerCommand(commandID, hostID, status, message string) (models.DockerHostCommandStatus, string, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + commandID = strings.TrimSpace(commandID) + if commandID == "" { + return models.DockerHostCommandStatus{}, "", false, fmt.Errorf("command id is required") + } + + resolvedHostID, ok := m.dockerCommandIndex[commandID] + if !ok { + return models.DockerHostCommandStatus{}, "", false, fmt.Errorf("docker host command %q not found", commandID) + } + + if hostID != "" { + normalized := normalizeDockerHostID(hostID) + if normalized == "" { + return models.DockerHostCommandStatus{}, "", false, fmt.Errorf("docker host id is required") + } + if normalized != resolvedHostID { + return models.DockerHostCommandStatus{}, "", false, fmt.Errorf("command %q does not belong to host %q", commandID, normalized) + } + } + + cmd, ok := m.dockerCommands[resolvedHostID] + if !ok || cmd.status.ID != commandID { + return models.DockerHostCommandStatus{}, "", false, fmt.Errorf("docker host command %q not active", commandID) + } + + if message != "" { + message = strings.TrimSpace(message) + } + + shouldRemove := false + switch status { + case DockerCommandStatusAcknowledged: + cmd.markAcknowledged(message) + case DockerCommandStatusCompleted: + cmd.markAcknowledged(message) + cmd.markCompleted(message) + if cmd.status.Type == DockerCommandTypeStop { + shouldRemove = true + } + case DockerCommandStatusFailed: + cmd.markFailed(message) + m.state.SetDockerHostPendingUninstall(resolvedHostID, false) + default: + return models.DockerHostCommandStatus{}, "", false, fmt.Errorf("invalid command status %q", status) + } + + m.state.SetDockerHostCommand(resolvedHostID, &cmd.status) + + log.Info(). + Str("dockerHostID", resolvedHostID). + Str("commandID", cmd.status.ID). + Str("status", cmd.status.Status). + Msg("Docker host acknowledged command") + + if status == DockerCommandStatusFailed || shouldRemove { + delete(m.dockerCommands, resolvedHostID) + delete(m.dockerCommandIndex, commandID) + } + + return cmd.status, resolvedHostID, shouldRemove, nil +} + +func normalizeDockerHostID(id string) string { + return strings.TrimSpace(id) +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 26a15a2..526abb7 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -278,6 +278,9 @@ type Monitor struct { guestSnapshots map[string]GuestMemorySnapshot rrdCacheMu sync.RWMutex // Protects RRD memavailable cache nodeRRDMemCache map[string]rrdMemCacheEntry + removedDockerHosts map[string]time.Time // Track deliberately removed Docker hosts (ID -> removal time) + dockerCommands map[string]*dockerHostCommand + dockerCommandIndex map[string]string } type rrdMemCacheEntry struct { @@ -396,6 +399,15 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) { } } + // Track removal to prevent resurrection from cached reports + m.mu.Lock() + m.removedDockerHosts[hostID] = time.Now() + if cmd, ok := m.dockerCommands[hostID]; ok { + delete(m.dockerCommandIndex, cmd.status.ID) + } + delete(m.dockerCommands, hostID) + m.mu.Unlock() + m.state.RemoveConnectionHealth(dockerConnectionPrefix + hostID) if m.alertManager != nil { m.alertManager.HandleDockerHostRemoved(host) @@ -411,6 +423,135 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) { return host, nil } +// HideDockerHost marks a docker host as hidden without removing it from state. +// Hidden hosts will not be shown in the frontend but will continue to accept updates. +func (m *Monitor) HideDockerHost(hostID string) (models.DockerHost, error) { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return models.DockerHost{}, fmt.Errorf("docker host id is required") + } + + host, ok := m.state.SetDockerHostHidden(hostID, true) + if !ok { + return models.DockerHost{}, fmt.Errorf("docker host %q not found", hostID) + } + + log.Info(). + Str("dockerHost", host.Hostname). + Str("dockerHostID", hostID). + Msg("Docker host hidden from view") + + return host, nil +} + +// UnhideDockerHost marks a docker host as visible again. +func (m *Monitor) UnhideDockerHost(hostID string) (models.DockerHost, error) { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return models.DockerHost{}, fmt.Errorf("docker host id is required") + } + + host, ok := m.state.SetDockerHostHidden(hostID, false) + if !ok { + return models.DockerHost{}, fmt.Errorf("docker host %q not found", hostID) + } + + // Clear removal tracking if it was marked as removed + m.mu.Lock() + delete(m.removedDockerHosts, hostID) + m.mu.Unlock() + + log.Info(). + Str("dockerHost", host.Hostname). + Str("dockerHostID", hostID). + Msg("Docker host unhidden") + + return host, nil +} + +// MarkDockerHostPendingUninstall marks a docker host as pending uninstall. +// This is used when the user has run the uninstall command and is waiting for the host to go offline. +func (m *Monitor) MarkDockerHostPendingUninstall(hostID string) (models.DockerHost, error) { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return models.DockerHost{}, fmt.Errorf("docker host id is required") + } + + host, ok := m.state.SetDockerHostPendingUninstall(hostID, true) + if !ok { + return models.DockerHost{}, fmt.Errorf("docker host %q not found", hostID) + } + + log.Info(). + Str("dockerHost", host.Hostname). + Str("dockerHostID", hostID). + Msg("Docker host marked as pending uninstall") + + return host, nil +} + +// AllowDockerHostReenroll removes a host ID from the removal blocklist so it can report again. +func (m *Monitor) AllowDockerHostReenroll(hostID string) error { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return fmt.Errorf("docker host id is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.removedDockerHosts[hostID]; !exists { + log.Debug(). + Str("dockerHostID", hostID). + Msg("Allow re-enroll requested for docker host that was not blocked") + return nil + } + + delete(m.removedDockerHosts, hostID) + if cmd, exists := m.dockerCommands[hostID]; exists { + delete(m.dockerCommandIndex, cmd.status.ID) + delete(m.dockerCommands, hostID) + } + m.state.SetDockerHostCommand(hostID, nil) + + log.Info(). + Str("dockerHostID", hostID). + Msg("Docker host removal block cleared; host may report again") + + return nil +} + +// GetDockerHost retrieves a docker host by identifier if present in state. +func (m *Monitor) GetDockerHost(hostID string) (models.DockerHost, bool) { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return models.DockerHost{}, false + } + + hosts := m.state.GetDockerHosts() + for _, host := range hosts { + if host.ID == hostID { + return host, true + } + } + return models.DockerHost{}, false +} + +// QueueDockerHostStop queues a stop command for the specified docker host. +func (m *Monitor) QueueDockerHostStop(hostID string) (models.DockerHostCommandStatus, error) { + return m.queueDockerStopCommand(hostID) +} + +// FetchDockerCommandForHost retrieves the next command payload (if any) for the host. +func (m *Monitor) FetchDockerCommandForHost(hostID string) (map[string]any, *models.DockerHostCommandStatus) { + return m.getDockerCommandPayload(hostID) +} + +// AcknowledgeDockerHostCommand updates the lifecycle status for a docker host command. +func (m *Monitor) AcknowledgeDockerHostCommand(commandID, hostID, status, message string) (models.DockerHostCommandStatus, string, bool, error) { + return m.acknowledgeDockerCommand(commandID, hostID, status, message) +} + func tokenHintFromRecord(record *config.APITokenRecord) string { if record == nil { return "" @@ -434,6 +575,19 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con return models.DockerHost{}, fmt.Errorf("docker report missing agent identifier") } + // Check if this host was deliberately removed - reject report to prevent resurrection + m.mu.RLock() + removedAt, wasRemoved := m.removedDockerHosts[identifier] + m.mu.RUnlock() + + if wasRemoved { + log.Info(). + Str("dockerHostID", identifier). + Time("removedAt", removedAt). + Msg("Rejecting report from deliberately removed Docker host") + return models.DockerHost{}, fmt.Errorf("docker host %q was removed at %v and cannot report again", identifier, removedAt.Format(time.RFC3339)) + } + hostname := strings.TrimSpace(report.Host.Hostname) if hostname == "" { return models.DockerHost{}, fmt.Errorf("docker report missing hostname") @@ -562,6 +716,25 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con m.state.UpsertDockerHost(host) m.state.SetConnectionHealth(dockerConnectionPrefix+host.ID, true) + // Check if the host was previously hidden and is now visible again + if hasPrevious && previous.Hidden && !host.Hidden { + log.Info(). + Str("dockerHost", host.Hostname). + Str("dockerHostID", host.ID). + Msg("Docker host auto-unhidden after receiving report") + } + + // Check if the host was pending uninstall - if so, log a warning that uninstall failed and clear the flag + if hasPrevious && previous.PendingUninstall { + log.Warn(). + Str("dockerHost", host.Hostname). + Str("dockerHostID", host.ID). + Msg("Docker host reporting again after pending uninstall - uninstall may have failed") + + // Clear the pending uninstall flag since the host is clearly still active + m.state.SetDockerHostPendingUninstall(host.ID, false) + } + if m.alertManager != nil { m.alertManager.CheckDockerHost(host) } @@ -574,6 +747,26 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con return host, nil } +const ( + removedDockerHostsTTL = 24 * time.Hour // Clean up removed hosts tracking after 24 hours +) + +// cleanupRemovedDockerHosts removes entries from the removed hosts map that are older than 24 hours. +func (m *Monitor) cleanupRemovedDockerHosts(now time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + + for hostID, removedAt := range m.removedDockerHosts { + if now.Sub(removedAt) > removedDockerHostsTTL { + delete(m.removedDockerHosts, hostID) + log.Debug(). + Str("dockerHostID", hostID). + Time("removedAt", removedAt). + Msg("Cleaned up old removed Docker host entry") + } + } +} + // evaluateDockerAgents updates health for Docker hosts based on last report time. func (m *Monitor) evaluateDockerAgents(now time.Time) { hosts := m.state.GetDockerHosts() @@ -1001,6 +1194,9 @@ func New(cfg *config.Config) (*Monitor, error) { nodeSnapshots: make(map[string]NodeMemorySnapshot), guestSnapshots: make(map[string]GuestMemorySnapshot), nodeRRDMemCache: make(map[string]rrdMemCacheEntry), + removedDockerHosts: make(map[string]time.Time), + dockerCommands: make(map[string]*dockerHostCommand), + dockerCommandIndex: make(map[string]string), } // Load saved configurations @@ -1347,7 +1543,9 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) { for { select { case <-pollTicker.C: - m.evaluateDockerAgents(time.Now()) + now := time.Now() + m.evaluateDockerAgents(now) + m.cleanupRemovedDockerHosts(now) if mock.IsMockEnabled() { // In mock mode, keep synthetic alerts fresh go m.checkMockAlerts() diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 8b5b18b..28d985b 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -623,11 +623,85 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis var jsonData []byte var err error + if len(alertList) == 0 { + log.Warn(). + Str("webhook", webhook.Name). + Msg("Attempted to send grouped webhook with no alerts") + return + } + + primaryAlert := alertList[0] + customFields := convertWebhookCustomFields(webhook.CustomFields) + + var templateData WebhookPayloadData + var dataPrepared bool + var urlRendered bool + var serviceDataApplied bool + + prepareData := func() *WebhookPayloadData { + if !dataPrepared { + prepared := n.prepareWebhookData(primaryAlert, customFields) + prepared.AlertCount = len(alertList) + prepared.Alerts = alertList + templateData = prepared + dataPrepared = true + } + return &templateData + } + + ensureURLAndServiceData := func() (*WebhookPayloadData, bool) { + dataPtr := prepareData() + + if !urlRendered { + rendered, renderErr := renderWebhookURL(webhook.URL, *dataPtr) + if renderErr != nil { + log.Error(). + Err(renderErr). + Str("webhook", webhook.Name). + Msg("Failed to render webhook URL template for grouped notification") + return nil, false + } + webhook.URL = rendered + urlRendered = true + } + + if !serviceDataApplied { + switch webhook.Service { + case "telegram": + chatID, chatErr := extractTelegramChatID(webhook.URL) + if chatErr != nil { + log.Error(). + Err(chatErr). + Str("webhook", webhook.Name). + Msg("Failed to extract Telegram chat_id for grouped notification") + return nil, false + } + if chatID != "" { + dataPtr.ChatID = chatID + log.Debug(). + Str("webhook", webhook.Name). + Str("chatID", chatID). + Msg("Extracted Telegram chat_id from rendered URL for grouped notification") + } + case "pagerduty": + if dataPtr.CustomFields == nil { + dataPtr.CustomFields = make(map[string]interface{}) + } + if routingKey, ok := webhook.Headers["routing_key"]; ok { + dataPtr.CustomFields["routing_key"] = routingKey + } + } + serviceDataApplied = true + } + + return dataPtr, true + } + // Check if webhook has a custom template first // Only use custom template if it's not empty if webhook.Template != "" && strings.TrimSpace(webhook.Template) != "" && len(alertList) > 0 { // Use custom template with enhanced message for grouped alerts - alert := alertList[0] + alert := primaryAlert if len(alertList) > 1 { // Build a full list of all alerts summary := alert.Message @@ -642,7 +716,6 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis } } - customFields := convertWebhookCustomFields(webhook.CustomFields) enhanced := EnhancedWebhookConfig{ WebhookConfig: webhook, Service: webhook.Service, @@ -650,17 +723,11 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis CustomFields: customFields, } - data := n.prepareWebhookData(alert, customFields) - data.AlertCount = len(alertList) - data.Alerts = alertList - - // For Telegram webhooks (check URL pattern since service might be empty) - if strings.Contains(webhook.URL, "api.telegram.org") { - // Don't need to extract chat_id from URL since it's in the template - // The template already has the chat_id embedded + if dataPtr, ok := ensureURLAndServiceData(); ok { + jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, *dataPtr, webhook.Service) + } else { + return } - - jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, data, webhook.Service) if err != nil { log.Error(). Err(err). @@ -673,10 +740,8 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis // For service-specific webhooks, use the first alert with a note about others // For simplicity, send the first alert with a note about others // Most webhook services work better with single structured payloads - alert := alertList[0] + alert := primaryAlert - // Convert to enhanced webhook to use template - customFields := convertWebhookCustomFields(webhook.CustomFields) enhanced := EnhancedWebhookConfig{ WebhookConfig: webhook, Service: webhook.Service, @@ -715,32 +780,11 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis } } - // Prepare data and generate payload - data := n.prepareWebhookData(alert, customFields) - data.AlertCount = len(alertList) - data.Alerts = alertList - - // Handle service-specific requirements - if webhook.Service == "telegram" { - if chatID, err := extractTelegramChatID(webhook.URL); err == nil && chatID != "" { - data.ChatID = chatID - } else if err != nil { - log.Error(). - Err(err). - Str("webhook", webhook.Name). - Msg("Failed to extract Telegram chat_id for grouped notification") - return // Skip this webhook - } - } else if webhook.Service == "pagerduty" { - if data.CustomFields == nil { - data.CustomFields = make(map[string]interface{}) - } - if routingKey, ok := webhook.Headers["routing_key"]; ok { - data.CustomFields["routing_key"] = routingKey - } + if dataPtr, ok := ensureURLAndServiceData(); ok { + jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, *dataPtr, webhook.Service) + } else { + return } - - jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, data, webhook.Service) if err != nil { log.Error(). Err(err). @@ -758,6 +802,10 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis // Use generic payload if no service or template not found // But ONLY if jsonData hasn't been set yet (from custom template) if jsonData == nil && (webhook.Service == "" || webhook.Service == "generic") { + if _, ok := ensureURLAndServiceData(); !ok { + return + } + // Use generic payload for other services payload := map[string]interface{}{ "alerts": alertList, @@ -778,6 +826,10 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis } } + if _, ok := ensureURLAndServiceData(); !ok { + return + } + // Send using same request logic n.sendWebhookRequest(webhook, jsonData, "grouped") } @@ -964,11 +1016,50 @@ func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.A var jsonData []byte var err error + customFields := convertWebhookCustomFields(webhook.CustomFields) + data := n.prepareWebhookData(alert, customFields) + + // Render URL template if placeholders are present + renderedURL, renderErr := renderWebhookURL(webhook.URL, data) + if renderErr != nil { + log.Error(). + Err(renderErr). + Str("webhook", webhook.Name). + Msg("Failed to render webhook URL template") + return + } + webhook.URL = renderedURL + + // Service-specific data enrichment + if webhook.Service == "telegram" { + chatID, chatErr := extractTelegramChatID(renderedURL) + if chatErr != nil { + log.Error(). + Err(chatErr). + Str("webhook", webhook.Name). + Msg("Failed to extract Telegram chat_id - skipping webhook") + return + } + if chatID != "" { + data.ChatID = chatID + log.Debug(). + Str("webhook", webhook.Name). + Str("chatID", chatID). + Msg("Extracted Telegram chat_id from rendered URL") + } + } else if webhook.Service == "pagerduty" { + if data.CustomFields == nil { + data.CustomFields = make(map[string]interface{}) + } + if routingKey, ok := webhook.Headers["routing_key"]; ok { + data.CustomFields["routing_key"] = routingKey + } + } + // Check if webhook has a custom template first // Only use custom template if it's not empty if webhook.Template != "" && strings.TrimSpace(webhook.Template) != "" { // Use custom template provided by user - customFields := convertWebhookCustomFields(webhook.CustomFields) enhanced := EnhancedWebhookConfig{ WebhookConfig: webhook, Service: webhook.Service, @@ -976,22 +1067,6 @@ func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.A CustomFields: customFields, } - // Prepare data and generate payload - data := n.prepareWebhookData(alert, customFields) - - // For Telegram, still extract chat_id from URL if present - if webhook.Service == "telegram" { - if chatID, err := extractTelegramChatID(webhook.URL); err == nil && chatID != "" { - data.ChatID = chatID - } else if err != nil { - log.Error(). - Err(err). - Str("webhook", webhook.Name). - Msg("Failed to extract Telegram chat_id - skipping webhook") - return // Skip this webhook - } - } - jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, data, webhook.Service) if err != nil { log.Error(). @@ -1004,7 +1079,6 @@ func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.A } else if webhook.Service != "" && webhook.Service != "generic" { // Check if this webhook has a service type and use the proper template // Convert to enhanced webhook to use template - customFields := convertWebhookCustomFields(webhook.CustomFields) enhanced := EnhancedWebhookConfig{ WebhookConfig: webhook, Service: webhook.Service, @@ -1024,40 +1098,6 @@ func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.A // Only use template if found, otherwise fall back to generic if templateFound { - // Prepare data and generate payload - data := n.prepareWebhookData(alert, customFields) - - // For Telegram, extract chat_id from URL if present - if webhook.Service == "telegram" { - chatID, err := extractTelegramChatID(webhook.URL) - if err != nil { - log.Error(). - Err(err). - Str("webhook", webhook.Name). - Str("url", webhook.URL). - Msg("Failed to extract Telegram chat_id - webhook will fail") - return // Skip this webhook rather than sending invalid payload - } - if chatID != "" { - data.ChatID = chatID - log.Debug(). - Str("webhook", webhook.Name). - Str("chatID", chatID). - Msg("Extracted Telegram chat_id from URL") - } - } - - // For PagerDuty, add routing key if present in URL or headers - if webhook.Service == "pagerduty" { - if data.CustomFields == nil { - data.CustomFields = make(map[string]interface{}) - } - // Check if routing key is in headers - if routingKey, ok := webhook.Headers["routing_key"]; ok { - data.CustomFields["routing_key"] = routingKey - } - } - jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, data, webhook.Service) if err != nil { log.Error(). @@ -1172,6 +1212,26 @@ func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFiel } } +func templateFuncMap() template.FuncMap { + return template.FuncMap{ + "title": func(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) + }, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "printf": fmt.Sprintf, + "urlquery": template.URLQueryEscaper, + "urlencode": template.URLQueryEscaper, + "urlpath": url.PathEscape, + "pathescape": func(s string) string { + return url.PathEscape(s) + }, + } +} + // generatePayloadFromTemplate renders the payload using Go templates func (n *NotificationManager) generatePayloadFromTemplate(templateStr string, data WebhookPayloadData) ([]byte, error) { return n.generatePayloadFromTemplateWithService(templateStr, data, "") @@ -1179,21 +1239,7 @@ func (n *NotificationManager) generatePayloadFromTemplate(templateStr string, da // generatePayloadFromTemplateWithService renders the payload using Go templates with service-specific handling func (n *NotificationManager) generatePayloadFromTemplateWithService(templateStr string, data WebhookPayloadData, service string) ([]byte, error) { - // Create template with helper functions - funcMap := template.FuncMap{ - "title": func(s string) string { - // Replace deprecated strings.Title with proper title casing - if s == "" { - return s - } - return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) - }, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "printf": fmt.Sprintf, - } - - tmpl, err := template.New("webhook").Funcs(funcMap).Parse(templateStr) + tmpl, err := template.New("webhook").Funcs(templateFuncMap()).Parse(templateStr) if err != nil { return nil, fmt.Errorf("invalid template: %w", err) } @@ -1222,6 +1268,44 @@ func (n *NotificationManager) generatePayloadFromTemplateWithService(templateStr return buf.Bytes(), nil } +// renderWebhookURL applies template rendering to webhook URLs and ensures the result is a valid URL +func renderWebhookURL(urlTemplate string, data WebhookPayloadData) (string, error) { + trimmed := strings.TrimSpace(urlTemplate) + if trimmed == "" { + return "", fmt.Errorf("webhook URL cannot be empty") + } + + if !strings.Contains(trimmed, "{{") { + return trimmed, nil + } + + tmpl, err := template.New("webhook_url").Funcs(templateFuncMap()).Parse(trimmed) + if err != nil { + return "", fmt.Errorf("invalid webhook URL template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("webhook URL template execution failed: %w", err) + } + + rendered := strings.TrimSpace(buf.String()) + if rendered == "" { + return "", fmt.Errorf("webhook URL template produced empty URL") + } + + parsed, err := url.Parse(rendered) + if err != nil { + return "", fmt.Errorf("webhook URL template produced invalid URL: %w", err) + } + + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("webhook URL template produced invalid URL: missing scheme or host") + } + + return parsed.String(), nil +} + // formatWebhookDuration formats a duration in a human-readable way func formatWebhookDuration(d time.Duration) string { if d < time.Minute { diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index ea0ac4f..8458d48 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -152,3 +152,42 @@ func TestConvertWebhookCustomFields(t *testing.T) { t.Fatalf("expected converted map to be independent of original mutations") } } + +func TestRenderWebhookURL_PathEncoding(t *testing.T) { + data := WebhookPayloadData{ + Message: "CPU spike detected", + } + + result, err := renderWebhookURL("https://example.com/alerts/{{.Message}}", data) + if err != nil { + t.Fatalf("expected no error rendering URL template, got %v", err) + } + + expected := "https://example.com/alerts/CPU%20spike%20detected" + if result != expected { + t.Fatalf("expected %s, got %s", expected, result) + } +} + +func TestRenderWebhookURL_QueryEncoding(t *testing.T) { + data := WebhookPayloadData{ + Message: "CPU & Memory > 90%", + } + + result, err := renderWebhookURL("https://hooks.example.com?msg={{urlquery .Message}}", data) + if err != nil { + t.Fatalf("expected no error rendering URL template, got %v", err) + } + + expected := "https://hooks.example.com?msg=CPU+%26+Memory+%3E+90%25" + if result != expected { + t.Fatalf("expected %s, got %s", expected, result) + } +} + +func TestRenderWebhookURL_InvalidTemplate(t *testing.T) { + _, err := renderWebhookURL("https://example.com/{{.Missing", WebhookPayloadData{}) + if err == nil { + t.Fatalf("expected error for invalid URL template, got nil") + } +} diff --git a/internal/notifications/webhook_enhanced.go b/internal/notifications/webhook_enhanced.go index 0429a1f..158f7a4 100644 --- a/internal/notifications/webhook_enhanced.go +++ b/internal/notifications/webhook_enhanced.go @@ -79,6 +79,36 @@ func (n *NotificationManager) SendEnhancedWebhook(webhook EnhancedWebhookConfig, // Prepare template data data := n.prepareWebhookData(alert, webhook.CustomFields) + // Render URL template when placeholders are present + renderedURL, renderErr := renderWebhookURL(webhook.URL, data) + if renderErr != nil { + return fmt.Errorf("failed to render webhook URL template: %w", renderErr) + } + webhook.URL = renderedURL + + // Service-specific enrichment + switch webhook.Service { + case "telegram": + chatID, chatErr := extractTelegramChatID(webhook.URL) + if chatErr != nil { + return fmt.Errorf("failed to extract Telegram chat_id: %w", chatErr) + } + if chatID != "" { + data.ChatID = chatID + log.Debug(). + Str("webhook", webhook.Name). + Str("chatID", chatID). + Msg("Extracted Telegram chat_id from rendered URL for enhanced webhook") + } + case "pagerduty": + if data.CustomFields == nil { + data.CustomFields = make(map[string]interface{}) + } + if routingKey, ok := webhook.Headers["routing_key"]; ok { + data.CustomFields["routing_key"] = routingKey + } + } + // Generate payload from template with service-specific handling payload, err := n.generatePayloadFromTemplateWithService(webhook.PayloadTemplate, data, webhook.Service) if err != nil { @@ -435,13 +465,32 @@ func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig) // Prepare data data := n.prepareWebhookData(testAlert, webhook.CustomFields) + // Render webhook URL using template data + renderedURL, renderErr := renderWebhookURL(webhook.URL, data) + if renderErr != nil { + return 0, "", fmt.Errorf("failed to render webhook URL template: %w", renderErr) + } + webhook.URL = renderedURL + // For Telegram, extract chat_id from URL if present if webhook.Service == "telegram" { if chatID, err := extractTelegramChatID(webhook.URL); err == nil && chatID != "" { data.ChatID = chatID + } else if err != nil { + log.Warn(). + Err(err). + Str("webhook", webhook.Name). + Msg("Failed to extract Telegram chat_id during enhanced webhook test") } // Note: For test webhooks, we don't fail if chat_id is missing // as this may be intentional during testing + } else if webhook.Service == "pagerduty" { + if data.CustomFields == nil { + data.CustomFields = make(map[string]interface{}) + } + if routingKey, ok := webhook.Headers["routing_key"]; ok { + data.CustomFields["routing_key"] = routingKey + } } // Generate payload with service-specific handling diff --git a/internal/updates/version.go b/internal/updates/version.go index 7dd602f..879c2f1 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -119,6 +119,23 @@ func (v *Version) IsPrerelease() bool { // GetCurrentVersion gets the current running version func GetCurrentVersion() (*VersionInfo, error) { + buildInfo := func(raw string, build string, isDev bool) *VersionInfo { + normalized := normalizeVersionString(raw) + return &VersionInfo{ + Version: normalized, + Build: build, + Runtime: "go", + Channel: detectChannelFromVersion(normalized), + IsDevelopment: isDev, + IsDocker: isDockerEnvironment(), + DeploymentType: GetDeploymentType(), + } + } + + if gitVersion, err := getGitVersion(); err == nil && gitVersion != "" { + return buildInfo(gitVersion, "development", true), nil + } + // Try to read from VERSION file first (release builds) versionPaths := []string{ "VERSION", @@ -129,22 +146,8 @@ func GetCurrentVersion() (*VersionInfo, error) { for _, path := range versionPaths { versionBytes, err := os.ReadFile(path) if err == nil { - version := strings.TrimSpace(string(versionBytes)) - if version != "" { - // Determine channel from version string - channel := "stable" - if strings.Contains(strings.ToLower(version), "rc") { - channel = "rc" - } - return &VersionInfo{ - Version: version, - Build: "release", - Runtime: "go", - Channel: channel, - IsDevelopment: false, - IsDocker: isDockerEnvironment(), - DeploymentType: GetDeploymentType(), - }, nil + if raw := strings.TrimSpace(string(versionBytes)); raw != "" { + return buildInfo(raw, "release", false), nil } } } @@ -152,37 +155,99 @@ func GetCurrentVersion() (*VersionInfo, error) { // Fall back to git (development builds) gitVersion, err := getGitVersion() if err == nil && gitVersion != "" { - // Determine channel from git version - channel := "stable" - if strings.Contains(strings.ToLower(gitVersion), "rc") { - channel = "rc" - } - return &VersionInfo{ - Version: gitVersion, - Build: "development", - Runtime: "go", - Channel: channel, - IsDevelopment: true, - IsDocker: isDockerEnvironment(), - DeploymentType: GetDeploymentType(), - }, nil + return buildInfo(gitVersion, "development", true), nil } // Final fallback - version := "4.24.0-rc.3" - channel := "stable" - if strings.Contains(strings.ToLower(version), "rc") { - channel = "rc" + return buildInfo("4.24.0-rc.3", "release", false), nil +} + +// normalizeVersionString ensures any version string can be parsed as semantic version. +// Release builds are returned unchanged, while git describe strings and branch names are +// converted to a safe 0.0.0-based prerelease format. +func normalizeVersionString(version string) string { + version = strings.TrimSpace(version) + version = strings.TrimPrefix(version, "v") + + if version == "" { + return "0.0.0-dev" } - return &VersionInfo{ - Version: version, - Build: "release", - Runtime: "go", - Channel: channel, - IsDevelopment: false, - IsDocker: isDockerEnvironment(), - DeploymentType: GetDeploymentType(), - }, nil + + if normalized, ok := normalizeGitDescribeVersion(version); ok { + return normalized + } + + if _, err := ParseVersion(version); err == nil { + return version + } + + return fmt.Sprintf("0.0.0-%s", sanitizePrereleaseIdentifier(version)) +} + +var gitDescribeRegex = regexp.MustCompile(`^(\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?)-(\d+)-g([0-9a-fA-F]+)(-dirty)?$`) + +func normalizeGitDescribeVersion(version string) (string, bool) { + matches := gitDescribeRegex.FindStringSubmatch(version) + if matches == nil { + return "", false + } + + base := matches[1] + if _, err := ParseVersion(base); err != nil { + return "", false + } + + build := fmt.Sprintf("git.%s.g%s", matches[2], strings.ToLower(matches[3])) + if matches[4] != "" { + build += ".dirty" + } + + return fmt.Sprintf("%s+%s", base, build), true +} + +func sanitizePrereleaseIdentifier(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "dev" + } + + var b strings.Builder + lastSep := false + + for _, r := range raw { + switch { + case r >= '0' && r <= '9', + r >= 'A' && r <= 'Z', + r >= 'a' && r <= 'z': + b.WriteRune(r) + lastSep = false + case r == '-' || r == '.': + if lastSep { + continue + } + b.WriteRune(r) + lastSep = true + default: + if !lastSep { + b.WriteRune('-') + lastSep = true + } + } + } + + clean := strings.Trim(b.String(), "-.") + if clean == "" { + return "dev" + } + return strings.ToLower(clean) +} + +func detectChannelFromVersion(version string) string { + versionLower := strings.ToLower(version) + if strings.Contains(versionLower, "rc") { + return "rc" + } + return "stable" } // getGitVersion gets version information from git diff --git a/internal/updates/version_test.go b/internal/updates/version_test.go new file mode 100644 index 0000000..c0beda7 --- /dev/null +++ b/internal/updates/version_test.go @@ -0,0 +1,66 @@ +package updates + +import "testing" + +func TestNormalizeVersionString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "release version stays the same", + input: "4.24.0-rc.3", + expected: "4.24.0-rc.3", + }, + { + name: "git describe converts to build metadata", + input: "4.24.0-rc.3-45-gABCDEF", + expected: "4.24.0-rc.3+git.45.gabcdef", + }, + { + name: "git describe dirty includes dirty flag", + input: "4.24.0-rc.3-45-gabc123-dirty", + expected: "4.24.0-rc.3+git.45.gabc123.dirty", + }, + { + name: "branch name falls back to prerelease", + input: "issue-551", + expected: "0.0.0-issue-551", + }, + { + name: "branch with slashes sanitized", + input: "feature/new-api", + expected: "0.0.0-feature-new-api", + }, + { + name: "empty input defaults to dev", + input: "", + expected: "0.0.0-dev", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := normalizeVersionString(tc.input); got != tc.expected { + t.Fatalf("normalizeVersionString(%q) = %q, expected %q", tc.input, got, tc.expected) + } + }) + } +} + +func TestDetectChannelFromVersion(t *testing.T) { + t.Parallel() + + if got := detectChannelFromVersion("4.24.0-rc.3"); got != "rc" { + t.Fatalf("detectChannelFromVersion rc = %s, expected rc", got) + } + + if got := detectChannelFromVersion("0.0.0-feature-x"); got != "stable" { + t.Fatalf("detectChannelFromVersion stable = %s, expected stable", got) + } +} diff --git a/pkg/agents/docker/command.go b/pkg/agents/docker/command.go new file mode 100644 index 0000000..e1aabce --- /dev/null +++ b/pkg/agents/docker/command.go @@ -0,0 +1,33 @@ +package dockeragent + +// Command represents a control instruction issued by Pulse to the Docker agent. +type Command struct { + ID string `json:"id"` + Type string `json:"type"` + Payload map[string]any `json:"payload,omitempty"` +} + +// ReportResponse captures the server response for a docker report submission. +type ReportResponse struct { + Success bool `json:"success"` + Commands []Command `json:"commands,omitempty"` +} + +// CommandAck is sent by the agent to confirm the result of a control command. +type CommandAck struct { + HostID string `json:"hostId"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +const ( + // CommandTypeStop instructs the agent to stop reporting and shut down. + CommandTypeStop = "stop" + + // CommandStatusAcknowledged indicates a command was received and is in progress. + CommandStatusAcknowledged = "acknowledged" + // CommandStatusCompleted indicates the command completed successfully. + CommandStatusCompleted = "completed" + // CommandStatusFailed indicates the command failed. + CommandStatusFailed = "failed" +) diff --git a/pulse-docker-agent b/pulse-docker-agent index 30399e8..a25e86f 100755 Binary files a/pulse-docker-agent and b/pulse-docker-agent differ diff --git a/scripts/install-docker-agent.sh b/scripts/install-docker-agent.sh index 32da79e..7bdaf55 100755 --- a/scripts/install-docker-agent.sh +++ b/scripts/install-docker-agent.sh @@ -8,6 +8,46 @@ trim() { printf '%s' "$value" } +determine_agent_identifier() { + local agent_id="" + + if command -v docker &> /dev/null; then + agent_id=$(docker info --format '{{.ID}}' 2>/dev/null | head -n1 | tr -d '[:space:]') + fi + + if [[ -z "$agent_id" ]] && [[ -r /etc/machine-id ]]; then + agent_id=$(tr -d '[:space:]' < /etc/machine-id) + fi + + if [[ -z "$agent_id" ]]; then + agent_id=$(hostname 2>/dev/null | tr -d '[:space:]') + fi + + printf '%s' "$agent_id" +} + +log_info() { + printf '[INFO] %s\n' "$1" +} + +log_success() { + printf '[ OK ] %s\n' "$1" +} + +log_warn() { + printf '[WARN] %s\n' "$1" >&2 +} + +log_header() { + printf '\n== %s ==\n' "$1" +} + +quote_shell_arg() { + local value="$1" + value=${value//\'/\'\\\'\'} + printf "'%s'" "$value" +} + parse_bool() { local value value=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') @@ -343,6 +383,7 @@ PRIMARY_URL="" PRIMARY_TOKEN="" PRIMARY_INSECURE="false" JOINED_TARGETS="" +ORIGINAL_ARGS=("$@") # Parse arguments while [[ $# -gt 0 ]]; do @@ -385,13 +426,215 @@ done # Normalize PULSE_URL - strip trailing slashes to prevent double-slash issues PULSE_URL="${PULSE_URL%/}" +ORIGINAL_ARGS_STRING="" +if [[ ${#ORIGINAL_ARGS[@]} -gt 0 ]]; then + __quoted_original_args=() + for __arg in "${ORIGINAL_ARGS[@]}"; do + __quoted_original_args+=("$(quote_shell_arg "$__arg")") + done + ORIGINAL_ARGS_STRING="${__quoted_original_args[*]}" +fi +unset __quoted_original_args __arg + +ORIGINAL_ARGS_ESCAPED="" +if [[ ${#ORIGINAL_ARGS[@]} -gt 0 ]]; then + __command_args=() + for __arg in "${ORIGINAL_ARGS[@]}"; do + __command_args+=("$(printf '%q' "$__arg")") + done + ORIGINAL_ARGS_ESCAPED="${__command_args[*]}" +fi +unset __command_args __arg + +INSTALLER_URL_HINT="${PULSE_INSTALLER_URL_HINT:-}" +if [[ -z "$INSTALLER_URL_HINT" && -n "$PULSE_URL" ]]; then + INSTALLER_URL_HINT="${PULSE_URL%/}/install-docker-agent.sh" +fi +if [[ -z "$INSTALLER_URL_HINT" && ${#TARGET_SPECS[@]} -gt 0 ]]; then + if parse_target_spec "${TARGET_SPECS[0]}" >/dev/null 2>&1; then + if [[ -n "$PARSED_TARGET_URL" ]]; then + INSTALLER_URL_HINT="${PARSED_TARGET_URL%/}/install-docker-agent.sh" + fi + fi +fi + +if [[ -z "$INSTALLER_URL_HINT" && -f "$SERVICE_PATH" ]]; then + mapfile -t __service_targets_for_hint < <(extract_targets_from_service "$SERVICE_PATH") + if [[ ${#__service_targets_for_hint[@]} -gt 0 ]]; then + if parse_target_spec "${__service_targets_for_hint[0]}" >/dev/null 2>&1; then + if [[ -n "$PARSED_TARGET_URL" ]]; then + INSTALLER_URL_HINT="${PARSED_TARGET_URL%/}/install-docker-agent.sh" + fi + fi + fi + unset __service_targets_for_hint +fi + +if [[ -z "$INSTALLER_URL_HINT" && -n "${PPID:-}" ]]; then + PARENT_CMD=$(ps -o command= -p "$PPID" 2>/dev/null || true) + if [[ -n "$PARENT_CMD" ]]; then + if [[ "$PARENT_CMD" =~ (https?://[^[:space:]\"\'\|]+/install-docker-agent\.sh) ]]; then + INSTALLER_URL_HINT="${BASH_REMATCH[1]}" + elif [[ "$PARENT_CMD" =~ (https?://[^[:space:]\|]+install-docker-agent\.sh) ]]; then + INSTALLER_URL_HINT="${BASH_REMATCH[1]}" + fi + if [[ -n "$INSTALLER_URL_HINT" ]]; then + INSTALLER_URL_HINT="${INSTALLER_URL_HINT#\"}" + INSTALLER_URL_HINT="${INSTALLER_URL_HINT#\'}" + INSTALLER_URL_HINT="${INSTALLER_URL_HINT%\"}" + INSTALLER_URL_HINT="${INSTALLER_URL_HINT%\'}" + fi + fi + unset PARENT_CMD +fi + +PIPELINE_COMMAND="" +PIPELINE_COMMAND_SUDO_C="" +PIPELINE_COMMAND_INNER="" +if [[ -n "$INSTALLER_URL_HINT" ]]; then + INSTALLER_URL_QUOTED=$(quote_shell_arg "$INSTALLER_URL_HINT") + PIPELINE_COMMAND="curl -fsSL ${INSTALLER_URL_QUOTED} | sudo bash -s" + if [[ ${#ORIGINAL_ARGS[@]} -gt 0 ]]; then + PIPELINE_COMMAND+=" -- ${ORIGINAL_ARGS_STRING}" + fi + INSTALLER_URL_ESCAPED=$(printf '%q' "$INSTALLER_URL_HINT") + PIPELINE_COMMAND_INNER="curl -fsSL ${INSTALLER_URL_ESCAPED} | bash -s" + if [[ -n "$ORIGINAL_ARGS_ESCAPED" ]]; then + PIPELINE_COMMAND_INNER+=" -- ${ORIGINAL_ARGS_ESCAPED}" + fi + PIPELINE_COMMAND_SUDO_C="sudo bash -c $(quote_shell_arg "$PIPELINE_COMMAND_INNER")" + unset INSTALLER_URL_ESCAPED INSTALLER_URL_QUOTED +fi + +SCRIPT_SOURCE_HINT="" +if [[ -n "${BASH_SOURCE[0]:-}" ]]; then + __script_source_candidate="${BASH_SOURCE[0]}" + if [[ -n "$__script_source_candidate" && -f "$__script_source_candidate" ]]; then + if command -v realpath >/dev/null 2>&1; then + __resolved_source=$(realpath "$__script_source_candidate" 2>/dev/null || true) + elif command -v readlink >/dev/null 2>&1; then + __resolved_source=$(readlink -f "$__script_source_candidate" 2>/dev/null || true) + else + __resolved_source="" + fi + if [[ -z "$__resolved_source" ]]; then + if [[ "$__script_source_candidate" == /* ]]; then + __resolved_source="$__script_source_candidate" + else + __resolved_source="$(pwd)/$__script_source_candidate" + fi + fi + SCRIPT_SOURCE_HINT="$__resolved_source" + fi +fi +unset __script_source_candidate __resolved_source + +LOCAL_COMMAND="" +if [[ -n "$SCRIPT_SOURCE_HINT" ]]; then + LOCAL_COMMAND="sudo bash $(quote_shell_arg "$SCRIPT_SOURCE_HINT")" + if [[ ${#ORIGINAL_ARGS[@]} -gt 0 ]]; then + LOCAL_COMMAND+=" ${ORIGINAL_ARGS_STRING}" + fi +fi + # Check if running as root if [[ $EUID -ne 0 ]]; then + AUTO_SUDO_ATTEMPTED="false" + AUTO_SUDO_EXIT_CODE=0 + if [[ -z "${PULSE_AUTO_SUDO_ATTEMPTED:-}" ]]; then + PULSE_AUTO_SUDO_ATTEMPTED=1 + export PULSE_AUTO_SUDO_ATTEMPTED + if command -v sudo >/dev/null 2>&1; then + if [[ -n "$SCRIPT_SOURCE_HINT" ]]; then + AUTO_SUDO_ATTEMPTED="true" + echo "Requesting sudo to continue installation..." + if sudo bash "$SCRIPT_SOURCE_HINT" "${ORIGINAL_ARGS[@]}"; then + exit 0 + else + AUTO_SUDO_EXIT_CODE=$? + fi + elif [[ -n "$PIPELINE_COMMAND_INNER" ]]; then + AUTO_SUDO_ATTEMPTED="true" + echo "Requesting sudo to continue installation..." + if sudo bash -c "$PIPELINE_COMMAND_INNER"; then + exit 0 + else + AUTO_SUDO_EXIT_CODE=$? + fi + fi + fi + fi + + if [[ "$AUTO_SUDO_ATTEMPTED" == "true" ]]; then + echo "WARN: Automatic sudo elevation failed (exit code ${AUTO_SUDO_EXIT_CODE})." + echo "" + if [[ -n "$PIPELINE_COMMAND" ]]; then + echo "Retry manually with sudo:" + echo " $PIPELINE_COMMAND" + if [[ -n "$PIPELINE_COMMAND_SUDO_C" ]]; then + echo "" + fi + fi + if [[ -n "$PIPELINE_COMMAND_SUDO_C" ]]; then + echo "Or run the entire pipeline under sudo:" + echo " $PIPELINE_COMMAND_SUDO_C" + if [[ -n "$LOCAL_COMMAND" ]]; then + echo "" + fi + fi + if [[ -n "$LOCAL_COMMAND" ]]; then + echo "If you downloaded the script locally:" + echo " $LOCAL_COMMAND" + echo "" + fi + if [[ -z "$PIPELINE_COMMAND" && -z "$LOCAL_COMMAND" ]]; then + echo "Please re-run the installer with elevated privileges, for example:" + if [[ ${#ORIGINAL_ARGS[@]} -gt 0 ]]; then + echo " curl -fsSL /install-docker-agent.sh | sudo bash -s -- ${ORIGINAL_ARGS_STRING}" + FALLBACK_PIPELINE_INNER="curl -fsSL /install-docker-agent.sh | bash -s -- ${ORIGINAL_ARGS_ESCAPED}" + echo " sudo bash -c $(quote_shell_arg "$FALLBACK_PIPELINE_INNER")" + unset FALLBACK_PIPELINE_INNER + else + echo " curl -fsSL /install-docker-agent.sh | sudo bash -s" + echo " sudo bash -c 'curl -fsSL /install-docker-agent.sh | bash -s'" + fi + fi + exit "${AUTO_SUDO_EXIT_CODE:-1}" + fi + echo "Error: This script must be run as root" echo "" - echo "Please run the command with 'bash' instead of just piping to bash:" - echo " Install: curl -fsSL /install-docker-agent.sh | bash -s -- --url " - echo " Uninstall: curl -fsSL /install-docker-agent.sh | bash -s -- --uninstall" + if [[ -n "$PIPELINE_COMMAND" ]]; then + echo "Re-run with sudo using the same arguments:" + echo " $PIPELINE_COMMAND" + if [[ -n "$PIPELINE_COMMAND_SUDO_C" ]]; then + echo "" + fi + fi + if [[ -n "$PIPELINE_COMMAND_SUDO_C" ]]; then + echo "Or run the entire pipeline under sudo:" + echo " $PIPELINE_COMMAND_SUDO_C" + if [[ -n "$LOCAL_COMMAND" ]]; then + echo "" + fi + fi + if [[ -n "$LOCAL_COMMAND" ]]; then + echo "If you downloaded the script locally:" + echo " $LOCAL_COMMAND" + echo "" + fi + if [[ -z "$PIPELINE_COMMAND" && -z "$LOCAL_COMMAND" ]]; then + echo "Please re-run the installer with elevated privileges, for example:" + if [[ ${#ORIGINAL_ARGS[@]} -gt 0 ]]; then + echo " curl -fsSL /install-docker-agent.sh | sudo bash -s -- ${ORIGINAL_ARGS_STRING}" + FALLBACK_PIPELINE_INNER="curl -fsSL /install-docker-agent.sh | bash -s -- ${ORIGINAL_ARGS_ESCAPED}" + echo " sudo bash -c $(quote_shell_arg "$FALLBACK_PIPELINE_INNER")" + unset FALLBACK_PIPELINE_INNER + else + echo " curl -fsSL /install-docker-agent.sh | sudo bash -s" + echo " sudo bash -c 'curl -fsSL /install-docker-agent.sh | bash -s'" + fi + fi exit 1 fi @@ -508,7 +751,7 @@ if [[ "$UNINSTALL" != true ]]; then RAW_TARGETS+=("${PULSE_URL%/}|$TOKEN|$PRIMARY_INSECURE") fi - if [[ -f "$SERVICE_PATH" ]]; then + if [[ -f "$SERVICE_PATH" && ${#RAW_TARGETS[@]} -eq 0 ]]; then mapfile -t EXISTING_TARGETS < <(extract_targets_from_service "$SERVICE_PATH") if [[ ${#EXISTING_TARGETS[@]} -gt 0 ]]; then RAW_TARGETS+=("${EXISTING_TARGETS[@]}") @@ -552,31 +795,38 @@ if [[ "$UNINSTALL" != true ]]; then TOKEN="$PRIMARY_TOKEN" fi -echo "===================================" -echo "Pulse Docker Agent Installer" -echo "===================================" -if [[ -n "$AGENT_PATH_NOTE" ]]; then - echo "$AGENT_PATH_NOTE" -fi -echo "Primary Pulse URL: $PRIMARY_URL" -if [[ ${#TARGETS[@]} -gt 1 ]]; then - echo "Additional Pulse targets: $(( ${#TARGETS[@]} - 1 ))" -fi -echo "Install path: $AGENT_PATH" -echo "Interval: $INTERVAL" +log_header "Pulse Docker Agent Installer" if [[ "$UNINSTALL" != true ]]; then - echo "API token: (provided)" - echo "Targets:" + AGENT_IDENTIFIER=$(determine_agent_identifier) +else + AGENT_IDENTIFIER="" +fi +if [[ -n "$AGENT_PATH_NOTE" ]]; then + log_warn "$AGENT_PATH_NOTE" +fi +log_info "Primary Pulse URL : $PRIMARY_URL" +if [[ ${#TARGETS[@]} -gt 1 ]]; then + log_info "Additional targets : $(( ${#TARGETS[@]} - 1 ))" +fi +log_info "Install path : $AGENT_PATH" +log_info "Log directory : /var/log/pulse-docker-agent" +log_info "Reporting interval: $INTERVAL" +if [[ "$UNINSTALL" != true ]]; then + log_info "API token : provided" + if [[ -n "$AGENT_IDENTIFIER" ]]; then + log_info "Docker host ID : $AGENT_IDENTIFIER" + fi + log_info "Targets:" for spec in "${TARGETS[@]}"; do IFS='|' read -r target_url _ target_insecure <<< "$spec" if [[ "$target_insecure" == "true" ]]; then - echo " - $target_url (skip TLS verify)" + log_info " • $target_url (skip TLS verify)" else - echo " - $target_url" + log_info " • $target_url" fi done fi -echo "" +printf '\n' # Detect architecture for download if [[ "$UNINSTALL" != true ]]; then @@ -593,14 +843,14 @@ if [[ "$UNINSTALL" != true ]]; then ;; *) DOWNLOAD_ARCH="" - echo "Warning: Unknown architecture '$ARCH'. Falling back to default agent binary." + log_warn "Unknown architecture '$ARCH'. Falling back to default agent binary." ;; esac fi # Check if Docker is installed if ! command -v docker &> /dev/null; then - echo "Warning: Docker not found. The agent requires Docker to be installed." + log_warn 'Docker not found. The agent requires Docker to be installed.' read -p "Continue anyway? (y/N) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then @@ -619,7 +869,7 @@ if [[ "$UNINSTALL" != true ]]; then fi # Download agent binary -echo "Downloading Pulse Docker agent..." +log_info "Downloading agent binary" DOWNLOAD_URL_BASE="$PRIMARY_URL/download/pulse-docker-agent" DOWNLOAD_URL="$DOWNLOAD_URL_BASE" if [[ -n "$DOWNLOAD_ARCH" ]]; then @@ -681,73 +931,129 @@ download_agent_from_url() { if download_agent_from_url "$DOWNLOAD_URL"; then : elif [[ "$DOWNLOAD_URL" != "$DOWNLOAD_URL_BASE" ]] && download_agent_from_url "$DOWNLOAD_URL_BASE"; then - echo "Falling back to server default agent binary..." + log_info 'Falling back to server default agent binary' else - echo "Error: Failed to download agent binary" - echo "Make sure the Pulse server is accessible at: $PRIMARY_URL" + log_warn 'Failed to download agent binary' + log_warn "Ensure the Pulse server is reachable at $PRIMARY_URL" exit 1 fi chmod +x "$AGENT_PATH" -echo "✓ Agent binary installed" +log_success "Agent binary installed" + +allow_reenroll_if_needed() { + local host_id="$1" + if [[ -z "$host_id" || -z "$PRIMARY_TOKEN" || -z "$PRIMARY_URL" ]]; then + return 0 + fi + + local endpoint="$PRIMARY_URL/api/agents/docker/hosts/${host_id}/allow-reenroll" + local success="false" + + if command -v curl &> /dev/null; then + local curl_args=(-fsSL -X POST -H "X-API-Token: $PRIMARY_TOKEN" "$endpoint") + if [[ "$PRIMARY_INSECURE" == "true" ]]; then + curl_args=(-k "${curl_args[@]}") + fi + if curl "${curl_args[@]}" >/dev/null 2>&1; then + success="true" + fi + fi + + if [[ "$success" != "true" ]]; then + if command -v wget &> /dev/null; then + local wget_args=(--method=POST --header="X-API-Token: $PRIMARY_TOKEN" -q -O /dev/null "$endpoint") + if [[ "$PRIMARY_INSECURE" == "true" ]]; then + wget_args=(--no-check-certificate "${wget_args[@]}") + fi + if wget "${wget_args[@]}" >/dev/null 2>&1; then + success="true" + fi + fi + fi + + if [[ "$success" == "true" ]]; then + log_success "Cleared any previous stop block for host" + else + log_warn "Unable to confirm removal block clearance (continuing)" + fi + + return 0 +} + +allow_reenroll_if_needed "$AGENT_IDENTIFIER" # Check if systemd is available if ! command -v systemctl &> /dev/null || [ ! -d /etc/systemd/system ]; then - echo "" - echo "Systemd not detected - configuring for alternative init system..." + printf '\n%s\n' '-- Systemd not detected; configuring alternative startup --' # Check if this is Unraid (has /boot/config directory) if [ -d /boot/config ]; then - echo "Unraid detected - creating startup script..." + log_info 'Detected Unraid environment' - # Create go.d directory if it doesn't exist mkdir -p /boot/config/go.d - - # Create startup script STARTUP_SCRIPT="/boot/config/go.d/pulse-docker-agent.sh" cat > "$STARTUP_SCRIPT" < /var/log/pulse-docker-agent.log 2>&1 & +PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG > /var/log/pulse-docker-agent.log 2>&1 & EOF chmod +x "$STARTUP_SCRIPT" - echo "✓ Startup script created at $STARTUP_SCRIPT" + log_success "Created startup script: $STARTUP_SCRIPT" - # Start the agent now - echo "Starting agent..." - PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL" > /var/log/pulse-docker-agent.log 2>&1 & + log_info 'Starting agent' + PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG > /var/log/pulse-docker-agent.log 2>&1 & - echo "" - echo "===================================" - echo "✓ Installation complete!" - echo "===================================" - echo "" - echo "The agent is now running and will auto-start on reboot." - echo "Your Docker host should appear in Pulse within 30 seconds." - echo "" - echo "Logs: /var/log/pulse-docker-agent.log" - echo "" + log_header 'Installation complete' + log_info 'Agent started via Unraid go.d hook' + log_info 'Log file : /var/log/pulse-docker-agent.log' + log_info 'Host visible in Pulse: ~30 seconds' exit 0 fi - # For other non-systemd systems, provide manual instructions - echo "" - echo "The agent has been installed to: $AGENT_PATH" - echo "" - echo "To run manually:" - echo " PULSE_URL=$PRIMARY_URL PULSE_TOKEN= \\" - echo " PULSE_TARGETS=\"https://pulse.example.com|[;https://pulse-alt.example.com|]\" \\" - echo " $AGENT_PATH --interval $INTERVAL &" - echo "" - echo "To make it start automatically, add the above command to your system's startup scripts." - echo "" + log_info 'Manual startup environment detected' + log_info "Binary location : $AGENT_PATH" + log_info 'Start manually with :' + printf ' PULSE_URL=%s PULSE_TOKEN= \\n' "$PRIMARY_URL" + printf ' PULSE_TARGETS="%s" \\n' "https://pulse.example.com|[;https://pulse-alt.example.com|]" + printf ' %s --interval %s & +' "$AGENT_PATH" "$INTERVAL" + log_info 'Add the same command to your init system to start automatically.' exit 0 + +fi + + +# Check if server is in development mode +NO_AUTO_UPDATE_FLAG="" +if command -v curl &> /dev/null || command -v wget &> /dev/null; then + SERVER_INFO_URL="$PRIMARY_URL/api/server/info" + IS_DEV="false" + + if command -v curl &> /dev/null; then + SERVER_INFO=$(curl -fsSL "$SERVER_INFO_URL" 2>/dev/null || echo "") + elif command -v wget &> /dev/null; then + SERVER_INFO=$(wget -qO- "$SERVER_INFO_URL" 2>/dev/null || echo "") + fi + +if [[ -n "$SERVER_INFO" ]] && echo "$SERVER_INFO" | grep -q '"isDevelopment"[[:space:]]*:[[:space:]]*true'; then + IS_DEV="true" + NO_AUTO_UPDATE_FLAG=" --no-auto-update" + log_info 'Development server detected – auto-update disabled' +fi + +if [[ -n "$NO_AUTO_UPDATE_FLAG" ]]; then + if ! "$AGENT_PATH" --help 2>&1 | grep -q -- '--no-auto-update'; then + log_warn 'Agent binary lacks --no-auto-update flag; keeping auto-update enabled' + NO_AUTO_UPDATE_FLAG="" + fi +fi fi # Create systemd service -echo "Creating systemd service..." +log_header 'Configuring systemd service' SYSTEMD_ENV_TARGETS_LINE="" if [[ -n "$JOINED_TARGETS" ]]; then SYSTEMD_ENV_TARGETS_LINE="Environment=\"PULSE_TARGETS=$JOINED_TARGETS\"" @@ -767,8 +1073,8 @@ $SYSTEMD_ENV_URL_LINE $SYSTEMD_ENV_TOKEN_LINE $SYSTEMD_ENV_TARGETS_LINE $SYSTEMD_ENV_INSECURE_LINE -ExecStart=$AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL" -Restart=always +ExecStart=$AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG +Restart=on-failure RestartSec=5s User=root @@ -776,21 +1082,16 @@ User=root WantedBy=multi-user.target EOF -echo "✓ Systemd service created" +log_success "Wrote unit file: $SERVICE_PATH" # Reload systemd and start service -echo "Starting service..." +log_info 'Starting service' systemctl daemon-reload systemctl enable pulse-docker-agent systemctl start pulse-docker-agent -echo "" -echo "===================================" -echo "✓ Installation complete!" -echo "===================================" -echo "" -echo "The Pulse Docker agent is now running." -echo "Check status: systemctl status pulse-docker-agent" -echo "View logs: journalctl -u pulse-docker-agent -f" -echo "" -echo "Your Docker host should appear in Pulse within 30 seconds." +log_header 'Installation complete' +log_info 'Agent service enabled and started' +log_info 'Check status : systemctl status pulse-docker-agent' +log_info 'Follow logs : journalctl -u pulse-docker-agent -f' +log_info 'Host visible in Pulse : ~30 seconds'