feat: add docker agent command handling
This commit is contained in:
parent
aaae27dc11
commit
91fecacfef
29 changed files with 3404 additions and 624 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const response = await apiFetch(
|
||||
`${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
static async deleteDockerHost(
|
||||
hostId: string,
|
||||
options: { hide?: boolean; force?: boolean } = {}
|
||||
): Promise<DeleteDockerHostResponse> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
Supports template variables like{' '}
|
||||
<code class="font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
{'{{.Message}}'}
|
||||
</code>
|
||||
. Use{' '}
|
||||
<code class="font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
{'{{urlpath ...}}'}
|
||||
</code>{' '}
|
||||
or{' '}
|
||||
<code class="font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
{'{{urlquery ...}}'}
|
||||
</code>{' '}
|
||||
to keep dynamic values URL-safe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Payload Template - only show for generic service */}
|
||||
|
|
|
|||
|
|
@ -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<APITokenManagerProps> = (props) => {
|
||||
const { state } = useWebSocket();
|
||||
const dockerHosts = createMemo<DockerHost[]>(() => state.dockerHosts ?? []);
|
||||
const dockerTokenUsage = createMemo(() => {
|
||||
const usage = new Map<string, { count: number; hosts: string[] }>();
|
||||
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<APITokenRecord[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
|
|
@ -43,22 +66,32 @@ export const APITokenManager: Component<APITokenManagerProps> = (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<APITokenManagerProps> = (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<APITokenManagerProps> = (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<APITokenManagerProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const tokenHint = (record: APITokenRecord) => {
|
||||
if (record.prefix && record.suffix) {
|
||||
return `${record.prefix}…${record.suffix}`;
|
||||
}
|
||||
if (record.prefix) {
|
||||
return `${record.prefix}…`;
|
||||
}
|
||||
return '—';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
|
|
@ -137,63 +190,85 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300" for="api-token-name">
|
||||
Token name
|
||||
</label>
|
||||
<input
|
||||
id="api-token-name"
|
||||
type="text"
|
||||
value={nameInput()}
|
||||
onInput={(event) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating()}
|
||||
>
|
||||
{isGenerating() ? 'Generating…' : 'Generate API token'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={props.currentTokenHint && !tokens().length}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Current token hint: <span class="font-mono">{props.currentTokenHint}</span>
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* CRITICAL: Show generated token FIRST and PROMINENTLY */}
|
||||
<Show when={newTokenValue()}>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">✅ New API token generated</h4>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Save this value now – it is only shown once. Update your automation or agents immediately.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4 border-4 border-green-500 dark:border-green-600 rounded-lg p-5 bg-green-50 dark:bg-green-900/30">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-green-900 dark:text-green-100">Token Generated!</h3>
|
||||
<p class="text-sm font-semibold text-green-800 dark:text-green-200 mt-1">
|
||||
⚠️ This is shown ONCE. Copy it now or lose it forever!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-green-900 dark:text-green-100 uppercase tracking-wide">
|
||||
Your new token:
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 font-mono text-base bg-white dark:bg-gray-800 px-4 py-3 rounded-lg border-2 border-green-300 dark:border-green-700 break-all text-gray-900 dark:text-gray-100 font-bold">
|
||||
{newTokenValue()}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
class="px-5 py-3 text-sm font-bold bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors shadow-lg"
|
||||
>
|
||||
{copied() ? '✓ Copied!' : 'Copy Token'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg p-3">
|
||||
<p class="text-sm text-yellow-900 dark:text-yellow-100 font-medium">
|
||||
💡 Next: Use this token on the <a href="/settings/docker-agents" class="underline hover:no-underline font-bold">Docker Agents</a> page to deploy monitoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
|
||||
{newTokenValue()}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
onClick={() => setNewTokenValue(null)}
|
||||
class="text-sm text-green-700 dark:text-green-300 hover:underline"
|
||||
>
|
||||
{copied() ? 'Copied!' : 'Copy'}
|
||||
I've saved it, dismiss this
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate new token</h3>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="api-token-name"
|
||||
type="text"
|
||||
value={nameInput()}
|
||||
onInput={(event) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating()}
|
||||
>
|
||||
{isGenerating() ? 'Generating…' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Active tokens</h3>
|
||||
<Show
|
||||
|
|
@ -217,27 +292,61 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={tokens()}>
|
||||
{(token) => (
|
||||
<tr>
|
||||
<td class="py-2 px-3 text-gray-900 dark:text-gray-100">{token.name || 'Untitled token'}</td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">{tokenHint(token)}</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeTime(new Date(token.createdAt).getTime())}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
|
||||
{token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(token)}
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-medium text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(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 (
|
||||
<tr>
|
||||
<td class="py-2 px-3 text-gray-900 dark:text-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{token.name || 'Untitled token'}</span>
|
||||
<Show when={usage}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Docker
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={usage}>
|
||||
<div
|
||||
class="mt-1 text-xs text-blue-700 dark:text-blue-300"
|
||||
title={hostTitle}
|
||||
>
|
||||
Used by Docker {hostCountLabel}: {hostSummary}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{tokenHint(token)}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeTime(new Date(token.createdAt).getTime())}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
|
||||
{token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(token)}
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-medium text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface CommandBuilderProps {
|
|||
onTokenGenerated?: (token: string, record: APITokenRecord) => void;
|
||||
requiresToken: boolean;
|
||||
hasExistingToken?: boolean;
|
||||
canManageTokens?: boolean;
|
||||
}
|
||||
|
||||
export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||
|
|
@ -28,7 +29,9 @@ export const CommandBuilder: Component<CommandBuilderProps> = (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<CommandBuilderProps> = (props) => {
|
|||
|
||||
// Generate new token
|
||||
const generateNewToken = async () => {
|
||||
if (!canManageTokens()) return;
|
||||
if (isGenerating()) return;
|
||||
setIsGenerating(true);
|
||||
|
||||
|
|
@ -294,15 +298,22 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
|||
</div>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-400">Generate a token to secure your Docker agents.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGenerateModal}
|
||||
disabled={isGenerating()}
|
||||
class="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
{isGenerating() ? 'Generating...' : 'Generate API Token'}
|
||||
</button>
|
||||
<Show when={canManageTokens()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGenerateModal}
|
||||
disabled={isGenerating()}
|
||||
class="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
{isGenerating() ? 'Generating...' : 'Generate API Token'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!canManageTokens()}>
|
||||
<p class="mt-3 text-xs text-yellow-700 dark:text-yellow-400">
|
||||
Sign in with an administrator account to create tokens from the browser.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
@ -341,15 +352,17 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
|||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGenerateModal}
|
||||
disabled={isGenerating()}
|
||||
class="px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded hover:bg-blue-200 dark:hover:bg-blue-900/60 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
title="Generate another token for a new host or automation workflow"
|
||||
>
|
||||
Generate Token
|
||||
</button>
|
||||
<Show when={canManageTokens()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGenerateModal}
|
||||
disabled={isGenerating()}
|
||||
class="px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded hover:bg-blue-200 dark:hover:bg-blue-900/60 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
title="Generate another token for a new host or automation workflow"
|
||||
>
|
||||
Generate Token
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
|
|
@ -549,10 +562,41 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (newlyGeneratedToken()) {
|
||||
navigator.clipboard.writeText(newlyGeneratedToken()!);
|
||||
window.showToast('success', 'Token copied!');
|
||||
const value = newlyGeneratedToken();
|
||||
if (!value) return;
|
||||
|
||||
const fallbackCopy = () => {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return copied;
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyPromise =
|
||||
typeof navigator !== 'undefined' && navigator.clipboard?.writeText
|
||||
? navigator.clipboard.writeText(value).then(() => true).catch((err) => {
|
||||
console.warn('Clipboard API copy failed, falling back', err);
|
||||
return fallbackCopy();
|
||||
})
|
||||
: Promise.resolve(fallbackCopy());
|
||||
|
||||
copyPromise.then((success) => {
|
||||
if (typeof window !== 'undefined' && window.showToast) {
|
||||
window.showToast(success ? 'success' : 'error', success ? 'Token copied!' : 'Failed to copy token');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, Show, createSignal, For, createEffect } from 'solid-js';
|
||||
import { Component, Show, createSignal, For, createEffect, on } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
|
@ -29,25 +29,25 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
|
|||
const [discoveryResult, setDiscoveryResult] = createSignal<DiscoveryResult | null>(null);
|
||||
const [hasScanned, setHasScanned] = createSignal(false);
|
||||
|
||||
// Load cached results when modal opens
|
||||
createEffect(() => {
|
||||
if (props.isOpen && !hasScanned()) {
|
||||
setHasScanned(true);
|
||||
loadCachedResults();
|
||||
// Load cached results when modal opens and reset when it closes
|
||||
createEffect(on(
|
||||
() => props.isOpen,
|
||||
(isOpen) => {
|
||||
if (isOpen && !hasScanned()) {
|
||||
setHasScanned(true);
|
||||
loadCachedResults();
|
||||
} else if (!isOpen) {
|
||||
setHasScanned(false);
|
||||
// Keep cached results, don't clear them
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset scan state when modal closes
|
||||
createEffect(() => {
|
||||
if (!props.isOpen) {
|
||||
setHasScanned(false);
|
||||
// Keep cached results, don't clear them
|
||||
}
|
||||
});
|
||||
));
|
||||
|
||||
// Listen for real-time WebSocket updates when modal is open and scanning
|
||||
createEffect(() => {
|
||||
if (!props.isOpen) return;
|
||||
createEffect(on(
|
||||
() => props.isOpen,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleWsMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
|
|
@ -92,16 +92,17 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Get WebSocket from global state
|
||||
const ws = (window as any).__pulseWs;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.addEventListener('message', handleWsMessage);
|
||||
// Get WebSocket from global state
|
||||
const ws = (window as any).__pulseWs;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.addEventListener('message', handleWsMessage);
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener('message', handleWsMessage);
|
||||
};
|
||||
return () => {
|
||||
ws.removeEventListener('message', handleWsMessage);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
));
|
||||
|
||||
const loadCachedResults = async () => {
|
||||
try {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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-<branch>[...], which is expected.
|
||||
return
|
||||
}
|
||||
|
||||
if normalizeVersion(actual) != normalizeVersion(expected) {
|
||||
t.Fatalf("expected version=%s, got %s", expected, actual)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
260
internal/monitoring/docker_commands.go
Normal file
260
internal/monitoring/docker_commands.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
66
internal/updates/version_test.go
Normal file
66
internal/updates/version_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
33
pkg/agents/docker/command.go
Normal file
33
pkg/agents/docker/command.go
Normal file
|
|
@ -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"
|
||||
)
|
||||
Binary file not shown.
|
|
@ -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 <URL>/install-docker-agent.sh | sudo bash -s -- ${ORIGINAL_ARGS_STRING}"
|
||||
FALLBACK_PIPELINE_INNER="curl -fsSL <URL>/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 <URL>/install-docker-agent.sh | sudo bash -s"
|
||||
echo " sudo bash -c 'curl -fsSL <URL>/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 <URL>/install-docker-agent.sh | bash -s -- --url <URL>"
|
||||
echo " Uninstall: curl -fsSL <URL>/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 <URL>/install-docker-agent.sh | sudo bash -s -- ${ORIGINAL_ARGS_STRING}"
|
||||
FALLBACK_PIPELINE_INNER="curl -fsSL <URL>/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 <URL>/install-docker-agent.sh | sudo bash -s"
|
||||
echo " sudo bash -c 'curl -fsSL <URL>/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" <<EOF
|
||||
#!/bin/bash
|
||||
# Pulse Docker Agent - Auto-start script
|
||||
sleep 10 # Wait for Docker to be ready
|
||||
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 &
|
||||
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=<api-token> \\"
|
||||
echo " PULSE_TARGETS=\"https://pulse.example.com|<token>[;https://pulse-alt.example.com|<token2>]\" \\"
|
||||
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=<api-token> \\n' "$PRIMARY_URL"
|
||||
printf ' PULSE_TARGETS="%s" \\n' "https://pulse.example.com|<token>[;https://pulse-alt.example.com|<token2>]"
|
||||
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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue