diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 7a20cb8..6b30525 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "strings" "sync/atomic" "syscall" @@ -18,6 +19,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentupdate" "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" + "github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rs/zerolog" "golang.org/x/sync/errgroup" @@ -30,7 +32,7 @@ var ( agentInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "pulse_agent_info", Help: "Information about the Pulse agent", - }, []string{"version", "host_enabled", "docker_enabled"}) + }, []string{"version", "host_enabled", "docker_enabled", "kubernetes_enabled"}) agentUp = promauto.NewGauge(prometheus.GaugeOpts{ Name: "pulse_agent_up", @@ -78,6 +80,7 @@ func main() { Str("pulse_url", cfg.PulseURL). Bool("host_agent", cfg.EnableHost). Bool("docker_agent", cfg.EnableDocker). + Bool("kubernetes_agent", cfg.EnableKubernetes). Bool("proxmox_mode", cfg.EnableProxmox). Bool("auto_update", !cfg.DisableAutoUpdate). Msg("Starting Pulse Unified Agent") @@ -87,6 +90,7 @@ func main() { Version, fmt.Sprintf("%t", cfg.EnableHost), fmt.Sprintf("%t", cfg.EnableDocker), + fmt.Sprintf("%t", cfg.EnableKubernetes), ).Set(1) agentUp.Set(1) @@ -187,10 +191,50 @@ func main() { } } + // 10. Start Kubernetes Agent (if enabled) + if cfg.EnableKubernetes { + kubeCfg := kubernetesagent.Config{ + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + Interval: cfg.Interval, + AgentID: cfg.AgentID, + AgentType: "unified", + AgentVersion: Version, + InsecureSkipVerify: cfg.InsecureSkipVerify, + LogLevel: cfg.LogLevel, + Logger: &logger, + KubeconfigPath: cfg.KubeconfigPath, + KubeContext: cfg.KubeContext, + IncludeNamespaces: cfg.KubeIncludeNamespaces, + ExcludeNamespaces: cfg.KubeExcludeNamespaces, + IncludeAllPods: cfg.KubeIncludeAllPods, + MaxPods: cfg.KubeMaxPods, + } + + agent, err := kubernetesagent.New(kubeCfg) + if err != nil { + logger.Warn().Err(err).Msg("Kubernetes not available, will retry with exponential backoff") + + g.Go(func() error { + retried := initKubernetesWithRetry(ctx, kubeCfg, &logger) + if retried != nil { + logger.Info().Msg("Kubernetes agent module started (after retry)") + return retried.Run(ctx) + } + return nil + }) + } else { + g.Go(func() error { + logger.Info().Msg("Kubernetes agent module started") + return agent.Run(ctx) + }) + } + } + // Mark as ready after all agents started ready.Store(true) - // 10. Wait for all agents to exit + // 11. Wait for all agents to exit if err := g.Wait(); err != nil && err != context.Canceled { logger.Error().Err(err).Msg("Agent terminated with error") agentUp.Set(0) @@ -198,7 +242,7 @@ func main() { os.Exit(1) } - // 11. Cleanup + // 12. Cleanup agentUp.Set(0) cleanupDockerAgent(dockerAgent, &logger) @@ -274,16 +318,25 @@ type Config struct { Logger *zerolog.Logger // Module flags - EnableHost bool - EnableDocker bool - EnableProxmox bool - ProxmoxType string // "pve", "pbs", or "" for auto-detect + EnableHost bool + EnableDocker bool + EnableKubernetes bool + EnableProxmox bool + ProxmoxType string // "pve", "pbs", or "" for auto-detect // Auto-update DisableAutoUpdate bool // Health/metrics server HealthAddr string + + // Kubernetes + KubeconfigPath string + KubeContext string + KubeIncludeNamespaces []string + KubeExcludeNamespaces []string + KubeIncludeAllPods bool + KubeMaxPods int } func loadConfig() Config { @@ -298,10 +351,17 @@ func loadConfig() Config { envLogLevel := utils.GetenvTrim("LOG_LEVEL") envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST") envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER") + envEnableKubernetes := utils.GetenvTrim("PULSE_ENABLE_KUBERNETES") envEnableProxmox := utils.GetenvTrim("PULSE_ENABLE_PROXMOX") envProxmoxType := utils.GetenvTrim("PULSE_PROXMOX_TYPE") envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE") envHealthAddr := utils.GetenvTrim("PULSE_HEALTH_ADDR") + envKubeconfig := utils.GetenvTrim("PULSE_KUBECONFIG") + envKubeContext := utils.GetenvTrim("PULSE_KUBE_CONTEXT") + envKubeIncludeNamespaces := utils.GetenvTrim("PULSE_KUBE_INCLUDE_NAMESPACES") + envKubeExcludeNamespaces := utils.GetenvTrim("PULSE_KUBE_EXCLUDE_NAMESPACES") + envKubeIncludeAllPods := utils.GetenvTrim("PULSE_KUBE_INCLUDE_ALL_PODS") + envKubeMaxPods := utils.GetenvTrim("PULSE_KUBE_MAX_PODS") // Defaults defaultInterval := 30 * time.Second @@ -321,6 +381,11 @@ func loadConfig() Config { defaultEnableDocker = utils.ParseBool(envEnableDocker) } + defaultEnableKubernetes := false + if envEnableKubernetes != "" { + defaultEnableKubernetes = utils.ParseBool(envEnableKubernetes) + } + defaultEnableProxmox := false if envEnableProxmox != "" { defaultEnableProxmox = utils.ParseBool(envEnableProxmox) @@ -342,14 +407,23 @@ func loadConfig() Config { enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module") enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module") + enableKubernetesFlag := flag.Bool("enable-kubernetes", defaultEnableKubernetes, "Enable Kubernetes Agent module") enableProxmoxFlag := flag.Bool("enable-proxmox", defaultEnableProxmox, "Enable Proxmox mode (creates API token, registers node)") proxmoxTypeFlag := flag.String("proxmox-type", envProxmoxType, "Proxmox type: pve or pbs (auto-detected if not specified)") disableAutoUpdateFlag := flag.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates") healthAddrFlag := flag.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)") + kubeconfigFlag := flag.String("kubeconfig", envKubeconfig, "Path to kubeconfig (optional; uses in-cluster config if available)") + kubeContextFlag := flag.String("kube-context", envKubeContext, "Kubeconfig context (optional)") + kubeIncludeAllPodsFlag := flag.Bool("kube-include-all-pods", utils.ParseBool(envKubeIncludeAllPods), "Include all non-succeeded pods (may be large)") + kubeMaxPodsFlag := flag.Int("kube-max-pods", defaultInt(envKubeMaxPods, 200), "Max pods included in report") showVersion := flag.Bool("version", false, "Print the agent version and exit") var tagFlags multiValue flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)") + var kubeIncludeNamespaceFlags multiValue + flag.Var(&kubeIncludeNamespaceFlags, "kube-include-namespace", "Namespace to include (repeatable; default is all)") + var kubeExcludeNamespaceFlags multiValue + flag.Var(&kubeExcludeNamespaceFlags, "kube-exclude-namespace", "Namespace to exclude (repeatable)") flag.Parse() @@ -376,22 +450,31 @@ func loadConfig() Config { } tags := gatherTags(envTags, tagFlags) + kubeIncludeNamespaces := gatherCSV(envKubeIncludeNamespaces, kubeIncludeNamespaceFlags) + kubeExcludeNamespaces := gatherCSV(envKubeExcludeNamespaces, kubeExcludeNamespaceFlags) return Config{ - PulseURL: pulseURL, - APIToken: token, - Interval: *intervalFlag, - HostnameOverride: strings.TrimSpace(*hostnameFlag), - AgentID: strings.TrimSpace(*agentIDFlag), - Tags: tags, - InsecureSkipVerify: *insecureFlag, - LogLevel: logLevel, - EnableHost: *enableHostFlag, - EnableDocker: *enableDockerFlag, - EnableProxmox: *enableProxmoxFlag, - ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag), - DisableAutoUpdate: *disableAutoUpdateFlag, - HealthAddr: strings.TrimSpace(*healthAddrFlag), + PulseURL: pulseURL, + APIToken: token, + Interval: *intervalFlag, + HostnameOverride: strings.TrimSpace(*hostnameFlag), + AgentID: strings.TrimSpace(*agentIDFlag), + Tags: tags, + InsecureSkipVerify: *insecureFlag, + LogLevel: logLevel, + EnableHost: *enableHostFlag, + EnableDocker: *enableDockerFlag, + EnableKubernetes: *enableKubernetesFlag, + EnableProxmox: *enableProxmoxFlag, + ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag), + DisableAutoUpdate: *disableAutoUpdateFlag, + HealthAddr: strings.TrimSpace(*healthAddrFlag), + KubeconfigPath: strings.TrimSpace(*kubeconfigFlag), + KubeContext: strings.TrimSpace(*kubeContextFlag), + KubeIncludeNamespaces: kubeIncludeNamespaces, + KubeExcludeNamespaces: kubeExcludeNamespaces, + KubeIncludeAllPods: *kubeIncludeAllPodsFlag, + KubeMaxPods: *kubeMaxPodsFlag, } } @@ -414,6 +497,37 @@ func gatherTags(env string, flags []string) []string { return tags } +func gatherCSV(env string, flags []string) []string { + values := make([]string, 0) + if env != "" { + for _, value := range strings.Split(env, ",") { + value = strings.TrimSpace(value) + if value != "" { + values = append(values, value) + } + } + } + for _, value := range flags { + value = strings.TrimSpace(value) + if value != "" { + values = append(values, value) + } + } + return values +} + +func defaultInt(value string, fallback int) int { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + func parseLogLevel(value string) (zerolog.Level, error) { normalized := strings.ToLower(strings.TrimSpace(value)) if normalized == "" { @@ -484,3 +598,58 @@ func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *ze } } } + +// initKubernetesWithRetry attempts to initialize the Kubernetes agent with exponential backoff. +// It returns the agent when Kubernetes becomes available, or nil if the context is cancelled. +// Retry intervals: 5s, 10s, 20s, 40s, 80s, 160s, then cap at 5 minutes. +func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, logger *zerolog.Logger) *kubernetesagent.Agent { + const ( + initialDelay = 5 * time.Second + maxDelay = 5 * time.Minute + multiplier = 2.0 + ) + + delay := initialDelay + attempt := 0 + + for { + select { + case <-ctx.Done(): + logger.Info().Msg("Kubernetes retry cancelled, context done") + return nil + default: + } + + attempt++ + logger.Info(). + Int("attempt", attempt). + Str("next_retry", delay.String()). + Msg("Waiting to retry Kubernetes connection") + + select { + case <-ctx.Done(): + logger.Info().Msg("Kubernetes retry cancelled during wait") + return nil + case <-time.After(delay): + } + + agent, err := kubernetesagent.New(cfg) + if err == nil { + logger.Info(). + Int("attempts", attempt). + Msg("Successfully connected to Kubernetes after retry") + return agent + } + + logger.Warn(). + Err(err). + Int("attempt", attempt). + Str("next_retry", (time.Duration(float64(delay) * multiplier)).String()). + Msg("Kubernetes still not available, will retry") + + delay = time.Duration(float64(delay) * multiplier) + if delay > maxDelay { + delay = maxDelay + } + } +} diff --git a/docs/API.md b/docs/API.md index 7fcda81..d9b06ff 100644 --- a/docs/API.md +++ b/docs/API.md @@ -146,7 +146,7 @@ Initiate OIDC login flow. `GET /download/pulse-agent` Downloads the unified agent binary for the current platform. -The unified agent combines host and Docker monitoring. Use `--enable-docker` to enable Docker metrics. +The unified agent combines host, Docker, and Kubernetes monitoring. Use `--enable-docker` or `--enable-kubernetes` to enable additional metrics. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md) for installation instructions. @@ -157,6 +157,7 @@ See [UNIFIED_AGENT.md](UNIFIED_AGENT.md) for installation instructions. ### Submit Reports `POST /api/agents/host/report` - Host metrics `POST /api/agents/docker/report` - Docker container metrics +`POST /api/agents/kubernetes/report` - Kubernetes cluster metrics --- diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 6b0447e..72700a8 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -1,6 +1,6 @@ # Pulse Unified Agent -The unified agent (`pulse-agent`) combines host and Docker monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management. +The unified agent (`pulse-agent`) combines host, Docker, and Kubernetes monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management. ## Quick Start @@ -29,6 +29,7 @@ curl -fsSL http://:7655/install.sh | \ - **Host Metrics**: CPU, memory, disk, network I/O, temperatures - **Docker Monitoring**: Container metrics, health checks, Swarm support (when enabled) +- **Kubernetes Monitoring**: Cluster, node, pod, and deployment health (when enabled) - **Auto-Update**: Automatically updates when a new version is released - **Multi-Platform**: Linux, macOS, Windows support @@ -41,6 +42,13 @@ curl -fsSL http://:7655/install.sh | \ | `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` | | `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` | | `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` | +| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Enable Kubernetes metrics | `false` | +| `--kubeconfig` | `PULSE_KUBECONFIG` | Kubeconfig path (optional) | *(auto)* | +| `--kube-context` | `PULSE_KUBE_CONTEXT` | Kubeconfig context (optional) | *(auto)* | +| `--kube-include-namespace` | `PULSE_KUBE_INCLUDE_NAMESPACES` | Limit namespaces (repeatable or CSV) | *(all)* | +| `--kube-exclude-namespace` | `PULSE_KUBE_EXCLUDE_NAMESPACES` | Exclude namespaces (repeatable or CSV) | *(none)* | +| `--kube-include-all-pods` | `PULSE_KUBE_INCLUDE_ALL_PODS` | Include all non-succeeded pods | `false` | +| `--kube-max-pods` | `PULSE_KUBE_MAX_PODS` | Max pods per report | `200` | | `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` | | `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` | | `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* | @@ -61,6 +69,12 @@ curl -fsSL http://:7655/install.sh | \ bash -s -- --url http://:7655 --token --enable-docker ``` +### Host + Kubernetes Monitoring +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token --enable-kubernetes +``` + ### Docker Monitoring Only ```bash curl -fsSL http://:7655/install.sh | \ diff --git a/frontend-modern/src/api/__tests__/charts.test.ts b/frontend-modern/src/api/__tests__/charts.test.ts index b50c7dd..79d1411 100644 --- a/frontend-modern/src/api/__tests__/charts.test.ts +++ b/frontend-modern/src/api/__tests__/charts.test.ts @@ -1,7 +1,7 @@ /** * Tests for Charts API types and interface */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { ChartData, ChartsResponse, TimeRange, MetricPoint, ChartStats } from '@/api/charts'; // Note: We test the types and interfaces here since the actual API calls diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index 04a140e..6a9eaf4 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -216,6 +216,191 @@ export class MonitoringAPI { } } + static async deleteKubernetesCluster(clusterId: string): Promise { + const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}`; + + const response = await apiFetch(url, { + method: 'DELETE', + }); + + if (!response.ok) { + if (response.status === 404) { + 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 parse errors + } + } + } catch (_err) { + // ignore read error + } + + throw new Error(message); + } + + if (response.status === 204) { + return {}; + } + + const text = await response.text(); + if (!text?.trim()) { + return {}; + } + + try { + return JSON.parse(text) as DeleteKubernetesClusterResponse; + } catch (err) { + throw new Error((err as Error).message || 'Failed to parse delete kubernetes cluster response'); + } + } + + static async unhideKubernetesCluster(clusterId: string): Promise { + const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/unhide`; + + const response = await apiFetch(url, { + method: 'PUT', + }); + + if (!response.ok) { + if (response.status === 404) { + 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 parse errors + } + } + } catch (_err) { + // ignore read error + } + + throw new Error(message); + } + } + + static async markKubernetesClusterPendingUninstall(clusterId: string): Promise { + const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/pending-uninstall`; + + const response = await apiFetch(url, { + method: 'PUT', + }); + + if (!response.ok) { + if (response.status === 404) { + 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 parse errors + } + } + } catch (_err) { + // ignore read error + } + + throw new Error(message); + } + } + + static async setKubernetesClusterDisplayName(clusterId: string, displayName: string): Promise { + const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/display-name`; + + const response = await apiFetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ displayName }), + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Kubernetes cluster not found'); + } + + 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 parse errors + } + } + } catch (_err) { + // ignore read error + } + + throw new Error(message); + } + } + + static async allowKubernetesClusterReenroll(clusterId: string): Promise { + const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/allow-reenroll`; + + const response = await apiFetch(url, { + method: 'POST', + }); + + if (!response.ok) { + 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 (_err) { + // ignore parse error + } + } + } catch (_err) { + // ignore read error + } + + throw new Error(message); + } + } + static async deleteHostAgent(hostId: string): Promise { if (!hostId) { throw new Error('Host ID is required to remove a host agent.'); @@ -313,3 +498,9 @@ export interface DeleteDockerHostResponse { message?: string; command?: DockerHostCommand; } + +export interface DeleteKubernetesClusterResponse { + success?: boolean; + clusterId?: string; + message?: string; +} diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx index ac9195c..d161c6d 100644 --- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -8,7 +8,7 @@ import { notificationStore } from '@/stores/notifications'; import type { SecurityStatus } from '@/types/config'; import type { HostLookupResponse } from '@/types/api'; import type { APITokenRecord } from '@/api/security'; -import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; +import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE } from '@/constants/apiScopes'; import { copyToClipboard } from '@/utils/clipboard'; import { getPulseBaseUrl } from '@/utils/url'; import { logger } from '@/utils/logger'; @@ -108,6 +108,7 @@ export const UnifiedAgents: Component = () => { const [lookupError, setLookupError] = createSignal(null); const [lookupLoading, setLookupLoading] = createSignal(false); const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring + const [enableKubernetes, setEnableKubernetes] = createSignal(false); // Default to false - user must opt-in for Kubernetes monitoring const [enableProxmox, setEnableProxmox] = createSignal(false); // For Proxmox VE/PBS nodes - creates API token and auto-registers const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806) @@ -183,15 +184,15 @@ export const UnifiedAgents: Component = () => { setIsGeneratingToken(true); try { const desiredName = tokenName().trim() || buildDefaultTokenName(); - // Generate token with BOTH scopes - const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE]; + // Generate token with unified agent reporting scopes + const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE]; const { token, record } = await SecurityAPI.createToken(desiredName, scopes); setCurrentToken(token); setLatestRecord(record); setTokenName(''); setConfirmedNoToken(false); - notificationStore.success('Token generated with Host and Docker permissions.', 4000); + notificationStore.success('Token generated with Host, Docker, and Kubernetes permissions.', 4000); } catch (err) { logger.error('Failed to generate agent token', err); notificationStore.error('Failed to generate agent token. Confirm you are signed in as an administrator.', 6000); @@ -237,6 +238,7 @@ export const UnifiedAgents: Component = () => { }; const getDockerFlag = () => enableDocker() ? ' --enable-docker' : ''; + const getKubernetesFlag = () => enableKubernetes() ? ' --enable-kubernetes' : ''; const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : ''; const getInsecureFlag = () => insecureMode() ? ' --insecure' : ''; const getCurlInsecureFlag = () => insecureMode() ? '-k' : ''; @@ -344,19 +346,32 @@ export const UnifiedAgents: Component = () => { }); const hasRemovedDockerHosts = createMemo(() => removedDockerHosts().length > 0); + const kubernetesClusters = createMemo(() => { + const clusters = state.kubernetesClusters || []; + return clusters.slice().sort((a, b) => (a.displayName || a.name || a.id).localeCompare(b.displayName || b.name || b.id)); + }); + + const removedKubernetesClusters = createMemo(() => { + const removed = state.removedKubernetesClusters || []; + return removed.sort((a, b) => b.removedAt - a.removedAt); + }); + const hasRemovedKubernetesClusters = createMemo(() => removedKubernetesClusters().length > 0); + const getUpgradeCommand = (_hostname: string) => { const token = resolvedToken(); return `curl ${getCurlInsecureFlag()}-fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${token}${getInsecureFlag()}`; }; - const handleRemoveAgent = async (id: string, type: 'host' | 'docker') => { + const handleRemoveAgent = async (id: string, type: 'host' | 'docker' | 'kubernetes') => { if (!confirm('Are you sure you want to remove this agent? This will stop monitoring but will not uninstall the agent from the remote machine.')) return; try { if (type === 'host') { await MonitoringAPI.deleteHostAgent(id); - } else { + } else if (type === 'docker') { await MonitoringAPI.deleteDockerHost(id); + } else { + await MonitoringAPI.deleteKubernetesCluster(id); } notificationStore.success('Agent removed from Pulse'); } catch (err) { @@ -375,6 +390,16 @@ export const UnifiedAgents: Component = () => { } }; + const handleAllowKubernetesReenroll = async (clusterId: string, name?: string) => { + try { + await MonitoringAPI.allowKubernetesClusterReenroll(clusterId); + notificationStore.success(`Re-enrollment allowed for ${name || clusterId}. Restart the agent to reconnect.`); + } catch (err) { + logger.error('Failed to allow kubernetes re-enrollment', err); + notificationStore.error('Failed to allow kubernetes re-enrollment'); + } + }; + return (
@@ -391,7 +416,7 @@ export const UnifiedAgents: Component = () => {

Generate API token

- Create a fresh token scoped for both Host and Docker monitoring. + Create a fresh token scoped for Host, Docker, and Kubernetes monitoring.

@@ -466,6 +491,15 @@ export const UnifiedAgents: Component = () => { /> Docker monitoring +
+ +
+

Kubernetes Clusters

+

+ Kubernetes clusters currently reporting to Pulse. +

+
+ + + + + + + + + + + + + + + + + }> + {(cluster) => ( + + + + + + + + )} + + +
ClusterStatusVersionLast SeenActions
+ No Kubernetes clusters reporting yet. +
+ {cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id} + + + {cluster.status} + + + {cluster.version || cluster.agentVersion || '—'} + + {cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'} + + +
+
+
+
@@ -848,6 +952,55 @@ export const UnifiedAgents: Component = () => { + + + +
+

Removed Kubernetes Clusters

+

+ Kubernetes clusters that were removed and are blocked from re-enrolling. Allow re-enrollment to let them report again. +

+
+ + + + + + + + + + + + + + {(cluster) => ( + + + + + + + )} + + +
ClusterCluster IDRemovedActions
+ {cluster.displayName || cluster.name || 'Unknown'} + + {cluster.id.slice(0, 8)}... + + {formatRelativeTime(cluster.removedAt)} + + +
+
+
+
); }; diff --git a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx index 88562cf..c54492a 100644 --- a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx @@ -177,7 +177,7 @@ describe('UnifiedAgents token generation', () => { { interval: 0 }, ); expect(notificationSuccessMock).toHaveBeenCalledWith( - 'Token generated with Host and Docker permissions.', + 'Token generated with Host, Docker, and Kubernetes permissions.', 4000, ); }); diff --git a/frontend-modern/src/components/shared/Sparkline.tsx b/frontend-modern/src/components/shared/Sparkline.tsx index f745a3c..6fc3394 100644 --- a/frontend-modern/src/components/shared/Sparkline.tsx +++ b/frontend-modern/src/components/shared/Sparkline.tsx @@ -187,16 +187,10 @@ export const Sparkline: Component = (props) => { // Redraw when data or dimensions change createEffect(() => { - // Read reactive values to track dependencies - // This ensures the effect re-runs when data, metric, or dimensions change - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _data = props.data; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _metric = props.metric; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _w = width(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _h = height(); + void props.data; + void props.metric; + void width(); + void height(); // Unregister previous draw callback if it exists if (unregister) { diff --git a/frontend-modern/src/constants/apiScopes.ts b/frontend-modern/src/constants/apiScopes.ts index b078858..f2e5daa 100644 --- a/frontend-modern/src/constants/apiScopes.ts +++ b/frontend-modern/src/constants/apiScopes.ts @@ -8,6 +8,8 @@ export interface APIScopeOption { export const HOST_AGENT_SCOPE = 'host-agent:report'; export const DOCKER_REPORT_SCOPE = 'docker:report'; export const DOCKER_MANAGE_SCOPE = 'docker:manage'; +export const KUBERNETES_REPORT_SCOPE = 'kubernetes:report'; +export const KUBERNETES_MANAGE_SCOPE = 'kubernetes:manage'; export const MONITORING_READ_SCOPE = 'monitoring:read'; export const MONITORING_WRITE_SCOPE = 'monitoring:write'; export const SETTINGS_READ_SCOPE = 'settings:read'; @@ -38,6 +40,18 @@ export const API_SCOPE_OPTIONS: APIScopeOption[] = [ description: 'Enable agent-triggered runtime commands and host actions.', group: 'Agents', }, + { + value: KUBERNETES_REPORT_SCOPE, + label: 'Kubernetes agent reporting', + description: 'Allow the Kubernetes agent to submit cluster, node, and workload telemetry.', + group: 'Agents', + }, + { + value: KUBERNETES_MANAGE_SCOPE, + label: 'Kubernetes cluster management', + description: 'Allow administrative actions for Kubernetes cluster agents.', + group: 'Agents', + }, { value: HOST_AGENT_SCOPE, label: 'Host agent reporting', diff --git a/frontend-modern/src/stores/__tests__/metricsHistory.test.ts b/frontend-modern/src/stores/__tests__/metricsHistory.test.ts index 15790b5..65b9191 100644 --- a/frontend-modern/src/stores/__tests__/metricsHistory.test.ts +++ b/frontend-modern/src/stores/__tests__/metricsHistory.test.ts @@ -3,7 +3,7 @@ * * These tests focus on the ring buffer operations and metric recording logic. */ -import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; // We can't directly test the module due to side effects (localStorage, window), // so we test the core logic patterns used in the implementation. diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index f8d81d4..3e281d5 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -8,6 +8,8 @@ export interface State { containers: Container[]; dockerHosts: DockerHost[]; removedDockerHosts?: RemovedDockerHost[]; + kubernetesClusters?: KubernetesCluster[]; + removedKubernetesClusters?: RemovedKubernetesCluster[]; hosts: Host[]; replicationJobs: ReplicationJob[]; storage: Storage[]; @@ -38,6 +40,34 @@ export interface RemovedDockerHost { removedAt: number; } +export interface KubernetesCluster { + id: string; + agentId: string; + name?: string; + displayName?: string; + customDisplayName?: string; + server?: string; + context?: string; + version?: string; + status: string; + lastSeen: number; + intervalSeconds: number; + agentVersion?: string; + tokenId?: string; + tokenName?: string; + tokenHint?: string; + tokenLastUsedAt?: number; + hidden?: boolean; + pendingUninstall?: boolean; +} + +export interface RemovedKubernetesCluster { + id: string; + name?: string; + displayName?: string; + removedAt: number; +} + export interface Node { id: string; name: string; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 24df59c..78d1ae8 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -12,6 +12,7 @@ export type ResourceType = | 'node' // Proxmox VE node | 'host' // Standalone host (via host-agent) | 'docker-host' // Docker/Podman host + | 'k8s-cluster' // Kubernetes cluster | 'k8s-node' // Kubernetes node | 'truenas' // TrueNAS system | 'vm' // Proxmox VM @@ -134,7 +135,7 @@ export interface Resource { * Helper type guards */ export function isInfrastructure(r: Resource): boolean { - return ['node', 'host', 'docker-host', 'k8s-node', 'truenas'].includes(r.type); + return ['node', 'host', 'docker-host', 'k8s-cluster', 'k8s-node', 'truenas'].includes(r.type); } export function isWorkload(r: Resource): boolean { diff --git a/go.mod b/go.mod index 2fdd729..be0e260 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/rs/zerolog v1.34.0 github.com/shirou/gopsutil/v4 v4.25.11 github.com/spf13/cobra v1.10.1 - github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.45.0 golang.org/x/oauth2 v0.33.0 golang.org/x/sync v0.18.0 @@ -27,6 +26,10 @@ require ( golang.org/x/term v0.37.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.32.0 + k8s.io/apimachinery v0.32.0 + k8s.io/client-go v0.32.0 + modernc.org/sqlite v1.40.1 ) require ( @@ -36,34 +39,48 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect @@ -71,6 +88,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect @@ -80,11 +98,20 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/text v0.31.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gotest.tools/v3 v3.5.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.40.1 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index fcd51f9..46a8ff4 100644 Binary files a/go.sum and b/go.sum differ diff --git a/internal/api/kubernetes_agents.go b/internal/api/kubernetes_agents.go new file mode 100644 index 0000000..44ab243 --- /dev/null +++ b/internal/api/kubernetes_agents.go @@ -0,0 +1,286 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" + "github.com/rs/zerolog/log" +) + +// KubernetesAgentHandlers manages ingest from the Kubernetes agent. +type KubernetesAgentHandlers struct { + monitor *monitoring.Monitor + wsHub *websocket.Hub +} + +// NewKubernetesAgentHandlers constructs a new Kubernetes agent handler group. +func NewKubernetesAgentHandlers(m *monitoring.Monitor, hub *websocket.Hub) *KubernetesAgentHandlers { + return &KubernetesAgentHandlers{monitor: m, wsHub: hub} +} + +// SetMonitor updates the monitor reference for kubernetes agent handlers. +func (h *KubernetesAgentHandlers) SetMonitor(m *monitoring.Monitor) { + h.monitor = m +} + +// HandleReport accepts heartbeat payloads from the Kubernetes agent. +func (h *KubernetesAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) + return + } + + // Limit request body to 2MB to prevent memory exhaustion (pods can be sizable). + r.Body = http.MaxBytesReader(w, r.Body, 2*1024*1024) + defer r.Body.Close() + + var report agentsk8s.Report + if err := json.NewDecoder(r.Body).Decode(&report); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()}) + return + } + + if report.Timestamp.IsZero() { + report.Timestamp = time.Now() + } + + tokenRecord := getAPITokenRecordFromRequest(r) + + cluster, err := h.monitor.ApplyKubernetesReport(report, tokenRecord) + if err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil) + return + } + + log.Debug(). + Str("k8sClusterID", cluster.ID). + Str("k8sClusterName", cluster.Name). + Int("nodes", len(cluster.Nodes)). + Int("pods", len(cluster.Pods)). + Int("deployments", len(cluster.Deployments)). + Msg("Kubernetes agent report processed") + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "clusterId": cluster.ID, + "nodes": len(cluster.Nodes), + "pods": len(cluster.Pods), + "deployments": len(cluster.Deployments), + "lastSeen": cluster.LastSeen, + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize kubernetes agent response") + } +} + +// HandleClusterActions routes kubernetes cluster management actions based on path and method. +func (h *KubernetesAgentHandlers) HandleClusterActions(w http.ResponseWriter, r *http.Request) { + // Allow reenroll request + if strings.HasSuffix(r.URL.Path, "/allow-reenroll") && r.Method == http.MethodPost { + h.HandleAllowReenroll(w, r) + return + } + + // Unhide request + if strings.HasSuffix(r.URL.Path, "/unhide") && r.Method == http.MethodPut { + h.HandleUnhideCluster(w, r) + return + } + + // Pending uninstall request + if strings.HasSuffix(r.URL.Path, "/pending-uninstall") && r.Method == http.MethodPut { + h.HandleMarkPendingUninstall(w, r) + return + } + + // Custom display name update request + if strings.HasSuffix(r.URL.Path, "/display-name") && r.Method == http.MethodPut { + h.HandleSetCustomDisplayName(w, r) + return + } + + // Delete/hide request + if r.Method == http.MethodDelete { + h.HandleDeleteCluster(w, r) + return + } + + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) +} + +// HandleDeleteCluster removes and blocks a cluster from re-enrolling. +func (h *KubernetesAgentHandlers) HandleDeleteCluster(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil) + return + } + + trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/kubernetes/clusters/") + clusterID := strings.TrimSpace(trimmedPath) + clusterID = strings.TrimSuffix(clusterID, "/") + if clusterID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_cluster_id", "Kubernetes cluster ID is required", nil) + return + } + + cluster, err := h.monitor.RemoveKubernetesCluster(clusterID) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "kubernetes_cluster_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "clusterId": cluster.ID, + "message": "Kubernetes cluster removed", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize kubernetes cluster operation response") + } +} + +// HandleAllowReenroll clears the removal block for a cluster to permit future reports. +func (h *KubernetesAgentHandlers) 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/kubernetes/clusters/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/allow-reenroll") + clusterID := strings.TrimSpace(trimmedPath) + if clusterID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_cluster_id", "Kubernetes cluster ID is required", nil) + return + } + + if err := h.monitor.AllowKubernetesClusterReenroll(clusterID); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "kubernetes_cluster_reenroll_failed", err.Error(), nil) + return + } + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "clusterId": clusterID, + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize kubernetes cluster allow reenroll response") + } +} + +// HandleUnhideCluster unhides a previously hidden kubernetes cluster. +func (h *KubernetesAgentHandlers) HandleUnhideCluster(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/kubernetes/clusters/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/unhide") + clusterID := strings.TrimSpace(trimmedPath) + if clusterID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_cluster_id", "Kubernetes cluster ID is required", nil) + return + } + + cluster, err := h.monitor.UnhideKubernetesCluster(clusterID) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "kubernetes_cluster_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "clusterId": cluster.ID, + "message": "Kubernetes cluster unhidden", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize kubernetes cluster unhide response") + } +} + +// HandleMarkPendingUninstall marks a cluster as pending uninstall. +func (h *KubernetesAgentHandlers) 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/kubernetes/clusters/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/pending-uninstall") + clusterID := strings.TrimSpace(trimmedPath) + if clusterID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_cluster_id", "Kubernetes cluster ID is required", nil) + return + } + + cluster, err := h.monitor.MarkKubernetesClusterPendingUninstall(clusterID) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "kubernetes_cluster_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "clusterId": cluster.ID, + "message": "Kubernetes cluster marked as pending uninstall", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize kubernetes cluster pending uninstall response") + } +} + +// HandleSetCustomDisplayName updates the custom display name for a kubernetes cluster. +func (h *KubernetesAgentHandlers) HandleSetCustomDisplayName(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/kubernetes/clusters/") + trimmedPath = strings.TrimSuffix(trimmedPath, "/display-name") + clusterID := strings.TrimSpace(trimmedPath) + if clusterID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_cluster_id", "Kubernetes cluster ID is required", nil) + return + } + + // Limit request body to 8KB to prevent memory exhaustion + r.Body = http.MaxBytesReader(w, r.Body, 8*1024) + defer r.Body.Close() + + var req struct { + DisplayName string `json:"displayName"` + } + 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 + } + + customName := strings.TrimSpace(req.DisplayName) + + cluster, err := h.monitor.SetKubernetesClusterCustomDisplayName(clusterID, customName) + if err != nil { + writeErrorResponse(w, http.StatusNotFound, "kubernetes_cluster_not_found", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "clusterId": cluster.ID, + "message": "Kubernetes cluster custom display name updated", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize kubernetes cluster custom display name response") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index a9b0bd3..39c8c76 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -51,6 +51,7 @@ type Router struct { notificationHandlers *NotificationHandlers notificationQueueHandlers *NotificationQueueHandlers dockerAgentHandlers *DockerAgentHandlers + kubernetesAgentHandlers *KubernetesAgentHandlers hostAgentHandlers *HostAgentHandlers temperatureProxyHandlers *TemperatureProxyHandlers systemSettingsHandler *SystemSettingsHandler @@ -188,6 +189,7 @@ func (r *Router) setupRoutes() { r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings) updateHandlers := NewUpdateHandlers(r.updateManager, r.updateHistory) r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub) + r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.monitor, r.wsHub) r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub) r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.config, r.persistence, r.reloadFunc) r.resourceHandlers = NewResourceHandlers() @@ -197,6 +199,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/monitoring/scheduler/health", RequireAuth(r.config, r.handleSchedulerHealth)) r.mux.HandleFunc("/api/state", r.handleState) r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport))) + r.mux.HandleFunc("/api/agents/kubernetes/report", RequireAuth(r.config, RequireScope(config.ScopeKubernetesReport, r.kubernetesAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup))) r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost))) @@ -207,6 +210,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/temperature-proxy/host-status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleHostProxyStatus))) r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) + r.mux.HandleFunc("/api/agents/kubernetes/clusters/", RequireAdmin(r.config, RequireScope(config.ScopeKubernetesManage, r.kubernetesAgentHandlers.HandleClusterActions))) r.mux.HandleFunc("/api/version", r.handleVersion) r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage))) r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts))) @@ -1130,7 +1134,7 @@ func (r *Router) setupRoutes() { })) r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule)) r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings)) - + // AI Intelligence endpoints - expose learned patterns, correlations, and predictions r.mux.HandleFunc("/api/ai/intelligence/patterns", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatterns)) r.mux.HandleFunc("/api/ai/intelligence/predictions", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPredictions)) @@ -1444,13 +1448,13 @@ func (r *Router) StartPatrol(ctx context.Context) { } } } - + // Initialize operational memory (change detection and remediation logging) dataDir := "" if r.persistence != nil { dataDir = r.persistence.DataDir() } - + changeDetector := ai.NewChangeDetector(ai.ChangeDetectorConfig{ MaxChanges: 1000, DataDir: dataDir, @@ -1458,7 +1462,7 @@ func (r *Router) StartPatrol(ctx context.Context) { if changeDetector != nil { r.aiSettingsHandler.SetChangeDetector(changeDetector) } - + remediationLog := ai.NewRemediationLog(ai.RemediationLogConfig{ MaxRecords: 500, DataDir: dataDir, @@ -1466,7 +1470,7 @@ func (r *Router) StartPatrol(ctx context.Context) { if remediationLog != nil { r.aiSettingsHandler.SetRemediationLog(remediationLog) } - + // Initialize pattern detector for failure prediction patternDetector := ai.NewPatternDetector(ai.PatternDetectorConfig{ MaxEvents: 5000, @@ -1477,7 +1481,7 @@ func (r *Router) StartPatrol(ctx context.Context) { }) if patternDetector != nil { r.aiSettingsHandler.SetPatternDetector(patternDetector) - + // Wire alert history to pattern detector for event tracking if alertManager := r.monitor.GetAlertManager(); alertManager != nil { alertManager.OnAlertHistory(func(alert alerts.Alert) { @@ -1487,7 +1491,7 @@ func (r *Router) StartPatrol(ctx context.Context) { log.Info().Msg("AI Pattern Detector: Wired to alert history for failure prediction") } } - + // Initialize correlation detector for multi-resource relationships correlationDetector := ai.NewCorrelationDetector(ai.CorrelationConfig{ MaxEvents: 10000, @@ -1498,7 +1502,7 @@ func (r *Router) StartPatrol(ctx context.Context) { }) if correlationDetector != nil { r.aiSettingsHandler.SetCorrelationDetector(correlationDetector) - + // Wire alert history to correlation detector if alertManager := r.monitor.GetAlertManager(); alertManager != nil { alertManager.OnAlertHistory(func(alert alerts.Alert) { diff --git a/internal/config/api_tokens.go b/internal/config/api_tokens.go index d08e7c3..c5b8539 100644 --- a/internal/config/api_tokens.go +++ b/internal/config/api_tokens.go @@ -11,15 +11,17 @@ import ( // Canonical API token scope strings. const ( - ScopeWildcard = "*" - ScopeMonitoringRead = "monitoring:read" - ScopeMonitoringWrite = "monitoring:write" - ScopeDockerReport = "docker:report" - ScopeDockerManage = "docker:manage" - ScopeHostReport = "host-agent:report" - ScopeHostManage = "host-agent:manage" - ScopeSettingsRead = "settings:read" - ScopeSettingsWrite = "settings:write" + ScopeWildcard = "*" + ScopeMonitoringRead = "monitoring:read" + ScopeMonitoringWrite = "monitoring:write" + ScopeDockerReport = "docker:report" + ScopeDockerManage = "docker:manage" + ScopeKubernetesReport = "kubernetes:report" + ScopeKubernetesManage = "kubernetes:manage" + ScopeHostReport = "host-agent:report" + ScopeHostManage = "host-agent:manage" + ScopeSettingsRead = "settings:read" + ScopeSettingsWrite = "settings:write" ) // AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel). @@ -28,6 +30,8 @@ var AllKnownScopes = []string{ ScopeMonitoringWrite, ScopeDockerReport, ScopeDockerManage, + ScopeKubernetesReport, + ScopeKubernetesManage, ScopeHostReport, ScopeHostManage, ScopeSettingsRead, diff --git a/internal/config/api_tokens_test.go b/internal/config/api_tokens_test.go index 3d861e9..8b94b70 100644 --- a/internal/config/api_tokens_test.go +++ b/internal/config/api_tokens_test.go @@ -167,6 +167,8 @@ func TestIsKnownScope(t *testing.T) { {name: "monitoring:write", scope: ScopeMonitoringWrite, expected: true}, {name: "docker:report", scope: ScopeDockerReport, expected: true}, {name: "docker:manage", scope: ScopeDockerManage, expected: true}, + {name: "kubernetes:report", scope: ScopeKubernetesReport, expected: true}, + {name: "kubernetes:manage", scope: ScopeKubernetesManage, expected: true}, {name: "host-agent:report", scope: ScopeHostReport, expected: true}, {name: "host-agent:manage", scope: ScopeHostManage, expected: true}, {name: "settings:read", scope: ScopeSettingsRead, expected: true}, diff --git a/internal/kubernetesagent/agent.go b/internal/kubernetesagent/agent.go new file mode 100644 index 0000000..29e0f01 --- /dev/null +++ b/internal/kubernetesagent/agent.go @@ -0,0 +1,683 @@ +package kubernetesagent + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/buffer" + agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" + "github.com/rs/zerolog" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +type Config struct { + PulseURL string + APIToken string + Interval time.Duration + AgentID string + AgentType string // "unified" when running as part of pulse-agent + AgentVersion string // Version to report; if empty, uses kubernetesagent.Version + InsecureSkipVerify bool + LogLevel zerolog.Level + Logger *zerolog.Logger + + // Kubernetes connection + KubeconfigPath string + KubeContext string + + // Report shaping + IncludeNamespaces []string + ExcludeNamespaces []string + IncludeAllPods bool // Include all non-succeeded pods (still capped) + MaxPods int // Max pods included in the report +} + +type Agent struct { + cfg Config + logger zerolog.Logger + httpClient *http.Client + + kubeClient *kubernetes.Clientset + restCfg *rest.Config + + agentID string + agentVersion string + interval time.Duration + pulseURL string + + clusterID string + clusterName string + clusterServer string + clusterContext string + clusterVersion string + + includeNamespaces sets.Set[string] + excludeNamespaces sets.Set[string] + + reportBuffer *buffer.Queue[agentsk8s.Report] +} + +const ( + defaultInterval = 30 * time.Second + defaultMaxPods = 200 + requestTimeout = 20 * time.Second + reportUserAgent = "pulse-kubernetes-agent/" +) + +func New(cfg Config) (*Agent, error) { + if cfg.Interval <= 0 { + cfg.Interval = defaultInterval + } + if cfg.MaxPods <= 0 { + cfg.MaxPods = defaultMaxPods + } + + if zerolog.GlobalLevel() == zerolog.DebugLevel && cfg.LogLevel != zerolog.DebugLevel { + zerolog.SetGlobalLevel(cfg.LogLevel) + } + + logger := cfg.Logger + if logger == nil { + defaultLogger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Str("component", "pulse-kubernetes-agent").Logger() + logger = &defaultLogger + } else { + scoped := logger.With().Str("component", "pulse-kubernetes-agent").Logger() + logger = &scoped + } + + if strings.TrimSpace(cfg.APIToken) == "" { + return nil, fmt.Errorf("api token is required") + } + + pulseURL := strings.TrimRight(strings.TrimSpace(cfg.PulseURL), "/") + if pulseURL == "" { + pulseURL = "http://localhost:7655" + } + + restCfg, contextName, err := buildRESTConfig(cfg.KubeconfigPath, cfg.KubeContext) + if err != nil { + return nil, err + } + + kubeClient, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return nil, fmt.Errorf("create kubernetes client: %w", err) + } + + agentVersion := strings.TrimSpace(cfg.AgentVersion) + if agentVersion == "" { + agentVersion = Version + } + + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if cfg.InsecureSkipVerify { + //nolint:gosec // Insecure mode is explicitly user-controlled. + tlsConfig.InsecureSkipVerify = true + } + httpClient := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsConfig, + }, + } + + clusterServer := strings.TrimSpace(restCfg.Host) + clusterContext := strings.TrimSpace(contextName) + clusterName := clusterContext + clusterID := computeClusterID(clusterServer, clusterContext, clusterName) + + agentID := strings.TrimSpace(cfg.AgentID) + if agentID == "" { + agentID = clusterID + } + + agent := &Agent{ + cfg: cfg, + logger: *logger, + httpClient: httpClient, + kubeClient: kubeClient, + restCfg: restCfg, + agentID: agentID, + agentVersion: agentVersion, + interval: cfg.Interval, + pulseURL: pulseURL, + clusterID: clusterID, + clusterName: clusterName, + clusterServer: clusterServer, + clusterContext: clusterContext, + includeNamespaces: makeNamespaceSet(cfg.IncludeNamespaces), + excludeNamespaces: makeNamespaceSet(cfg.ExcludeNamespaces), + reportBuffer: buffer.New[agentsk8s.Report](60), + } + + if err := agent.discoverClusterMetadata(context.Background()); err != nil { + agent.logger.Warn().Err(err).Msg("Failed to discover Kubernetes cluster metadata") + } + + agent.logger.Info(). + Str("cluster_id", agent.clusterID). + Str("cluster_name", agent.clusterName). + Str("server", agent.clusterServer). + Str("context", agent.clusterContext). + Msg("Kubernetes agent initialized") + + return agent, nil +} + +func buildRESTConfig(kubeconfigPath, kubeContext string) (*rest.Config, string, error) { + kubeconfigPath = strings.TrimSpace(kubeconfigPath) + kubeContext = strings.TrimSpace(kubeContext) + + // Prefer explicit kubeconfig. + if kubeconfigPath != "" { + loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath} + overrides := &clientcmd.ConfigOverrides{} + if kubeContext != "" { + overrides.CurrentContext = kubeContext + } + + cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) + rawCfg, err := cc.RawConfig() + if err != nil { + return nil, "", fmt.Errorf("load kubeconfig: %w", err) + } + + contextName := rawCfg.CurrentContext + if kubeContext != "" { + contextName = kubeContext + } + + restCfg, err := cc.ClientConfig() + if err != nil { + return nil, "", fmt.Errorf("build kubeconfig rest config: %w", err) + } + return restCfg, contextName, nil + } + + // Otherwise try in-cluster configuration. + restCfg, err := rest.InClusterConfig() + if err == nil { + return restCfg, "in-cluster", nil + } + + // Fallback: default kubeconfig path. + cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{CurrentContext: kubeContext}, + ) + rawCfg, rawErr := cc.RawConfig() + if rawErr != nil { + return nil, "", fmt.Errorf("kubernetes config not available (in-cluster failed: %v; kubeconfig failed: %w)", err, rawErr) + } + + contextName := rawCfg.CurrentContext + if kubeContext != "" { + contextName = kubeContext + } + + restCfg, cfgErr := cc.ClientConfig() + if cfgErr != nil { + return nil, "", fmt.Errorf("build kubeconfig rest config: %w", cfgErr) + } + return restCfg, contextName, nil +} + +func makeNamespaceSet(values []string) sets.Set[string] { + set := sets.New[string]() + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + set.Insert(v) + } + } + return set +} + +func computeClusterID(server, context, name string) string { + payload := strings.TrimSpace(server) + "|" + strings.TrimSpace(context) + "|" + strings.TrimSpace(name) + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} + +func (a *Agent) discoverClusterMetadata(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + version, err := a.kubeClient.Discovery().ServerVersion() + if err != nil { + return err + } + if version != nil { + a.clusterVersion = strings.TrimSpace(version.GitVersion) + } + return nil +} + +func (a *Agent) Run(ctx context.Context) error { + ticker := time.NewTicker(a.interval) + defer ticker.Stop() + + a.runOnce(ctx) + + for { + select { + case <-ticker.C: + a.runOnce(ctx) + case <-ctx.Done(): + return nil + } + } +} + +func (a *Agent) runOnce(ctx context.Context) { + a.flushReports(ctx) + + report, err := a.collectReport(ctx) + if err != nil { + a.logger.Warn().Err(err).Msg("Failed to collect Kubernetes report") + return + } + + if err := a.sendReport(ctx, report); err != nil { + a.logger.Warn().Err(err).Msg("Failed to send Kubernetes report, buffering") + a.reportBuffer.Push(report) + } +} + +func (a *Agent) flushReports(ctx context.Context) { + for { + report, ok := a.reportBuffer.Peek() + if !ok { + return + } + if err := a.sendReport(ctx, report); err != nil { + return + } + _, _ = a.reportBuffer.Pop() + } +} + +func (a *Agent) namespaceAllowed(ns string) bool { + ns = strings.TrimSpace(ns) + if ns == "" { + return false + } + if a.excludeNamespaces.Has(ns) { + return false + } + if a.includeNamespaces.Len() == 0 { + return true + } + return a.includeNamespaces.Has(ns) +} + +func (a *Agent) collectReport(ctx context.Context) (agentsk8s.Report, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + nodes, err := a.collectNodes(ctx) + if err != nil { + return agentsk8s.Report{}, err + } + + pods, err := a.collectPods(ctx) + if err != nil { + return agentsk8s.Report{}, err + } + + deployments, err := a.collectDeployments(ctx) + if err != nil { + return agentsk8s.Report{}, err + } + + return agentsk8s.Report{ + Agent: agentsk8s.AgentInfo{ + ID: a.agentID, + Version: a.agentVersion, + Type: strings.TrimSpace(a.cfg.AgentType), + IntervalSeconds: int(a.interval / time.Second), + }, + Cluster: agentsk8s.ClusterInfo{ + ID: a.clusterID, + Name: a.clusterName, + Server: a.clusterServer, + Context: a.clusterContext, + Version: a.clusterVersion, + }, + Nodes: nodes, + Pods: pods, + Deployments: deployments, + Timestamp: time.Now().UTC(), + }, nil +} + +func (a *Agent) collectNodes(ctx context.Context) ([]agentsk8s.Node, error) { + list, err := a.kubeClient.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + + nodes := make([]agentsk8s.Node, 0, len(list.Items)) + for _, node := range list.Items { + ready := isNodeReady(node) + nodes = append(nodes, agentsk8s.Node{ + UID: string(node.UID), + Name: node.Name, + Ready: ready, + Unschedulable: node.Spec.Unschedulable, + KubeletVersion: node.Status.NodeInfo.KubeletVersion, + ContainerRuntimeVersion: node.Status.NodeInfo.ContainerRuntimeVersion, + OSImage: node.Status.NodeInfo.OSImage, + KernelVersion: node.Status.NodeInfo.KernelVersion, + Architecture: node.Status.NodeInfo.Architecture, + Capacity: toNodeResources(node.Status.Capacity), + Allocatable: toNodeResources(node.Status.Allocatable), + Roles: rolesFromNodeLabels(node.Labels), + }) + } + + sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name }) + return nodes, nil +} + +func isNodeReady(node corev1.Node) bool { + for _, cond := range node.Status.Conditions { + if cond.Type == corev1.NodeReady && cond.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +func rolesFromNodeLabels(nodeLabels map[string]string) []string { + roles := make([]string, 0, 4) + for k := range nodeLabels { + if strings.HasPrefix(k, "node-role.kubernetes.io/") { + role := strings.TrimPrefix(k, "node-role.kubernetes.io/") + role = strings.TrimSpace(role) + if role == "" { + role = "master" + } + roles = append(roles, role) + } + } + if v := strings.TrimSpace(nodeLabels["kubernetes.io/role"]); v != "" { + roles = append(roles, v) + } + roles = dedupeStrings(roles) + sort.Strings(roles) + return roles +} + +func dedupeStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func toNodeResources(list corev1.ResourceList) agentsk8s.NodeResources { + cpu := list[corev1.ResourceCPU] + mem := list[corev1.ResourceMemory] + pods := list[corev1.ResourcePods] + + return agentsk8s.NodeResources{ + CPUCores: cpu.MilliValue() / 1000, + MemoryBytes: mem.Value(), + Pods: pods.Value(), + } +} + +func (a *Agent) collectPods(ctx context.Context) ([]agentsk8s.Pod, error) { + // Default: focus on non-succeeded pods to reduce payload size. + selector := fields.OneTermNotEqualSelector("status.phase", string(corev1.PodSucceeded)) + listOpts := metav1.ListOptions{FieldSelector: selector.String()} + + podList, err := a.kubeClient.CoreV1().Pods(metav1.NamespaceAll).List(ctx, listOpts) + if err != nil { + return nil, fmt.Errorf("list pods: %w", err) + } + + items := make([]agentsk8s.Pod, 0) + for _, pod := range podList.Items { + if !a.namespaceAllowed(pod.Namespace) { + continue + } + if !a.cfg.IncludeAllPods && !isProblemPod(pod) { + continue + } + + labelsCopy := make(map[string]string, len(pod.Labels)) + for k, v := range pod.Labels { + labelsCopy[k] = v + } + + containers := make([]agentsk8s.PodContainer, 0, len(pod.Status.ContainerStatuses)) + restarts := 0 + for _, cs := range pod.Status.ContainerStatuses { + restarts += int(cs.RestartCount) + state, reason, message := summarizeContainerState(cs) + containers = append(containers, agentsk8s.PodContainer{ + Name: cs.Name, + Image: cs.Image, + Ready: cs.Ready, + RestartCount: cs.RestartCount, + State: state, + Reason: reason, + Message: message, + }) + } + + ownerKind, ownerName := ownerRef(pod.OwnerReferences) + createdAt := pod.CreationTimestamp.Time + var startTime *time.Time + if pod.Status.StartTime != nil { + t := pod.Status.StartTime.Time + startTime = &t + } + + items = append(items, agentsk8s.Pod{ + UID: string(pod.UID), + Name: pod.Name, + Namespace: pod.Namespace, + NodeName: pod.Spec.NodeName, + Phase: string(pod.Status.Phase), + Reason: pod.Status.Reason, + Message: pod.Status.Message, + QoSClass: string(pod.Status.QOSClass), + CreatedAt: createdAt, + StartTime: startTime, + Restarts: restarts, + Labels: labelsCopy, + OwnerKind: ownerKind, + OwnerName: ownerName, + Containers: containers, + }) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Namespace == items[j].Namespace { + return items[i].Name < items[j].Name + } + return items[i].Namespace < items[j].Namespace + }) + + if len(items) > a.cfg.MaxPods { + items = items[:a.cfg.MaxPods] + } + + return items, nil +} + +func isProblemPod(pod corev1.Pod) bool { + switch pod.Status.Phase { + case corev1.PodPending, corev1.PodFailed, corev1.PodUnknown: + return true + } + + for _, cs := range pod.Status.InitContainerStatuses { + if cs.State.Waiting != nil || cs.State.Terminated != nil { + return true + } + if !cs.Ready && (cs.State.Running == nil) { + return true + } + } + + for _, cs := range pod.Status.ContainerStatuses { + if cs.State.Waiting != nil || cs.State.Terminated != nil { + return true + } + if !cs.Ready { + return true + } + } + + return false +} + +func summarizeContainerState(cs corev1.ContainerStatus) (string, string, string) { + switch { + case cs.State.Running != nil: + return "running", "", "" + case cs.State.Waiting != nil: + return "waiting", strings.TrimSpace(cs.State.Waiting.Reason), strings.TrimSpace(cs.State.Waiting.Message) + case cs.State.Terminated != nil: + return "terminated", strings.TrimSpace(cs.State.Terminated.Reason), strings.TrimSpace(cs.State.Terminated.Message) + default: + return "unknown", "", "" + } +} + +func ownerRef(refs []metav1.OwnerReference) (string, string) { + for _, r := range refs { + if r.Controller != nil && *r.Controller { + return r.Kind, r.Name + } + } + if len(refs) > 0 { + return refs[0].Kind, refs[0].Name + } + return "", "" +} + +func (a *Agent) collectDeployments(ctx context.Context) ([]agentsk8s.Deployment, error) { + depList, err := a.kubeClient.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list deployments: %w", err) + } + + items := make([]agentsk8s.Deployment, 0) + for _, dep := range depList.Items { + if !a.namespaceAllowed(dep.Namespace) { + continue + } + if !isProblemDeployment(dep) { + continue + } + + labelsCopy := make(map[string]string, len(dep.Labels)) + for k, v := range dep.Labels { + labelsCopy[k] = v + } + + items = append(items, agentsk8s.Deployment{ + UID: string(dep.UID), + Name: dep.Name, + Namespace: dep.Namespace, + DesiredReplicas: desiredReplicas(dep), + UpdatedReplicas: dep.Status.UpdatedReplicas, + ReadyReplicas: dep.Status.ReadyReplicas, + AvailableReplicas: dep.Status.AvailableReplicas, + Labels: labelsCopy, + }) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Namespace == items[j].Namespace { + return items[i].Name < items[j].Name + } + return items[i].Namespace < items[j].Namespace + }) + + return items, nil +} + +func desiredReplicas(dep appsv1.Deployment) int32 { + if dep.Spec.Replicas == nil { + return 0 + } + return *dep.Spec.Replicas +} + +func isProblemDeployment(dep appsv1.Deployment) bool { + desired := desiredReplicas(dep) + if desired <= 0 { + return false + } + return dep.Status.AvailableReplicas < desired || dep.Status.ReadyReplicas < desired || dep.Status.UpdatedReplicas < desired +} + +func (a *Agent) sendReport(ctx context.Context, report agentsk8s.Report) error { + payload, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("marshal report: %w", err) + } + + url := fmt.Sprintf("%s/api/agents/kubernetes/report", a.pulseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.cfg.APIToken) + req.Header.Set("X-API-Token", a.cfg.APIToken) + req.Header.Set("User-Agent", reportUserAgent+a.agentVersion) + + resp, err := a.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + msg := strings.TrimSpace(string(body)) + if msg != "" { + return fmt.Errorf("pulse responded with status %s: %s", resp.Status, msg) + } + return fmt.Errorf("pulse responded with status %s", resp.Status) + } + + return nil +} diff --git a/internal/kubernetesagent/version.go b/internal/kubernetesagent/version.go new file mode 100644 index 0000000..f78fa92 --- /dev/null +++ b/internal/kubernetesagent/version.go @@ -0,0 +1,4 @@ +package kubernetesagent + +// Version is overridden at build time. +var Version = "dev" diff --git a/internal/models/converters.go b/internal/models/converters.go index bfbad1d..13f8cad 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -311,6 +311,151 @@ func (r RemovedDockerHost) ToFrontend() RemovedDockerHostFrontend { } } +// ToFrontend converts a KubernetesCluster to its frontend representation. +func (c KubernetesCluster) ToFrontend() KubernetesClusterFrontend { + cluster := KubernetesClusterFrontend{ + ID: c.ID, + AgentID: c.AgentID, + Name: c.Name, + DisplayName: c.DisplayName, + CustomDisplayName: c.CustomDisplayName, + Server: c.Server, + Context: c.Context, + Version: c.Version, + Status: c.Status, + LastSeen: c.LastSeen.Unix() * 1000, + IntervalSeconds: c.IntervalSeconds, + AgentVersion: c.AgentVersion, + TokenID: c.TokenID, + TokenName: c.TokenName, + TokenHint: c.TokenHint, + Hidden: c.Hidden, + PendingUninstall: c.PendingUninstall, + } + + if c.TokenLastUsedAt != nil { + ts := c.TokenLastUsedAt.Unix() * 1000 + cluster.TokenLastUsedAt = &ts + } + + if len(c.Nodes) > 0 { + cluster.Nodes = make([]KubernetesNodeFrontend, len(c.Nodes)) + for i, n := range c.Nodes { + cluster.Nodes[i] = KubernetesNodeFrontend{ + UID: n.UID, + Name: n.Name, + Ready: n.Ready, + Unschedulable: n.Unschedulable, + KubeletVersion: n.KubeletVersion, + ContainerRuntimeVersion: n.ContainerRuntimeVersion, + OSImage: n.OSImage, + KernelVersion: n.KernelVersion, + Architecture: n.Architecture, + CapacityCPU: n.CapacityCPU, + CapacityMemoryBytes: n.CapacityMemoryBytes, + CapacityPods: n.CapacityPods, + AllocCPU: n.AllocCPU, + AllocMemoryBytes: n.AllocMemoryBytes, + AllocPods: n.AllocPods, + Roles: append([]string(nil), n.Roles...), + } + } + } + + if len(c.Pods) > 0 { + cluster.Pods = make([]KubernetesPodFrontend, len(c.Pods)) + for i, p := range c.Pods { + labels := make(map[string]string, len(p.Labels)) + for k, v := range p.Labels { + labels[k] = v + } + containers := make([]KubernetesPodContainerFrontend, 0, len(p.Containers)) + for _, cn := range p.Containers { + containers = append(containers, KubernetesPodContainerFrontend{ + Name: cn.Name, + Image: cn.Image, + Ready: cn.Ready, + RestartCount: cn.RestartCount, + State: cn.State, + Reason: cn.Reason, + Message: cn.Message, + }) + } + + var startTime *int64 + if p.StartTime != nil { + ts := p.StartTime.Unix() * 1000 + startTime = &ts + } + + cluster.Pods[i] = KubernetesPodFrontend{ + UID: p.UID, + Name: p.Name, + Namespace: p.Namespace, + NodeName: p.NodeName, + Phase: p.Phase, + Reason: p.Reason, + Message: p.Message, + QoSClass: p.QoSClass, + CreatedAt: timeToUnixMillis(p.CreatedAt), + StartTime: startTime, + Restarts: p.Restarts, + Labels: labels, + OwnerKind: p.OwnerKind, + OwnerName: p.OwnerName, + Containers: containers, + } + } + } + + if len(c.Deployments) > 0 { + cluster.Deployments = make([]KubernetesDeploymentFrontend, len(c.Deployments)) + for i, d := range c.Deployments { + labels := make(map[string]string, len(d.Labels)) + for k, v := range d.Labels { + labels[k] = v + } + cluster.Deployments[i] = KubernetesDeploymentFrontend{ + UID: d.UID, + Name: d.Name, + Namespace: d.Namespace, + DesiredReplicas: d.DesiredReplicas, + UpdatedReplicas: d.UpdatedReplicas, + ReadyReplicas: d.ReadyReplicas, + AvailableReplicas: d.AvailableReplicas, + Labels: labels, + } + } + } + + if cluster.DisplayName == "" && cluster.Name != "" { + cluster.DisplayName = cluster.Name + } + + if cluster.DisplayName == "" { + cluster.DisplayName = cluster.ID + } + + return cluster +} + +func timeToUnixMillis(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() * 1000 +} + +// ToFrontend converts a RemovedKubernetesCluster to its frontend representation. +func (r RemovedKubernetesCluster) ToFrontend() RemovedKubernetesClusterFrontend { + return RemovedKubernetesClusterFrontend{ + ID: r.ID, + Name: r.Name, + DisplayName: r.DisplayName, + RemovedAt: r.RemovedAt.Unix() * 1000, + } +} + // ToFrontend converts a Host to HostFrontend. func (h Host) ToFrontend() HostFrontend { host := HostFrontend{ diff --git a/internal/models/models.go b/internal/models/models.go index 75a026a..cf1294b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -11,30 +11,32 @@ import ( // State represents the current state of all monitored resources type State struct { mu sync.RWMutex - Nodes []Node `json:"nodes"` - VMs []VM `json:"vms"` - Containers []Container `json:"containers"` - DockerHosts []DockerHost `json:"dockerHosts"` - RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` - Hosts []Host `json:"hosts"` - Storage []Storage `json:"storage"` - CephClusters []CephCluster `json:"cephClusters"` - PhysicalDisks []PhysicalDisk `json:"physicalDisks"` - PBSInstances []PBSInstance `json:"pbs"` - PMGInstances []PMGInstance `json:"pmg"` - PBSBackups []PBSBackup `json:"pbsBackups"` - PMGBackups []PMGBackup `json:"pmgBackups"` - Backups Backups `json:"backups"` - ReplicationJobs []ReplicationJob `json:"replicationJobs"` - Metrics []Metric `json:"metrics"` - PVEBackups PVEBackups `json:"pveBackups"` - Performance Performance `json:"performance"` - ConnectionHealth map[string]bool `json:"connectionHealth"` - Stats Stats `json:"stats"` - ActiveAlerts []Alert `json:"activeAlerts"` - RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` - LastUpdate time.Time `json:"lastUpdate"` - TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + Nodes []Node `json:"nodes"` + VMs []VM `json:"vms"` + Containers []Container `json:"containers"` + DockerHosts []DockerHost `json:"dockerHosts"` + RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` + KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"` + RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"` + Hosts []Host `json:"hosts"` + Storage []Storage `json:"storage"` + CephClusters []CephCluster `json:"cephClusters"` + PhysicalDisks []PhysicalDisk `json:"physicalDisks"` + PBSInstances []PBSInstance `json:"pbs"` + PMGInstances []PMGInstance `json:"pmg"` + PBSBackups []PBSBackup `json:"pbsBackups"` + PMGBackups []PMGBackup `json:"pmgBackups"` + Backups Backups `json:"backups"` + ReplicationJobs []ReplicationJob `json:"replicationJobs"` + Metrics []Metric `json:"metrics"` + PVEBackups PVEBackups `json:"pveBackups"` + Performance Performance `json:"performance"` + ConnectionHealth map[string]bool `json:"connectionHealth"` + Stats Stats `json:"stats"` + ActiveAlerts []Alert `json:"activeAlerts"` + RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` + LastUpdate time.Time `json:"lastUpdate"` + TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` } // Alert represents an active alert (simplified for State) @@ -228,22 +230,22 @@ type HostRAIDDevice struct { // HostCephCluster represents Ceph cluster status collected directly by the host agent. // This is separate from CephCluster which comes from the Proxmox API. type HostCephCluster struct { - FSID string `json:"fsid"` - Health HostCephHealth `json:"health"` - MonMap HostCephMonitorMap `json:"monMap,omitempty"` - MgrMap HostCephManagerMap `json:"mgrMap,omitempty"` - OSDMap HostCephOSDMap `json:"osdMap"` - PGMap HostCephPGMap `json:"pgMap"` - Pools []HostCephPool `json:"pools,omitempty"` - Services []HostCephService `json:"services,omitempty"` - CollectedAt time.Time `json:"collectedAt"` + FSID string `json:"fsid"` + Health HostCephHealth `json:"health"` + MonMap HostCephMonitorMap `json:"monMap,omitempty"` + MgrMap HostCephManagerMap `json:"mgrMap,omitempty"` + OSDMap HostCephOSDMap `json:"osdMap"` + PGMap HostCephPGMap `json:"pgMap"` + Pools []HostCephPool `json:"pools,omitempty"` + Services []HostCephService `json:"services,omitempty"` + CollectedAt time.Time `json:"collectedAt"` } // HostCephHealth represents Ceph cluster health status. type HostCephHealth struct { - Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR - Checks map[string]HostCephCheck `json:"checks,omitempty"` - Summary []HostCephHealthSummary `json:"summary,omitempty"` + Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR + Checks map[string]HostCephCheck `json:"checks,omitempty"` + Summary []HostCephHealthSummary `json:"summary,omitempty"` } // HostCephCheck represents a health check detail. @@ -464,6 +466,101 @@ type DockerContainerMount struct { Driver string `json:"driver,omitempty"` } +// KubernetesCluster represents a Kubernetes cluster reporting telemetry via the agent. +type KubernetesCluster struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Name string `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + CustomDisplayName string `json:"customDisplayName,omitempty"` + Server string `json:"server,omitempty"` + Context string `json:"context,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status"` + LastSeen time.Time `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds"` + AgentVersion string `json:"agentVersion,omitempty"` + + Nodes []KubernetesNode `json:"nodes,omitempty"` + Pods []KubernetesPod `json:"pods,omitempty"` + Deployments []KubernetesDeployment `json:"deployments,omitempty"` + + // Token information + 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"` +} + +// RemovedKubernetesCluster tracks a Kubernetes cluster that was deliberately removed and blocked from reporting. +type RemovedKubernetesCluster struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + RemovedAt time.Time `json:"removedAt"` +} + +type KubernetesNode struct { + UID string `json:"uid"` + Name string `json:"name"` + Ready bool `json:"ready"` + Unschedulable bool `json:"unschedulable,omitempty"` + KubeletVersion string `json:"kubeletVersion,omitempty"` + ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"` + OSImage string `json:"osImage,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + CapacityCPU int64 `json:"capacityCpuCores,omitempty"` + CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"` + CapacityPods int64 `json:"capacityPods,omitempty"` + AllocCPU int64 `json:"allocatableCpuCores,omitempty"` + AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"` + AllocPods int64 `json:"allocatablePods,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +type KubernetesPod struct { + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + NodeName string `json:"nodeName,omitempty"` + Phase string `json:"phase,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + QoSClass string `json:"qosClass,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + StartTime *time.Time `json:"startTime,omitempty"` + Restarts int `json:"restarts,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + OwnerKind string `json:"ownerKind,omitempty"` + OwnerName string `json:"ownerName,omitempty"` + Containers []KubernetesPodContainer `json:"containers,omitempty"` +} + +type KubernetesPodContainer struct { + Name string `json:"name"` + Image string `json:"image,omitempty"` + Ready bool `json:"ready"` + RestartCount int32 `json:"restartCount,omitempty"` + State string `json:"state,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +type KubernetesDeployment struct { + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + UpdatedReplicas int32 `json:"updatedReplicas,omitempty"` + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + AvailableReplicas int32 `json:"availableReplicas,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + // DockerService summarises a Docker Swarm service. type DockerService struct { ID string `json:"id"` @@ -1577,6 +1674,185 @@ func (s *State) GetRemovedDockerHosts() []RemovedDockerHost { return entries } +// UpsertKubernetesCluster inserts or updates a Kubernetes cluster in state. +func (s *State) UpsertKubernetesCluster(cluster KubernetesCluster) { + s.mu.Lock() + defer s.mu.Unlock() + + updated := false + for i, existing := range s.KubernetesClusters { + if existing.ID == cluster.ID { + if existing.CustomDisplayName != "" { + cluster.CustomDisplayName = existing.CustomDisplayName + } + cluster.Hidden = existing.Hidden + cluster.PendingUninstall = existing.PendingUninstall + s.KubernetesClusters[i] = cluster + updated = true + break + } + } + + if !updated { + s.KubernetesClusters = append(s.KubernetesClusters, cluster) + } + + sort.Slice(s.KubernetesClusters, func(i, j int) bool { + left := s.KubernetesClusters[i].Name + right := s.KubernetesClusters[j].Name + if left == "" { + left = s.KubernetesClusters[i].ID + } + if right == "" { + right = s.KubernetesClusters[j].ID + } + return left < right + }) + + s.LastUpdate = time.Now() +} + +// RemoveKubernetesCluster removes a Kubernetes cluster by ID and returns the removed cluster. +func (s *State) RemoveKubernetesCluster(clusterID string) (KubernetesCluster, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, cluster := range s.KubernetesClusters { + if cluster.ID == clusterID { + s.KubernetesClusters = append(s.KubernetesClusters[:i], s.KubernetesClusters[i+1:]...) + s.LastUpdate = time.Now() + return cluster, true + } + } + + return KubernetesCluster{}, false +} + +// SetKubernetesClusterStatus updates the status of a kubernetes cluster if present. +func (s *State) SetKubernetesClusterStatus(clusterID, status string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + changed := false + for i, cluster := range s.KubernetesClusters { + if cluster.ID == clusterID { + if cluster.Status != status { + cluster.Status = status + s.KubernetesClusters[i] = cluster + s.LastUpdate = time.Now() + } + changed = true + break + } + } + return changed +} + +// SetKubernetesClusterHidden updates the hidden status of a kubernetes cluster and returns the updated cluster. +func (s *State) SetKubernetesClusterHidden(clusterID string, hidden bool) (KubernetesCluster, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, cluster := range s.KubernetesClusters { + if cluster.ID == clusterID { + cluster.Hidden = hidden + s.KubernetesClusters[i] = cluster + s.LastUpdate = time.Now() + return cluster, true + } + } + return KubernetesCluster{}, false +} + +// SetKubernetesClusterPendingUninstall updates the pending uninstall flag and returns the updated cluster. +func (s *State) SetKubernetesClusterPendingUninstall(clusterID string, pending bool) (KubernetesCluster, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, cluster := range s.KubernetesClusters { + if cluster.ID == clusterID { + cluster.PendingUninstall = pending + s.KubernetesClusters[i] = cluster + s.LastUpdate = time.Now() + return cluster, true + } + } + return KubernetesCluster{}, false +} + +// SetKubernetesClusterCustomDisplayName updates the custom display name for a kubernetes cluster. +func (s *State) SetKubernetesClusterCustomDisplayName(clusterID string, customName string) (KubernetesCluster, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, cluster := range s.KubernetesClusters { + if cluster.ID == clusterID { + cluster.CustomDisplayName = customName + s.KubernetesClusters[i] = cluster + s.LastUpdate = time.Now() + return cluster, true + } + } + + return KubernetesCluster{}, false +} + +// GetKubernetesClusters returns a copy of kubernetes clusters. +func (s *State) GetKubernetesClusters() []KubernetesCluster { + s.mu.RLock() + defer s.mu.RUnlock() + + clusters := make([]KubernetesCluster, len(s.KubernetesClusters)) + copy(clusters, s.KubernetesClusters) + return clusters +} + +// AddRemovedKubernetesCluster records a removed kubernetes cluster entry. +func (s *State) AddRemovedKubernetesCluster(entry RemovedKubernetesCluster) { + s.mu.Lock() + defer s.mu.Unlock() + + replaced := false + for i, existing := range s.RemovedKubernetesClusters { + if existing.ID == entry.ID { + s.RemovedKubernetesClusters[i] = entry + replaced = true + break + } + } + if !replaced { + s.RemovedKubernetesClusters = append(s.RemovedKubernetesClusters, entry) + } + sort.Slice(s.RemovedKubernetesClusters, func(i, j int) bool { + return s.RemovedKubernetesClusters[i].RemovedAt.After(s.RemovedKubernetesClusters[j].RemovedAt) + }) + s.LastUpdate = time.Now() +} + +// RemoveRemovedKubernetesCluster deletes a removed kubernetes cluster entry by ID. +func (s *State) RemoveRemovedKubernetesCluster(clusterID string) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, entry := range s.RemovedKubernetesClusters { + if entry.ID == clusterID { + s.RemovedKubernetesClusters = append(s.RemovedKubernetesClusters[:i], s.RemovedKubernetesClusters[i+1:]...) + s.LastUpdate = time.Now() + break + } + } +} + +// GetRemovedKubernetesClusters returns a copy of removed kubernetes cluster entries. +func (s *State) GetRemovedKubernetesClusters() []RemovedKubernetesCluster { + s.mu.RLock() + defer s.mu.RUnlock() + + entries := make([]RemovedKubernetesCluster, len(s.RemovedKubernetesClusters)) + copy(entries, s.RemovedKubernetesClusters) + return entries +} + // UpsertHost inserts or updates a generic host in state. func (s *State) UpsertHost(host Host) { s.mu.Lock() @@ -1654,7 +1930,6 @@ func (s *State) UpsertCephCluster(cluster CephCluster) { s.LastUpdate = time.Now() } - // SetHostStatus updates the status of a host if present. func (s *State) SetHostStatus(hostID, status string) bool { s.mu.Lock() diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index a90f11b..c7d0d59 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -68,16 +68,16 @@ type VMFrontend struct { // ContainerFrontend represents a Container with frontend-friendly field names type ContainerFrontend struct { - ID string `json:"id"` - VMID int `json:"vmid"` - Name string `json:"name"` - Node string `json:"node"` - Instance string `json:"instance"` - Status string `json:"status"` - Type string `json:"type"` + ID string `json:"id"` + VMID int `json:"vmid"` + Name string `json:"name"` + Node string `json:"node"` + Instance string `json:"instance"` + Status string `json:"status"` + Type string `json:"type"` // OCI container support (Proxmox VE 9.1+) - IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container - OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest") + IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container + OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest") CPU float64 `json:"cpu"` CPUs int `json:"cpus"` Memory *Memory `json:"memory,omitempty"` // Full memory object @@ -146,6 +146,98 @@ type RemovedDockerHostFrontend struct { RemovedAt int64 `json:"removedAt"` } +// KubernetesClusterFrontend represents a Kubernetes cluster for the frontend. +type KubernetesClusterFrontend struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Name string `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + CustomDisplayName string `json:"customDisplayName,omitempty"` + Server string `json:"server,omitempty"` + Context string `json:"context,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status"` + LastSeen int64 `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds"` + AgentVersion string `json:"agentVersion,omitempty"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` + Hidden bool `json:"hidden"` + PendingUninstall bool `json:"pendingUninstall"` + + Nodes []KubernetesNodeFrontend `json:"nodes,omitempty"` + Pods []KubernetesPodFrontend `json:"pods,omitempty"` + Deployments []KubernetesDeploymentFrontend `json:"deployments,omitempty"` +} + +type KubernetesNodeFrontend struct { + UID string `json:"uid"` + Name string `json:"name"` + Ready bool `json:"ready"` + Unschedulable bool `json:"unschedulable,omitempty"` + KubeletVersion string `json:"kubeletVersion,omitempty"` + ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"` + OSImage string `json:"osImage,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + CapacityCPU int64 `json:"capacityCpuCores,omitempty"` + CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"` + CapacityPods int64 `json:"capacityPods,omitempty"` + AllocCPU int64 `json:"allocatableCpuCores,omitempty"` + AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"` + AllocPods int64 `json:"allocatablePods,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +type KubernetesPodFrontend struct { + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + NodeName string `json:"nodeName,omitempty"` + Phase string `json:"phase,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + QoSClass string `json:"qosClass,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + StartTime *int64 `json:"startTime,omitempty"` + Restarts int `json:"restarts,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + OwnerKind string `json:"ownerKind,omitempty"` + OwnerName string `json:"ownerName,omitempty"` + Containers []KubernetesPodContainerFrontend `json:"containers,omitempty"` +} + +type KubernetesPodContainerFrontend struct { + Name string `json:"name"` + Image string `json:"image,omitempty"` + Ready bool `json:"ready"` + RestartCount int32 `json:"restartCount,omitempty"` + State string `json:"state,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +type KubernetesDeploymentFrontend struct { + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + UpdatedReplicas int32 `json:"updatedReplicas,omitempty"` + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + AvailableReplicas int32 `json:"availableReplicas,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +// RemovedKubernetesClusterFrontend represents a blocked kubernetes cluster entry for the frontend. +type RemovedKubernetesClusterFrontend struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + RemovedAt int64 `json:"removedAt"` +} + // DockerContainerFrontend represents a Docker container for the frontend type DockerContainerFrontend struct { ID string `json:"id"` @@ -426,29 +518,31 @@ type ReplicationJobFrontend struct { // StateFrontend represents the state with frontend-friendly field names type StateFrontend struct { - Nodes []NodeFrontend `json:"nodes"` - VMs []VMFrontend `json:"vms"` - Containers []ContainerFrontend `json:"containers"` - DockerHosts []DockerHostFrontend `json:"dockerHosts"` - RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"` - Hosts []HostFrontend `json:"hosts"` - Storage []StorageFrontend `json:"storage"` - CephClusters []CephClusterFrontend `json:"cephClusters"` - PhysicalDisks []PhysicalDisk `json:"physicalDisks"` - PBS []PBSInstance `json:"pbs"` // Keep as is - PMG []PMGInstance `json:"pmg"` - PBSBackups []PBSBackup `json:"pbsBackups"` - PMGBackups []PMGBackup `json:"pmgBackups"` - Backups Backups `json:"backups"` - ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"` - ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts - Metrics map[string]any `json:"metrics"` // Empty object for now - PVEBackups PVEBackups `json:"pveBackups"` // Keep as is - Performance map[string]any `json:"performance"` // Empty object for now - ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is - Stats map[string]any `json:"stats"` // Empty object for now - LastUpdate int64 `json:"lastUpdate"` // Unix timestamp - TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting + Nodes []NodeFrontend `json:"nodes"` + VMs []VMFrontend `json:"vms"` + Containers []ContainerFrontend `json:"containers"` + DockerHosts []DockerHostFrontend `json:"dockerHosts"` + RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"` + KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters,omitempty"` + RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters,omitempty"` + Hosts []HostFrontend `json:"hosts"` + Storage []StorageFrontend `json:"storage"` + CephClusters []CephClusterFrontend `json:"cephClusters"` + PhysicalDisks []PhysicalDisk `json:"physicalDisks"` + PBS []PBSInstance `json:"pbs"` // Keep as is + PMG []PMGInstance `json:"pmg"` + PBSBackups []PBSBackup `json:"pbsBackups"` + PMGBackups []PMGBackup `json:"pmgBackups"` + Backups Backups `json:"backups"` + ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"` + ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts + Metrics map[string]any `json:"metrics"` // Empty object for now + PVEBackups PVEBackups `json:"pveBackups"` // Keep as is + Performance map[string]any `json:"performance"` // Empty object for now + ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is + Stats map[string]any `json:"stats"` // Empty object for now + LastUpdate int64 `json:"lastUpdate"` // Unix timestamp + TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting // Unified resources - the new way to access all monitored entities Resources []ResourceFrontend `json:"resources,omitempty"` } @@ -481,9 +575,9 @@ type ResourceFrontend struct { Uptime *int64 `json:"uptime,omitempty"` // Metadata - Tags []string `json:"tags,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - LastSeen int64 `json:"lastSeen"` // Unix milliseconds + Tags []string `json:"tags,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + LastSeen int64 `json:"lastSeen"` // Unix milliseconds Alerts []ResourceAlertFrontend `json:"alerts,omitempty"` // Identity for deduplication diff --git a/internal/models/state_snapshot.go b/internal/models/state_snapshot.go index 0b84827..eed7588 100644 --- a/internal/models/state_snapshot.go +++ b/internal/models/state_snapshot.go @@ -4,30 +4,32 @@ import "time" // StateSnapshot represents a snapshot of the state without mutex type StateSnapshot struct { - Nodes []Node `json:"nodes"` - VMs []VM `json:"vms"` - Containers []Container `json:"containers"` - DockerHosts []DockerHost `json:"dockerHosts"` - RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` - Hosts []Host `json:"hosts"` - Storage []Storage `json:"storage"` - CephClusters []CephCluster `json:"cephClusters"` - PhysicalDisks []PhysicalDisk `json:"physicalDisks"` - PBSInstances []PBSInstance `json:"pbs"` - PMGInstances []PMGInstance `json:"pmg"` - PBSBackups []PBSBackup `json:"pbsBackups"` - PMGBackups []PMGBackup `json:"pmgBackups"` - Backups Backups `json:"backups"` - ReplicationJobs []ReplicationJob `json:"replicationJobs"` - Metrics []Metric `json:"metrics"` - PVEBackups PVEBackups `json:"pveBackups"` - Performance Performance `json:"performance"` - ConnectionHealth map[string]bool `json:"connectionHealth"` - Stats Stats `json:"stats"` - ActiveAlerts []Alert `json:"activeAlerts"` - RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` - LastUpdate time.Time `json:"lastUpdate"` - TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + Nodes []Node `json:"nodes"` + VMs []VM `json:"vms"` + Containers []Container `json:"containers"` + DockerHosts []DockerHost `json:"dockerHosts"` + RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` + KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"` + RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"` + Hosts []Host `json:"hosts"` + Storage []Storage `json:"storage"` + CephClusters []CephCluster `json:"cephClusters"` + PhysicalDisks []PhysicalDisk `json:"physicalDisks"` + PBSInstances []PBSInstance `json:"pbs"` + PMGInstances []PMGInstance `json:"pmg"` + PBSBackups []PBSBackup `json:"pbsBackups"` + PMGBackups []PMGBackup `json:"pmgBackups"` + Backups Backups `json:"backups"` + ReplicationJobs []ReplicationJob `json:"replicationJobs"` + Metrics []Metric `json:"metrics"` + PVEBackups PVEBackups `json:"pveBackups"` + Performance Performance `json:"performance"` + ConnectionHealth map[string]bool `json:"connectionHealth"` + Stats Stats `json:"stats"` + ActiveAlerts []Alert `json:"activeAlerts"` + RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` + LastUpdate time.Time `json:"lastUpdate"` + TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` } // GetSnapshot returns a snapshot of the current state without mutex @@ -45,19 +47,21 @@ func (s *State) GetSnapshot() StateSnapshot { // Create a snapshot without mutex snapshot := StateSnapshot{ - Nodes: append([]Node{}, s.Nodes...), - VMs: append([]VM{}, s.VMs...), - Containers: append([]Container{}, s.Containers...), - DockerHosts: append([]DockerHost{}, s.DockerHosts...), - RemovedDockerHosts: append([]RemovedDockerHost{}, s.RemovedDockerHosts...), - Hosts: append([]Host{}, s.Hosts...), - Storage: append([]Storage{}, s.Storage...), - CephClusters: append([]CephCluster{}, s.CephClusters...), - PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...), - PBSInstances: append([]PBSInstance{}, s.PBSInstances...), - PMGInstances: append([]PMGInstance{}, s.PMGInstances...), - PBSBackups: pbsBackups, - PMGBackups: pmgBackups, + Nodes: append([]Node{}, s.Nodes...), + VMs: append([]VM{}, s.VMs...), + Containers: append([]Container{}, s.Containers...), + DockerHosts: append([]DockerHost{}, s.DockerHosts...), + RemovedDockerHosts: append([]RemovedDockerHost{}, s.RemovedDockerHosts...), + KubernetesClusters: append([]KubernetesCluster{}, s.KubernetesClusters...), + RemovedKubernetesClusters: append([]RemovedKubernetesCluster{}, s.RemovedKubernetesClusters...), + Hosts: append([]Host{}, s.Hosts...), + Storage: append([]Storage{}, s.Storage...), + CephClusters: append([]CephCluster{}, s.CephClusters...), + PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...), + PBSInstances: append([]PBSInstance{}, s.PBSInstances...), + PMGInstances: append([]PMGInstance{}, s.PMGInstances...), + PBSBackups: pbsBackups, + PMGBackups: pmgBackups, Backups: Backups{ PVE: pveBackups, PBS: pbsBackups, @@ -113,6 +117,16 @@ func (s StateSnapshot) ToFrontend() StateFrontend { removedDockerHosts[i] = entry.ToFrontend() } + kubernetesClusters := make([]KubernetesClusterFrontend, len(s.KubernetesClusters)) + for i, cluster := range s.KubernetesClusters { + kubernetesClusters[i] = cluster.ToFrontend() + } + + removedKubernetesClusters := make([]RemovedKubernetesClusterFrontend, len(s.RemovedKubernetesClusters)) + for i, entry := range s.RemovedKubernetesClusters { + removedKubernetesClusters[i] = entry.ToFrontend() + } + hosts := make([]HostFrontend, len(s.Hosts)) for i, host := range s.Hosts { hosts[i] = host.ToFrontend() @@ -141,6 +155,8 @@ func (s StateSnapshot) ToFrontend() StateFrontend { Containers: containers, DockerHosts: dockerHosts, RemovedDockerHosts: removedDockerHosts, + KubernetesClusters: kubernetesClusters, + RemovedKubernetesClusters: removedKubernetesClusters, Hosts: hosts, Storage: storage, CephClusters: cephClusters, diff --git a/internal/monitoring/kubernetes_agents.go b/internal/monitoring/kubernetes_agents.go new file mode 100644 index 0000000..2ecb362 --- /dev/null +++ b/internal/monitoring/kubernetes_agents.go @@ -0,0 +1,390 @@ +package monitoring + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/logging" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +const ( + removedKubernetesClustersTTL = 24 * time.Hour +) + +func normalizeKubernetesClusterIdentifier(report agentsk8s.Report) string { + if v := strings.TrimSpace(report.Cluster.ID); v != "" { + return v + } + if v := strings.TrimSpace(report.Agent.ID); v != "" { + return v + } + + stableKey := strings.TrimSpace(report.Cluster.Server) + "|" + strings.TrimSpace(report.Cluster.Context) + "|" + strings.TrimSpace(report.Cluster.Name) + stableKey = strings.TrimSpace(stableKey) + if stableKey == "||" || stableKey == "" { + return "" + } + + sum := sha256.Sum256([]byte(stableKey)) + return hex.EncodeToString(sum[:]) +} + +// ApplyKubernetesReport ingests a Kubernetes agent report into state. +func (m *Monitor) ApplyKubernetesReport(report agentsk8s.Report, tokenRecord *config.APITokenRecord) (models.KubernetesCluster, error) { + identifier := normalizeKubernetesClusterIdentifier(report) + if strings.TrimSpace(identifier) == "" { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes report missing cluster identifier") + } + + // Check if this cluster was deliberately removed - reject report to prevent resurrection + m.mu.RLock() + removedAt, wasRemoved := m.removedKubernetesClusters[identifier] + m.mu.RUnlock() + if wasRemoved { + log.Info(). + Str("k8sClusterID", identifier). + Time("removedAt", removedAt). + Msg("Rejecting report from deliberately removed Kubernetes cluster") + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster %q was removed at %v and cannot report again. Use Allow re-enroll in Settings -> Agents -> Removed Kubernetes Clusters or rerun the installer with a kubernetes:manage token to clear this block", identifier, removedAt.Format(time.RFC3339)) + } + + // Enforce token uniqueness: each token can only be bound to one cluster agent + if tokenRecord != nil && tokenRecord.ID != "" { + tokenID := strings.TrimSpace(tokenRecord.ID) + agentID := strings.TrimSpace(report.Agent.ID) + if agentID == "" { + agentID = identifier + } + + m.mu.Lock() + if boundAgentID, exists := m.kubernetesTokenBindings[tokenID]; exists { + if boundAgentID != agentID { + m.mu.Unlock() + tokenHint := tokenHintFromRecord(tokenRecord) + if tokenHint != "" { + tokenHint = " (" + tokenHint + ")" + } + log.Warn(). + Str("tokenID", tokenID). + Str("tokenHint", tokenHint). + Str("reportingAgentID", agentID). + Str("boundAgentID", boundAgentID). + Msg("Rejecting Kubernetes report: token already bound to different agent") + return models.KubernetesCluster{}, fmt.Errorf("API token%s is already in use by agent %q. Each Kubernetes agent must use a unique API token. Generate a new token for this agent", tokenHint, boundAgentID) + } + } else { + m.kubernetesTokenBindings[tokenID] = agentID + log.Debug(). + Str("tokenID", tokenID). + Str("agentID", agentID). + Str("clusterID", identifier). + Msg("Bound Kubernetes agent token to agent identity") + } + m.mu.Unlock() + } + + timestamp := report.Timestamp + if timestamp.IsZero() { + timestamp = time.Now() + } + + agentID := strings.TrimSpace(report.Agent.ID) + if agentID == "" { + agentID = identifier + } + + name := strings.TrimSpace(report.Cluster.Name) + displayName := name + if displayName == "" { + displayName = identifier + } + + nodes := make([]models.KubernetesNode, 0, len(report.Nodes)) + for _, n := range report.Nodes { + roles := append([]string(nil), n.Roles...) + nodes = append(nodes, models.KubernetesNode{ + UID: strings.TrimSpace(n.UID), + Name: strings.TrimSpace(n.Name), + Ready: n.Ready, + Unschedulable: n.Unschedulable, + KubeletVersion: strings.TrimSpace(n.KubeletVersion), + ContainerRuntimeVersion: strings.TrimSpace(n.ContainerRuntimeVersion), + OSImage: strings.TrimSpace(n.OSImage), + KernelVersion: strings.TrimSpace(n.KernelVersion), + Architecture: strings.TrimSpace(n.Architecture), + CapacityCPU: n.Capacity.CPUCores, + CapacityMemoryBytes: n.Capacity.MemoryBytes, + CapacityPods: n.Capacity.Pods, + AllocCPU: n.Allocatable.CPUCores, + AllocMemoryBytes: n.Allocatable.MemoryBytes, + AllocPods: n.Allocatable.Pods, + Roles: roles, + }) + } + + pods := make([]models.KubernetesPod, 0, len(report.Pods)) + for _, p := range report.Pods { + labels := make(map[string]string, len(p.Labels)) + for k, v := range p.Labels { + labels[k] = v + } + containers := make([]models.KubernetesPodContainer, 0, len(p.Containers)) + for _, c := range p.Containers { + containers = append(containers, models.KubernetesPodContainer{ + Name: strings.TrimSpace(c.Name), + Image: strings.TrimSpace(c.Image), + Ready: c.Ready, + RestartCount: c.RestartCount, + State: strings.TrimSpace(c.State), + Reason: strings.TrimSpace(c.Reason), + Message: strings.TrimSpace(c.Message), + }) + } + pods = append(pods, models.KubernetesPod{ + UID: strings.TrimSpace(p.UID), + Name: strings.TrimSpace(p.Name), + Namespace: strings.TrimSpace(p.Namespace), + NodeName: strings.TrimSpace(p.NodeName), + Phase: strings.TrimSpace(p.Phase), + Reason: strings.TrimSpace(p.Reason), + Message: strings.TrimSpace(p.Message), + QoSClass: strings.TrimSpace(p.QoSClass), + CreatedAt: p.CreatedAt, + StartTime: p.StartTime, + Restarts: p.Restarts, + Labels: labels, + OwnerKind: strings.TrimSpace(p.OwnerKind), + OwnerName: strings.TrimSpace(p.OwnerName), + Containers: containers, + }) + } + + deployments := make([]models.KubernetesDeployment, 0, len(report.Deployments)) + for _, d := range report.Deployments { + labels := make(map[string]string, len(d.Labels)) + for k, v := range d.Labels { + labels[k] = v + } + deployments = append(deployments, models.KubernetesDeployment{ + UID: strings.TrimSpace(d.UID), + Name: strings.TrimSpace(d.Name), + Namespace: strings.TrimSpace(d.Namespace), + DesiredReplicas: d.DesiredReplicas, + UpdatedReplicas: d.UpdatedReplicas, + ReadyReplicas: d.ReadyReplicas, + AvailableReplicas: d.AvailableReplicas, + Labels: labels, + }) + } + + agentVersion := normalizeAgentVersion(report.Agent.Version) + + cluster := models.KubernetesCluster{ + ID: identifier, + AgentID: agentID, + Name: name, + DisplayName: displayName, + Server: strings.TrimSpace(report.Cluster.Server), + Context: strings.TrimSpace(report.Cluster.Context), + Version: strings.TrimSpace(report.Cluster.Version), + Status: "online", + LastSeen: timestamp, + IntervalSeconds: report.Agent.IntervalSeconds, + AgentVersion: agentVersion, + Nodes: nodes, + Pods: pods, + Deployments: deployments, + } + + if tokenRecord != nil { + cluster.TokenID = strings.TrimSpace(tokenRecord.ID) + cluster.TokenName = strings.TrimSpace(tokenRecord.Name) + cluster.TokenHint = tokenHintFromRecord(tokenRecord) + cluster.TokenLastUsedAt = tokenRecord.LastUsedAt + } + + m.state.UpsertKubernetesCluster(cluster) + m.state.SetConnectionHealth(kubernetesConnectionPrefix+identifier, true) + + return cluster, nil +} + +// RemoveKubernetesCluster removes a kubernetes cluster from the shared state and clears related data. +func (m *Monitor) RemoveKubernetesCluster(clusterID string) (models.KubernetesCluster, error) { + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster id is required") + } + + cluster, removed := m.state.RemoveKubernetesCluster(clusterID) + if !removed { + if logging.IsLevelEnabled(zerolog.DebugLevel) { + log.Debug().Str("k8sClusterID", clusterID).Msg("Kubernetes cluster not present in state during removal; proceeding") + } + cluster = models.KubernetesCluster{ + ID: clusterID, + Name: clusterID, + DisplayName: clusterID, + } + } + + // Revoke the API token associated with this Kubernetes cluster + if cluster.TokenID != "" { + tokenRemoved := m.config.RemoveAPIToken(cluster.TokenID) + if tokenRemoved { + m.config.SortAPITokens() + m.config.APITokenEnabled = m.config.HasAPITokens() + + if m.persistence != nil { + if err := m.persistence.SaveAPITokens(m.config.APITokens); err != nil { + log.Warn().Err(err).Str("tokenID", cluster.TokenID).Msg("Failed to persist API token revocation after Kubernetes cluster removal") + } else { + log.Info().Str("tokenID", cluster.TokenID).Str("tokenName", cluster.TokenName).Msg("API token revoked for removed Kubernetes cluster") + } + } + } + } + + removedAt := time.Now() + + m.mu.Lock() + m.removedKubernetesClusters[clusterID] = removedAt + if cluster.TokenID != "" { + delete(m.kubernetesTokenBindings, cluster.TokenID) + log.Debug(). + Str("tokenID", cluster.TokenID). + Str("k8sClusterID", clusterID). + Msg("Unbound Kubernetes agent token from removed cluster") + } + m.mu.Unlock() + + m.state.AddRemovedKubernetesCluster(models.RemovedKubernetesCluster{ + ID: clusterID, + Name: cluster.Name, + DisplayName: cluster.DisplayName, + RemovedAt: removedAt, + }) + + m.state.RemoveConnectionHealth(kubernetesConnectionPrefix + clusterID) + + log.Info(). + Str("k8sClusterID", clusterID). + Bool("removed", removed). + Msg("Kubernetes cluster removed") + + return cluster, nil +} + +// AllowKubernetesClusterReenroll clears the removal block for a kubernetes cluster. +func (m *Monitor) AllowKubernetesClusterReenroll(clusterID string) error { + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return fmt.Errorf("kubernetes cluster id is required") + } + + m.mu.Lock() + _, exists := m.removedKubernetesClusters[clusterID] + if !exists { + m.mu.Unlock() + return nil + } + delete(m.removedKubernetesClusters, clusterID) + m.mu.Unlock() + + m.state.RemoveRemovedKubernetesCluster(clusterID) + return nil +} + +// UnhideKubernetesCluster clears the hidden flag. +func (m *Monitor) UnhideKubernetesCluster(clusterID string) (models.KubernetesCluster, error) { + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster id is required") + } + + cluster, ok := m.state.SetKubernetesClusterHidden(clusterID, false) + if !ok { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster %q not found", clusterID) + } + return cluster, nil +} + +// MarkKubernetesClusterPendingUninstall sets the pending uninstall flag on a cluster. +func (m *Monitor) MarkKubernetesClusterPendingUninstall(clusterID string) (models.KubernetesCluster, error) { + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster id is required") + } + + cluster, ok := m.state.SetKubernetesClusterPendingUninstall(clusterID, true) + if !ok { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster %q not found", clusterID) + } + return cluster, nil +} + +// SetKubernetesClusterCustomDisplayName updates the custom display name for a cluster. +func (m *Monitor) SetKubernetesClusterCustomDisplayName(clusterID, customName string) (models.KubernetesCluster, error) { + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster id is required") + } + + cluster, ok := m.state.SetKubernetesClusterCustomDisplayName(clusterID, customName) + if !ok { + return models.KubernetesCluster{}, fmt.Errorf("kubernetes cluster %q not found", clusterID) + } + return cluster, nil +} + +func (m *Monitor) evaluateKubernetesAgents(now time.Time) { + clusters := m.state.GetKubernetesClusters() + for _, cluster := range clusters { + interval := cluster.IntervalSeconds + if interval <= 0 { + interval = int(kubernetesMinimumHealthWindow / time.Second) + } + + window := time.Duration(interval) * time.Second * kubernetesOfflineGraceMultiplier + if window < kubernetesMinimumHealthWindow { + window = kubernetesMinimumHealthWindow + } else if window > kubernetesMaximumHealthWindow { + window = kubernetesMaximumHealthWindow + } + + healthy := !cluster.LastSeen.IsZero() && now.Sub(cluster.LastSeen) <= window + key := kubernetesConnectionPrefix + cluster.ID + m.state.SetConnectionHealth(key, healthy) + if healthy { + m.state.SetKubernetesClusterStatus(cluster.ID, "online") + } else { + m.state.SetKubernetesClusterStatus(cluster.ID, "offline") + } + } +} + +func (m *Monitor) cleanupRemovedKubernetesClusters(now time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + + for clusterID, removedAt := range m.removedKubernetesClusters { + if now.Sub(removedAt) > removedKubernetesClustersTTL { + delete(m.removedKubernetesClusters, clusterID) + m.state.RemoveRemovedKubernetesCluster(clusterID) + if logging.IsLevelEnabled(zerolog.DebugLevel) { + log.Debug(). + Str("k8sClusterID", clusterID). + Time("removedAt", removedAt). + Msg("Cleaned up stale removed Kubernetes cluster block") + } + } + } +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 80e9b9e..46b2084 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -602,7 +602,7 @@ type Monitor struct { startTime time.Time rateTracker *RateTracker metricsHistory *MetricsHistory - metricsStore *metrics.Store // Persistent SQLite metrics storage + metricsStore *metrics.Store // Persistent SQLite metrics storage alertManager *alerts.Manager notificationMgr *notifications.NotificationManager configPersist *config.ConfigPersistence @@ -626,6 +626,8 @@ type Monitor struct { nodeRRDMemCache map[string]rrdMemCacheEntry removedDockerHosts map[string]time.Time // Track deliberately removed Docker hosts (ID -> removal time) dockerTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness + removedKubernetesClusters map[string]time.Time // Track deliberately removed Kubernetes clusters (ID -> removal time) + kubernetesTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness hostTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness dockerCommands map[string]*dockerHostCommand dockerCommandIndex map[string]string @@ -651,7 +653,7 @@ type Monitor struct { instanceInfoCache map[string]*instanceInfo pollStatusMap map[string]*pollStatus dlqInsightMap map[string]*dlqInsight - nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period) + nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period) resourceStore ResourceStoreInterface // Optional unified resource store for polling optimization } @@ -917,17 +919,21 @@ func (m *Monitor) shouldRunBackupPoll(last time.Time, now time.Time) (bool, stri } const ( - dockerConnectionPrefix = "docker-" - hostConnectionPrefix = "host-" - dockerOfflineGraceMultiplier = 4 - dockerMinimumHealthWindow = 30 * time.Second - dockerMaximumHealthWindow = 10 * time.Minute - hostOfflineGraceMultiplier = 6 - hostMinimumHealthWindow = 60 * time.Second - hostMaximumHealthWindow = 10 * time.Minute - nodeOfflineGracePeriod = 60 * time.Second // Grace period before marking Proxmox nodes offline - nodeRRDCacheTTL = 30 * time.Second - nodeRRDRequestTimeout = 2 * time.Second + dockerConnectionPrefix = "docker-" + kubernetesConnectionPrefix = "kubernetes-" + hostConnectionPrefix = "host-" + dockerOfflineGraceMultiplier = 4 + dockerMinimumHealthWindow = 30 * time.Second + dockerMaximumHealthWindow = 10 * time.Minute + kubernetesOfflineGraceMultiplier = 4 + kubernetesMinimumHealthWindow = 30 * time.Second + kubernetesMaximumHealthWindow = 10 * time.Minute + hostOfflineGraceMultiplier = 6 + hostMinimumHealthWindow = 60 * time.Second + hostMaximumHealthWindow = 10 * time.Minute + nodeOfflineGracePeriod = 60 * time.Second // Grace period before marking Proxmox nodes offline + nodeRRDCacheTTL = 30 * time.Second + nodeRRDRequestTimeout = 2 * time.Second ) type taskOutcome struct { @@ -1766,20 +1772,20 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con // Record Docker HOST metrics for sparkline charts now := time.Now() hostMetricKey := fmt.Sprintf("dockerHost:%s", host.ID) - + // Record host CPU usage m.metricsHistory.AddGuestMetric(hostMetricKey, "cpu", host.CPUUsage, now) - + // Record host Memory usage m.metricsHistory.AddGuestMetric(hostMetricKey, "memory", host.Memory.Usage, now) - + // Record host Disk usage (use first disk or calculate total) var hostDiskPercent float64 if len(host.Disks) > 0 { hostDiskPercent = host.Disks[0].Usage } m.metricsHistory.AddGuestMetric(hostMetricKey, "disk", hostDiskPercent, now) - + // Also write to persistent SQLite store if m.metricsStore != nil { m.metricsStore.Write("dockerHost", host.ID, "cpu", host.CPUUsage, now) @@ -1795,13 +1801,13 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con } // Build a unique metric key for Docker containers metricKey := fmt.Sprintf("docker:%s", container.ID) - + // Record CPU (already a percentage 0-100) m.metricsHistory.AddGuestMetric(metricKey, "cpu", container.CPUPercent, now) - + // Record Memory (already a percentage 0-100) m.metricsHistory.AddGuestMetric(metricKey, "memory", container.MemoryPercent, now) - + // Record Disk usage as percentage of writable layer vs root filesystem var diskPercent float64 if container.RootFilesystemBytes > 0 && container.WritableLayerBytes > 0 { @@ -2875,6 +2881,8 @@ func New(cfg *config.Config) (*Monitor, error) { nodeRRDMemCache: make(map[string]rrdMemCacheEntry), removedDockerHosts: make(map[string]time.Time), dockerTokenBindings: make(map[string]string), + removedKubernetesClusters: make(map[string]time.Time), + kubernetesTokenBindings: make(map[string]string), hostTokenBindings: make(map[string]string), dockerCommands: make(map[string]*dockerHostCommand), dockerCommandIndex: make(map[string]string), @@ -3518,8 +3526,10 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) { case <-pollTicker.C: now := time.Now() m.evaluateDockerAgents(now) + m.evaluateKubernetesAgents(now) m.evaluateHostAgents(now) m.cleanupRemovedDockerHosts(now) + m.cleanupRemovedKubernetesClusters(now) m.cleanupGuestMetadataCache(now) m.cleanupDiagnosticSnapshots(now) m.cleanupRRDCache(now) @@ -5098,12 +5108,12 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Run physical disk polling in background to avoid blocking the main task go func(inst string, pveClient PVEClientInterface, nodeList []proxmox.Node, nodeStatus map[string]string, modelNodesCopy []models.Node) { defer recoverFromPanic(fmt.Sprintf("pollPhysicalDisks-%s", inst)) - + // Use a generous timeout for disk polling diskTimeout := 60 * time.Second diskCtx, diskCancel := context.WithTimeout(context.Background(), diskTimeout) defer diskCancel() - + log.Debug(). Int("nodeCount", len(nodeList)). Dur("interval", pollingInterval). @@ -5131,7 +5141,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie return default: } - + // Skip offline nodes but preserve their existing disk data if nodeStatus[node.Node] != "online" { log.Debug().Str("node", node.Node).Msg("Skipping disk poll for offline node - preserving existing data") @@ -5418,12 +5428,12 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie default: go func(inst string, pveClient PVEClientInterface, nodeList []proxmox.Node) { defer recoverFromPanic(fmt.Sprintf("pollStorageWithNodes-%s", inst)) - + // Use a generous timeout for storage polling - it's not blocking the main task storageTimeout := 60 * time.Second storageCtx, storageCancel := context.WithTimeout(context.Background(), storageTimeout) defer storageCancel() - + m.pollStorageWithNodes(storageCtx, inst, pveClient, nodeList) }(instanceName, client, nodes) } @@ -7455,11 +7465,11 @@ func (m *Monitor) shouldSkipNodeMetrics(nodeName string) bool { m.mu.RLock() store := m.resourceStore m.mu.RUnlock() - + if store == nil { return false } - + should := store.ShouldSkipAPIPolling(nodeName) if should { log.Debug(). @@ -7475,12 +7485,12 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) { m.mu.RLock() store := m.resourceStore m.mu.RUnlock() - + if store == nil { log.Debug().Msg("[Resources] No resource store configured, skipping population") return } - + log.Debug(). Int("nodes", len(state.Nodes)). Int("vms", len(state.VMs)). @@ -7488,7 +7498,7 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) { Int("hosts", len(state.Hosts)). Int("dockerHosts", len(state.DockerHosts)). Msg("[Resources] Populating resource store from state snapshot") - + store.PopulateFromSnapshot(state) } @@ -7498,18 +7508,18 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { m.mu.RLock() store := m.resourceStore m.mu.RUnlock() - + if store == nil { log.Debug().Msg("[Resources] No store for broadcast") return nil } - + allResources := store.GetAll() log.Debug().Int("count", len(allResources)).Msg("[Resources] Got resources for broadcast") if len(allResources) == 0 { return nil } - + result := make([]models.ResourceFrontend, len(allResources)) for i, r := range allResources { input := models.ResourceConvertInput{ @@ -7529,7 +7539,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { Labels: r.Labels, LastSeenUnix: r.LastSeen.UnixMilli(), } - + // Convert metrics if r.CPU != nil { input.CPU = &models.ResourceMetricInput{ @@ -7560,7 +7570,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { input.NetworkRX = r.Network.RXBytes input.NetworkTX = r.Network.TXBytes } - + // Convert alerts if len(r.Alerts) > 0 { input.Alerts = make([]models.ResourceAlertInput, len(r.Alerts)) @@ -7576,7 +7586,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { } } } - + // Convert identity if r.Identity != nil { input.Identity = &models.ResourceIdentityInput{ @@ -7585,7 +7595,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { IPs: r.Identity.IPs, } } - + // Convert platform data from json.RawMessage to map if len(r.PlatformData) > 0 { var platformMap map[string]any @@ -7593,10 +7603,10 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { input.PlatformData = platformMap } } - + result[i] = models.ConvertResourceToFrontend(input) } - + return result } diff --git a/internal/resources/converters.go b/internal/resources/converters.go index 4473d97..907ab92 100644 --- a/internal/resources/converters.go +++ b/internal/resources/converters.go @@ -169,13 +169,13 @@ func FromVM(vm models.VM) Resource { CPU: &MetricValue{ Current: vm.CPU * 100, // VM CPU is 0-1, convert to percentage }, - Memory: memory, - Disk: disk, - Network: network, - Uptime: &vm.Uptime, - Tags: vm.Tags, - LastSeen: vm.LastSeen, - PlatformData: platformDataJSON, + Memory: memory, + Disk: disk, + Network: network, + Uptime: &vm.Uptime, + Tags: vm.Tags, + LastSeen: vm.LastSeen, + PlatformData: platformDataJSON, SchemaVersion: CurrentSchemaVersion, } } @@ -255,13 +255,13 @@ func FromContainer(ct models.Container) Resource { CPU: &MetricValue{ Current: ct.CPU * 100, // Container CPU is 0-1, convert to percentage }, - Memory: memory, - Disk: disk, - Network: network, - Uptime: &ct.Uptime, - Tags: ct.Tags, - LastSeen: ct.LastSeen, - PlatformData: platformDataJSON, + Memory: memory, + Disk: disk, + Network: network, + Uptime: &ct.Uptime, + Tags: ct.Tags, + LastSeen: ct.LastSeen, + PlatformData: platformDataJSON, SchemaVersion: CurrentSchemaVersion, } } @@ -347,14 +347,14 @@ func FromHost(h models.Host) Resource { diskIO := make([]DiskIOStats, len(h.DiskIO)) for i, d := range h.DiskIO { diskIO[i] = DiskIOStats{ - Device: d.Device, - ReadBytes: d.ReadBytes, - WriteBytes: d.WriteBytes, - ReadOps: d.ReadOps, - WriteOps: d.WriteOps, - ReadTimeMs: d.ReadTime, + Device: d.Device, + ReadBytes: d.ReadBytes, + WriteBytes: d.WriteBytes, + ReadOps: d.ReadOps, + WriteOps: d.WriteOps, + ReadTimeMs: d.ReadTime, WriteTimeMs: d.WriteTime, - IOTimeMs: d.IOTime, + IOTimeMs: d.IOTime, } } @@ -678,11 +678,217 @@ func FromDockerContainer(dc models.DockerContainer, hostID, hostName string) Res CPU: &MetricValue{ Current: dc.CPUPercent, }, - Memory: memory, - Uptime: &dc.UptimeSeconds, - Labels: dc.Labels, - LastSeen: time.Now(), // Containers don't have their own LastSeen - PlatformData: platformDataJSON, + Memory: memory, + Uptime: &dc.UptimeSeconds, + Labels: dc.Labels, + LastSeen: time.Now(), // Containers don't have their own LastSeen + PlatformData: platformDataJSON, + SchemaVersion: CurrentSchemaVersion, + } +} + +// FromKubernetesCluster converts a KubernetesCluster to a unified Resource. +func FromKubernetesCluster(cluster models.KubernetesCluster) Resource { + platformData := KubernetesClusterPlatformData{ + AgentID: cluster.AgentID, + Server: cluster.Server, + Context: cluster.Context, + Version: cluster.Version, + CustomDisplayName: cluster.CustomDisplayName, + Hidden: cluster.Hidden, + PendingUninstall: cluster.PendingUninstall, + NodeCount: len(cluster.Nodes), + PodCount: len(cluster.Pods), + DeploymentCount: len(cluster.Deployments), + TokenID: cluster.TokenID, + TokenName: cluster.TokenName, + TokenHint: cluster.TokenHint, + TokenLastUsedAt: cluster.TokenLastUsedAt, + } + platformDataJSON, _ := json.Marshal(platformData) + + displayName := strings.TrimSpace(cluster.DisplayName) + if displayName == "" { + displayName = strings.TrimSpace(cluster.Name) + } + if cluster.CustomDisplayName != "" { + displayName = cluster.CustomDisplayName + } + if displayName == "" { + displayName = cluster.ID + } + + name := strings.TrimSpace(cluster.Name) + if name == "" { + name = displayName + } + + return Resource{ + ID: cluster.ID, + Type: ResourceTypeK8sCluster, + Name: name, + DisplayName: displayName, + PlatformID: cluster.AgentID, + PlatformType: PlatformKubernetes, + SourceType: SourceAgent, + Status: mapKubernetesClusterStatus(cluster.Status), + LastSeen: cluster.LastSeen, + PlatformData: platformDataJSON, + SchemaVersion: CurrentSchemaVersion, + } +} + +// FromKubernetesNode converts a KubernetesNode to a unified Resource. +func FromKubernetesNode(node models.KubernetesNode, cluster models.KubernetesCluster) Resource { + platformData := KubernetesNodePlatformData{ + ClusterID: cluster.ID, + Ready: node.Ready, + Unschedulable: node.Unschedulable, + KubeletVersion: node.KubeletVersion, + ContainerRuntimeVersion: node.ContainerRuntimeVersion, + OSImage: node.OSImage, + KernelVersion: node.KernelVersion, + Architecture: node.Architecture, + CapacityCPUCores: node.CapacityCPU, + CapacityMemoryBytes: node.CapacityMemoryBytes, + CapacityPods: node.CapacityPods, + AllocatableCPUCores: node.AllocCPU, + AllocatableMemoryBytes: node.AllocMemoryBytes, + AllocatablePods: node.AllocPods, + Roles: append([]string(nil), node.Roles...), + } + platformDataJSON, _ := json.Marshal(platformData) + + nodeID := strings.TrimSpace(node.UID) + if nodeID == "" { + nodeID = node.Name + } + resourceID := fmt.Sprintf("%s/node/%s", cluster.ID, nodeID) + + status := StatusUnknown + if node.Ready { + status = StatusOnline + } else { + status = StatusOffline + } + if node.Unschedulable && status == StatusOnline { + status = StatusDegraded + } + + return Resource{ + ID: resourceID, + Type: ResourceTypeK8sNode, + Name: node.Name, + PlatformID: cluster.ID, + PlatformType: PlatformKubernetes, + SourceType: SourceAgent, + ParentID: cluster.ID, + Status: status, + LastSeen: cluster.LastSeen, + PlatformData: platformDataJSON, + SchemaVersion: CurrentSchemaVersion, + } +} + +// FromKubernetesPod converts a KubernetesPod to a unified Resource. +func FromKubernetesPod(pod models.KubernetesPod, cluster models.KubernetesCluster) Resource { + containers := make([]KubernetesPodContainerInfo, 0, len(pod.Containers)) + for _, c := range pod.Containers { + containers = append(containers, KubernetesPodContainerInfo{ + Name: c.Name, + Image: c.Image, + Ready: c.Ready, + RestartCount: c.RestartCount, + State: c.State, + Reason: c.Reason, + Message: c.Message, + }) + } + + platformData := KubernetesPodPlatformData{ + ClusterID: cluster.ID, + Namespace: pod.Namespace, + NodeName: pod.NodeName, + Phase: pod.Phase, + Reason: pod.Reason, + Message: pod.Message, + QoSClass: pod.QoSClass, + Restarts: pod.Restarts, + OwnerKind: pod.OwnerKind, + OwnerName: pod.OwnerName, + Containers: containers, + } + platformDataJSON, _ := json.Marshal(platformData) + + podID := strings.TrimSpace(pod.UID) + if podID == "" { + podID = fmt.Sprintf("%s/%s", pod.Namespace, pod.Name) + } + resourceID := fmt.Sprintf("%s/pod/%s", cluster.ID, podID) + + parentID := cluster.ID + if strings.TrimSpace(pod.NodeName) != "" { + parentID = fmt.Sprintf("%s/node/%s", cluster.ID, pod.NodeName) + } + + lastSeen := cluster.LastSeen + + return Resource{ + ID: resourceID, + Type: ResourceTypePod, + Name: pod.Name, + PlatformID: cluster.ID, + PlatformType: PlatformKubernetes, + SourceType: SourceAgent, + ParentID: parentID, + Status: mapKubernetesPodStatus(pod.Phase), + Labels: pod.Labels, + LastSeen: lastSeen, + PlatformData: platformDataJSON, + SchemaVersion: CurrentSchemaVersion, + } +} + +// FromKubernetesDeployment converts a KubernetesDeployment to a unified Resource. +func FromKubernetesDeployment(dep models.KubernetesDeployment, cluster models.KubernetesCluster) Resource { + platformData := KubernetesDeploymentPlatformData{ + ClusterID: cluster.ID, + Namespace: dep.Namespace, + DesiredReplicas: dep.DesiredReplicas, + UpdatedReplicas: dep.UpdatedReplicas, + ReadyReplicas: dep.ReadyReplicas, + AvailableReplicas: dep.AvailableReplicas, + } + platformDataJSON, _ := json.Marshal(platformData) + + depID := strings.TrimSpace(dep.UID) + if depID == "" { + depID = fmt.Sprintf("%s/%s", dep.Namespace, dep.Name) + } + resourceID := fmt.Sprintf("%s/deployment/%s", cluster.ID, depID) + + status := StatusUnknown + switch { + case dep.DesiredReplicas == 0: + status = StatusStopped + case dep.AvailableReplicas >= dep.DesiredReplicas: + status = StatusRunning + case dep.AvailableReplicas > 0: + status = StatusDegraded + } + + return Resource{ + ID: resourceID, + Type: ResourceTypeK8sDeployment, + Name: dep.Name, + PlatformID: cluster.ID, + PlatformType: PlatformKubernetes, + SourceType: SourceAgent, + ParentID: cluster.ID, + Status: status, + Labels: dep.Labels, + LastSeen: cluster.LastSeen, + PlatformData: platformDataJSON, SchemaVersion: CurrentSchemaVersion, } } @@ -840,6 +1046,30 @@ func mapDockerContainerStatus(state string) ResourceStatus { } } +func mapKubernetesClusterStatus(status string) ResourceStatus { + switch strings.ToLower(strings.TrimSpace(status)) { + case "online": + return StatusOnline + case "offline": + return StatusOffline + default: + return StatusUnknown + } +} + +func mapKubernetesPodStatus(phase string) ResourceStatus { + switch strings.ToLower(strings.TrimSpace(phase)) { + case "running": + return StatusRunning + case "succeeded", "failed": + return StatusStopped + case "pending": + return StatusUnknown + default: + return StatusUnknown + } +} + func mapPBSStatus(status, connectionHealth string) ResourceStatus { if connectionHealth != "healthy" { return StatusDegraded diff --git a/internal/resources/platform_data.go b/internal/resources/platform_data.go index 9861f0e..05e4208 100644 --- a/internal/resources/platform_data.go +++ b/internal/resources/platform_data.go @@ -56,10 +56,10 @@ type VMPlatformData struct { // Stored in Resource.PlatformData when Type is ResourceTypeContainer or ResourceTypeOCIContainer. type ContainerPlatformData struct { VMID int `json:"vmid"` - Node string `json:"node"` // Proxmox node hosting this container - Instance string `json:"instance"` // Proxmox instance URL - Type string `json:"type,omitempty"` // lxc or oci - CPUs int `json:"cpus"` // Number of vCPUs + Node string `json:"node"` // Proxmox node hosting this container + Instance string `json:"instance"` // Proxmox instance URL + Type string `json:"type,omitempty"` // lxc or oci + CPUs int `json:"cpus"` // Number of vCPUs Template bool `json:"template"` Lock string `json:"lock,omitempty"` OSName string `json:"osName,omitempty"` @@ -81,20 +81,20 @@ type ContainerPlatformData struct { // HostPlatformData contains host-agent specific fields. // Stored in Resource.PlatformData when Type is ResourceTypeHost. type HostPlatformData struct { - Platform string `json:"platform,omitempty"` // linux, windows, darwin - OSName string `json:"osName,omitempty"` // e.g., "Ubuntu 22.04" - OSVersion string `json:"osVersion,omitempty"` // OS version string - KernelVersion string `json:"kernelVersion,omitempty"` // Kernel version - Architecture string `json:"architecture,omitempty"` // amd64, arm64, etc. - CPUCount int `json:"cpuCount,omitempty"` // Number of CPUs - LoadAverage []float64 `json:"loadAverage,omitempty"` // 1, 5, 15 minute loads - AgentVersion string `json:"agentVersion,omitempty"` // Pulse agent version - IsLegacy bool `json:"isLegacy,omitempty"` // Legacy agent indicator - Sensors HostSensorSummary `json:"sensors,omitempty"` // Temperature/fan sensors - RAID []HostRAIDArray `json:"raid,omitempty"` // RAID arrays - DiskIO []DiskIOStats `json:"diskIO,omitempty"` // Per-disk I/O stats - Disks []DiskInfo `json:"disks,omitempty"` // Disk usage info - Interfaces []NetworkInterface `json:"interfaces,omitempty"` // Network interfaces + Platform string `json:"platform,omitempty"` // linux, windows, darwin + OSName string `json:"osName,omitempty"` // e.g., "Ubuntu 22.04" + OSVersion string `json:"osVersion,omitempty"` // OS version string + KernelVersion string `json:"kernelVersion,omitempty"` // Kernel version + Architecture string `json:"architecture,omitempty"` // amd64, arm64, etc. + CPUCount int `json:"cpuCount,omitempty"` // Number of CPUs + LoadAverage []float64 `json:"loadAverage,omitempty"` // 1, 5, 15 minute loads + AgentVersion string `json:"agentVersion,omitempty"` // Pulse agent version + IsLegacy bool `json:"isLegacy,omitempty"` // Legacy agent indicator + Sensors HostSensorSummary `json:"sensors,omitempty"` // Temperature/fan sensors + RAID []HostRAIDArray `json:"raid,omitempty"` // RAID arrays + DiskIO []DiskIOStats `json:"diskIO,omitempty"` // Per-disk I/O stats + Disks []DiskInfo `json:"disks,omitempty"` // Disk usage info + Interfaces []NetworkInterface `json:"interfaces,omitempty"` // Network interfaces // Token information TokenID string `json:"tokenId,omitempty"` @@ -136,14 +136,14 @@ type HostRAIDDevice struct { // DiskIOStats captures I/O statistics for a disk device. type DiskIOStats struct { - Device string `json:"device"` - ReadBytes uint64 `json:"readBytes,omitempty"` - WriteBytes uint64 `json:"writeBytes,omitempty"` - ReadOps uint64 `json:"readOps,omitempty"` - WriteOps uint64 `json:"writeOps,omitempty"` - ReadTimeMs uint64 `json:"readTimeMs,omitempty"` + Device string `json:"device"` + ReadBytes uint64 `json:"readBytes,omitempty"` + WriteBytes uint64 `json:"writeBytes,omitempty"` + ReadOps uint64 `json:"readOps,omitempty"` + WriteOps uint64 `json:"writeOps,omitempty"` + ReadTimeMs uint64 `json:"readTimeMs,omitempty"` WriteTimeMs uint64 `json:"writeTimeMs,omitempty"` - IOTimeMs uint64 `json:"ioTimeMs,omitempty"` + IOTimeMs uint64 `json:"ioTimeMs,omitempty"` } // DiskInfo represents disk/partition usage. @@ -170,23 +170,23 @@ type NetworkInterface struct { // DockerHostPlatformData contains Docker host-specific fields. // Stored in Resource.PlatformData when Type is ResourceTypeDockerHost. type DockerHostPlatformData struct { - AgentID string `json:"agentId"` - MachineID string `json:"machineId,omitempty"` - OS string `json:"os,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - Runtime string `json:"runtime,omitempty"` // docker, podman - RuntimeVersion string `json:"runtimeVersion,omitempty"` - DockerVersion string `json:"dockerVersion,omitempty"` - LoadAverage []float64 `json:"loadAverage,omitempty"` - AgentVersion string `json:"agentVersion,omitempty"` - CPUs int `json:"cpus"` - IsLegacy bool `json:"isLegacy,omitempty"` - Disks []DiskInfo `json:"disks,omitempty"` - Interfaces []NetworkInterface `json:"interfaces,omitempty"` - CustomDisplayName string `json:"customDisplayName,omitempty"` - Hidden bool `json:"hidden"` - PendingUninstall bool `json:"pendingUninstall"` + AgentID string `json:"agentId"` + MachineID string `json:"machineId,omitempty"` + OS string `json:"os,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + Runtime string `json:"runtime,omitempty"` // docker, podman + RuntimeVersion string `json:"runtimeVersion,omitempty"` + DockerVersion string `json:"dockerVersion,omitempty"` + LoadAverage []float64 `json:"loadAverage,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` + CPUs int `json:"cpus"` + IsLegacy bool `json:"isLegacy,omitempty"` + Disks []DiskInfo `json:"disks,omitempty"` + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + CustomDisplayName string `json:"customDisplayName,omitempty"` + Hidden bool `json:"hidden"` + PendingUninstall bool `json:"pendingUninstall"` // Swarm information Swarm *DockerSwarmInfo `json:"swarm,omitempty"` @@ -213,25 +213,107 @@ type DockerSwarmInfo struct { // DockerContainerPlatformData contains Docker container-specific fields. // Stored in Resource.PlatformData when Type is ResourceTypeDockerContainer. type DockerContainerPlatformData struct { - HostID string `json:"hostId"` // Parent Docker host ID - HostName string `json:"hostName"` // Parent Docker host name - Image string `json:"image"` // Container image - State string `json:"state"` // created, running, paused, restarting, exited, dead - Status string `json:"status"` // Human-readable status - Health string `json:"health"` // healthy, unhealthy, starting, none - RestartCount int `json:"restartCount"` - ExitCode int `json:"exitCode"` - CreatedAt time.Time `json:"createdAt"` - StartedAt *time.Time `json:"startedAt,omitempty"` - FinishedAt *time.Time `json:"finishedAt,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Ports []ContainerPort `json:"ports,omitempty"` - Networks []ContainerNetwork `json:"networks,omitempty"` + HostID string `json:"hostId"` // Parent Docker host ID + HostName string `json:"hostName"` // Parent Docker host name + Image string `json:"image"` // Container image + State string `json:"state"` // created, running, paused, restarting, exited, dead + Status string `json:"status"` // Human-readable status + Health string `json:"health"` // healthy, unhealthy, starting, none + RestartCount int `json:"restartCount"` + ExitCode int `json:"exitCode"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Ports []ContainerPort `json:"ports,omitempty"` + Networks []ContainerNetwork `json:"networks,omitempty"` // Podman-specific Podman *PodmanContainerInfo `json:"podman,omitempty"` } +// KubernetesClusterPlatformData contains Kubernetes cluster-specific fields. +// Stored in Resource.PlatformData when Type is ResourceTypeK8sCluster. +type KubernetesClusterPlatformData struct { + AgentID string `json:"agentId"` + Server string `json:"server,omitempty"` + Context string `json:"context,omitempty"` + Version string `json:"version,omitempty"` + CustomDisplayName string `json:"customDisplayName,omitempty"` + Hidden bool `json:"hidden"` + PendingUninstall bool `json:"pendingUninstall"` + + NodeCount int `json:"nodeCount,omitempty"` + PodCount int `json:"podCount,omitempty"` + DeploymentCount int `json:"deploymentCount,omitempty"` + + // Token information + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` +} + +// KubernetesNodePlatformData contains Kubernetes node-specific fields. +// Stored in Resource.PlatformData when Type is ResourceTypeK8sNode. +type KubernetesNodePlatformData struct { + ClusterID string `json:"clusterId"` + Ready bool `json:"ready"` + Unschedulable bool `json:"unschedulable,omitempty"` + KubeletVersion string `json:"kubeletVersion,omitempty"` + ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"` + OSImage string `json:"osImage,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + CapacityCPUCores int64 `json:"capacityCpuCores,omitempty"` + CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"` + CapacityPods int64 `json:"capacityPods,omitempty"` + AllocatableCPUCores int64 `json:"allocatableCpuCores,omitempty"` + AllocatableMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"` + AllocatablePods int64 `json:"allocatablePods,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +// KubernetesPodPlatformData contains Kubernetes pod-specific fields. +// Stored in Resource.PlatformData when Type is ResourceTypePod. +type KubernetesPodPlatformData struct { + ClusterID string `json:"clusterId"` + Namespace string `json:"namespace"` + NodeName string `json:"nodeName,omitempty"` + Phase string `json:"phase,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + QoSClass string `json:"qosClass,omitempty"` + Restarts int `json:"restarts,omitempty"` + + OwnerKind string `json:"ownerKind,omitempty"` + OwnerName string `json:"ownerName,omitempty"` + + Containers []KubernetesPodContainerInfo `json:"containers,omitempty"` +} + +// KubernetesPodContainerInfo captures per-container pod status. +type KubernetesPodContainerInfo struct { + Name string `json:"name"` + Image string `json:"image,omitempty"` + Ready bool `json:"ready"` + RestartCount int32 `json:"restartCount,omitempty"` + State string `json:"state,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +// KubernetesDeploymentPlatformData contains Kubernetes deployment-specific fields. +// Stored in Resource.PlatformData when Type is ResourceTypeK8sDeployment. +type KubernetesDeploymentPlatformData struct { + ClusterID string `json:"clusterId"` + Namespace string `json:"namespace"` + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + UpdatedReplicas int32 `json:"updatedReplicas,omitempty"` + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + AvailableReplicas int32 `json:"availableReplicas,omitempty"` +} + // ContainerPort describes a port mapping. type ContainerPort struct { PrivatePort int `json:"privatePort"` @@ -280,12 +362,12 @@ type DatastorePlatformData struct { // StoragePlatformData contains Proxmox storage-specific fields. // Stored in Resource.PlatformData when Type is ResourceTypeStorage. type StoragePlatformData struct { - Instance string `json:"instance"` - Node string `json:"node"` // Primary node - Nodes []string `json:"nodes,omitempty"` // All nodes (for shared storage) - Type string `json:"type"` // zfspool, lvmthin, cephfs, etc. - Content string `json:"content"` - Shared bool `json:"shared"` - Enabled bool `json:"enabled"` - Active bool `json:"active"` + Instance string `json:"instance"` + Node string `json:"node"` // Primary node + Nodes []string `json:"nodes,omitempty"` // All nodes (for shared storage) + Type string `json:"type"` // zfspool, lvmthin, cephfs, etc. + Content string `json:"content"` + Shared bool `json:"shared"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` } diff --git a/internal/resources/resource.go b/internal/resources/resource.go index ba44f9b..b02a90d 100644 --- a/internal/resources/resource.go +++ b/internal/resources/resource.go @@ -35,13 +35,13 @@ type Resource struct { ClusterID string `json:"clusterId,omitempty"` // Cluster membership // Universal Metrics (nullable - not all resources have all metrics) - Status ResourceStatus `json:"status"` // online, offline, running, stopped, degraded - CPU *MetricValue `json:"cpu,omitempty"` // CPU usage percentage - Memory *MetricValue `json:"memory,omitempty"` // Memory usage - Disk *MetricValue `json:"disk,omitempty"` // Primary disk usage - Network *NetworkMetric `json:"network,omitempty"` // Network I/O + Status ResourceStatus `json:"status"` // online, offline, running, stopped, degraded + CPU *MetricValue `json:"cpu,omitempty"` // CPU usage percentage + Memory *MetricValue `json:"memory,omitempty"` // Memory usage + Disk *MetricValue `json:"disk,omitempty"` // Primary disk usage + Network *NetworkMetric `json:"network,omitempty"` // Network I/O Temperature *float64 `json:"temperature,omitempty"` // Temperature in Celsius - Uptime *int64 `json:"uptime,omitempty"` // Uptime in seconds + Uptime *int64 `json:"uptime,omitempty"` // Uptime in seconds // Universal Metadata Tags []string `json:"tags,omitempty"` @@ -69,6 +69,7 @@ const ( ResourceTypeNode ResourceType = "node" // Proxmox VE node ResourceTypeHost ResourceType = "host" // Standalone host (via host-agent) ResourceTypeDockerHost ResourceType = "docker-host" // Docker/Podman host + ResourceTypeK8sCluster ResourceType = "k8s-cluster" // Kubernetes cluster ResourceTypeK8sNode ResourceType = "k8s-node" // Kubernetes node ResourceTypeTrueNAS ResourceType = "truenas" // TrueNAS system @@ -148,8 +149,8 @@ type NetworkMetric struct { // ResourceAlert represents an alert associated with a resource. type ResourceAlert struct { ID string `json:"id"` - Type string `json:"type"` // cpu, memory, disk, temperature, etc. - Level string `json:"level"` // warning, critical + Type string `json:"type"` // cpu, memory, disk, temperature, etc. + Level string `json:"level"` // warning, critical Message string `json:"message"` Value float64 `json:"value"` Threshold float64 `json:"threshold"` @@ -191,7 +192,7 @@ func (r *Resource) SetPlatformData(v interface{}) error { // (node, host, docker-host) rather than a workload (vm, container). func (r *Resource) IsInfrastructure() bool { switch r.Type { - case ResourceTypeNode, ResourceTypeHost, ResourceTypeDockerHost, ResourceTypeK8sNode, ResourceTypeTrueNAS: + case ResourceTypeNode, ResourceTypeHost, ResourceTypeDockerHost, ResourceTypeK8sCluster, ResourceTypeK8sNode, ResourceTypeTrueNAS: return true default: return false diff --git a/internal/resources/store.go b/internal/resources/store.go index 3e891f6..b2babfa 100644 --- a/internal/resources/store.go +++ b/internal/resources/store.go @@ -12,12 +12,12 @@ import ( type Store struct { mu sync.RWMutex resources map[string]*Resource // Keyed by Resource.ID - + // Index by identity for deduplication byHostname map[string][]string // hostname (lower) -> resource IDs byMachineID map[string]string // machine-id -> resource ID byIP map[string][]string // IP -> resource IDs - + // Track merged resources (one source is preferred over another) mergedFrom map[string]string // suppressed ID -> preferred ID } @@ -38,14 +38,14 @@ func NewStore() *Store { func (s *Store) Upsert(r Resource) string { s.mu.Lock() defer s.mu.Unlock() - + // Check for duplicates if r.Identity != nil { if existingID := s.findDuplicate(&r); existingID != "" { // Found a duplicate - determine which to prefer existing := s.resources[existingID] preferred := s.preferredResource(existing, &r) - + if preferred == &r { // New resource is preferred, replace the old one s.removeFromIndexes(existing) @@ -58,11 +58,11 @@ func (s *Store) Upsert(r Resource) string { } } } - + // Add/update the resource s.resources[r.ID] = &r s.addToIndexes(&r) - + return r.ID } @@ -70,13 +70,13 @@ func (s *Store) Upsert(r Resource) string { func (s *Store) Get(id string) (*Resource, bool) { s.mu.RLock() defer s.mu.RUnlock() - + // Check if this ID was merged into another if preferredID, merged := s.mergedFrom[id]; merged { r, ok := s.resources[preferredID] return r, ok } - + r, ok := s.resources[id] return r, ok } @@ -85,7 +85,7 @@ func (s *Store) Get(id string) (*Resource, bool) { func (s *Store) GetAll() []Resource { s.mu.RLock() defer s.mu.RUnlock() - + result := make([]Resource, 0, len(s.resources)) for _, r := range s.resources { result = append(result, *r) @@ -97,7 +97,7 @@ func (s *Store) GetAll() []Resource { func (s *Store) GetByType(t ResourceType) []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var result []Resource for _, r := range s.resources { if r.Type == t { @@ -111,7 +111,7 @@ func (s *Store) GetByType(t ResourceType) []Resource { func (s *Store) GetByPlatform(p PlatformType) []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var result []Resource for _, r := range s.resources { if r.PlatformType == p { @@ -125,7 +125,7 @@ func (s *Store) GetByPlatform(p PlatformType) []Resource { func (s *Store) GetInfrastructure() []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var result []Resource for _, r := range s.resources { if r.IsInfrastructure() { @@ -139,7 +139,7 @@ func (s *Store) GetInfrastructure() []Resource { func (s *Store) GetWorkloads() []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var result []Resource for _, r := range s.resources { if r.IsWorkload() { @@ -153,7 +153,7 @@ func (s *Store) GetWorkloads() []Resource { func (s *Store) GetChildren(parentID string) []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var result []Resource for _, r := range s.resources { if r.ParentID == parentID { @@ -170,13 +170,13 @@ func (s *Store) GetChildren(parentID string) []Resource { func (s *Store) FindContainerHost(containerNameOrID string) string { s.mu.RLock() defer s.mu.RUnlock() - + if containerNameOrID == "" { return "" } - + containerNameLower := strings.ToLower(containerNameOrID) - + // Find the container var container *Resource for _, r := range s.resources { @@ -185,24 +185,24 @@ func (s *Store) FindContainerHost(containerNameOrID string) string { } // Match by name or ID (case-insensitive) if strings.EqualFold(r.Name, containerNameOrID) || - strings.EqualFold(r.ID, containerNameOrID) || - strings.Contains(strings.ToLower(r.Name), containerNameLower) || - strings.Contains(strings.ToLower(r.ID), containerNameLower) { + strings.EqualFold(r.ID, containerNameOrID) || + strings.Contains(strings.ToLower(r.Name), containerNameLower) || + strings.Contains(strings.ToLower(r.ID), containerNameLower) { container = r break } } - + if container == nil || container.ParentID == "" { return "" } - + // Find the parent DockerHost parent := s.resources[container.ParentID] if parent == nil { return "" } - + // Return the hostname from identity, or the name if parent.Identity != nil && parent.Identity.Hostname != "" { return parent.Identity.Hostname @@ -210,17 +210,16 @@ func (s *Store) FindContainerHost(containerNameOrID string) string { return parent.Name } - // Remove removes a resource from the store. func (s *Store) Remove(id string) { s.mu.Lock() defer s.mu.Unlock() - + if r, ok := s.resources[id]; ok { s.removeFromIndexes(r) delete(s.resources, id) } - + // Also clean up any merge references delete(s.mergedFrom, id) for k, v := range s.mergedFrom { @@ -234,7 +233,7 @@ func (s *Store) Remove(id string) { func (s *Store) IsSuppressed(id string) bool { s.mu.RLock() defer s.mu.RUnlock() - + _, suppressed := s.mergedFrom[id] return suppressed } @@ -243,7 +242,7 @@ func (s *Store) IsSuppressed(id string) bool { func (s *Store) GetPreferredID(id string) string { s.mu.RLock() defer s.mu.RUnlock() - + if preferredID, ok := s.mergedFrom[id]; ok { return preferredID } @@ -254,7 +253,7 @@ func (s *Store) GetPreferredID(id string) string { func (s *Store) GetStats() StoreStats { s.mu.RLock() defer s.mu.RUnlock() - + stats := StoreStats{ TotalResources: len(s.resources), SuppressedResources: len(s.mergedFrom), @@ -264,7 +263,7 @@ func (s *Store) GetStats() StoreStats { WithAlerts: 0, LastUpdated: time.Now().UTC().Format(time.RFC3339), } - + for _, r := range s.resources { stats.ByType[r.Type]++ stats.ByPlatform[r.PlatformType]++ @@ -273,19 +272,19 @@ func (s *Store) GetStats() StoreStats { stats.WithAlerts++ } } - + return stats } // StoreStats contains statistics about the resource store. type StoreStats struct { - TotalResources int `json:"totalResources"` - SuppressedResources int `json:"suppressedResources"` - ByType map[ResourceType]int `json:"byType"` - ByPlatform map[PlatformType]int `json:"byPlatform"` - ByStatus map[ResourceStatus]int `json:"byStatus"` - WithAlerts int `json:"withAlerts"` - LastUpdated string `json:"lastUpdated"` + TotalResources int `json:"totalResources"` + SuppressedResources int `json:"suppressedResources"` + ByType map[ResourceType]int `json:"byType"` + ByPlatform map[PlatformType]int `json:"byPlatform"` + ByStatus map[ResourceStatus]int `json:"byStatus"` + WithAlerts int `json:"withAlerts"` + LastUpdated string `json:"lastUpdated"` } // GetPreferredResourceFor returns the preferred resource for a given ID. @@ -294,14 +293,14 @@ type StoreStats struct { func (s *Store) GetPreferredResourceFor(resourceID string) *Resource { s.mu.RLock() defer s.mu.RUnlock() - + // Check if this ID was merged if preferredID, merged := s.mergedFrom[resourceID]; merged { if r, ok := s.resources[preferredID]; ok { return r } } - + // Return the resource itself if it exists if r, ok := s.resources[resourceID]; ok { return r @@ -314,23 +313,23 @@ func (s *Store) GetPreferredResourceFor(resourceID string) *Resource { func (s *Store) IsSamePhysicalMachine(id1, id2 string) bool { s.mu.RLock() defer s.mu.RUnlock() - + // Check if they're literally the same if id1 == id2 { return true } - + // Check if both map to the same preferred resource preferred1 := id1 if pid, merged := s.mergedFrom[id1]; merged { preferred1 = pid } - + preferred2 := id2 if pid, merged := s.mergedFrom[id2]; merged { preferred2 = pid } - + return preferred1 == preferred2 } @@ -341,16 +340,16 @@ func (s *Store) HasPreferredSourceForHostname(hostname string) bool { if hostname == "" { return false } - + s.mu.RLock() defer s.mu.RUnlock() - + hostnameLower := strings.ToLower(hostname) resourceIDs, exists := s.byHostname[hostnameLower] if !exists { return false } - + // Check if any resource with this hostname has a preferred source type for _, id := range resourceIDs { if r, ok := s.resources[id]; ok { @@ -360,7 +359,7 @@ func (s *Store) HasPreferredSourceForHostname(hostname string) bool { } } } - + return false } @@ -369,7 +368,7 @@ func (s *Store) HasPreferredSourceForHostname(hostname string) bool { // These methods help reduce redundant API polling when agents are active // ============================================================================ -// ShouldSkipAPIPolling returns true if API polling should be skipped for the +// ShouldSkipAPIPolling returns true if API polling should be skipped for the // given hostname because an agent is providing richer, more frequent data. // This is useful for reducing load when both Proxmox API and host agents monitor // the same machine. @@ -383,10 +382,10 @@ func (s *Store) ShouldSkipAPIPolling(hostname string) bool { func (s *Store) GetAgentMonitoredHostnames() []string { s.mu.RLock() defer s.mu.RUnlock() - + var hostnames []string seen := make(map[string]bool) - + for _, r := range s.resources { if r.SourceType != SourceAgent && r.SourceType != SourceHybrid { continue @@ -400,7 +399,7 @@ func (s *Store) GetAgentMonitoredHostnames() []string { hostnames = append(hostnames, r.Identity.Hostname) } } - + return hostnames } @@ -412,15 +411,15 @@ func (s *Store) GetAgentMonitoredHostnames() []string { func (s *Store) GetPollingRecommendations() map[string]float64 { s.mu.RLock() defer s.mu.RUnlock() - + recommendations := make(map[string]float64) - + for _, r := range s.resources { if r.Identity == nil || r.Identity.Hostname == "" { continue } hostname := strings.ToLower(r.Identity.Hostname) - + switch r.SourceType { case SourceAgent: // Agent provides all data - skip API polling for metrics @@ -431,7 +430,7 @@ func (s *Store) GetPollingRecommendations() map[string]float64 { recommendations[hostname] = 0.5 // Poll at half frequency } } - + return recommendations } @@ -441,7 +440,7 @@ func (s *Store) findDuplicate(r *Resource) string { if r.Identity == nil { return "" } - + // 1. Machine ID match (most reliable) - but only for same type // A node and host agent on the same machine should coexist as different data sources if r.Identity.MachineID != "" && r.IsInfrastructure() { @@ -453,7 +452,7 @@ func (s *Store) findDuplicate(r *Resource) string { } } } - + // 2. Hostname match (case-insensitive) - only for same infrastructure type // Workloads (VMs, containers) can have duplicate names across clusters if r.Identity.Hostname != "" && r.IsInfrastructure() { @@ -471,7 +470,7 @@ func (s *Store) findDuplicate(r *Resource) string { } } } - + // 3. IP overlap (if same non-localhost IP, likely same machine) - only for same infrastructure type if r.IsInfrastructure() { for _, ip := range r.Identity.IPs { @@ -491,7 +490,7 @@ func (s *Store) findDuplicate(r *Resource) string { } } } - + return "" } @@ -501,14 +500,14 @@ func (s *Store) preferredResource(a, b *Resource) *Resource { // Prefer agent over API aScore := s.sourceScore(a.SourceType) bScore := s.sourceScore(b.SourceType) - + if aScore > bScore { return a } if bScore > aScore { return b } - + // Same source type - prefer the one with more recent data if a.LastSeen.After(b.LastSeen) { return a @@ -533,16 +532,16 @@ func (s *Store) addToIndexes(r *Resource) { if r.Identity == nil { return } - + if r.Identity.MachineID != "" { s.byMachineID[r.Identity.MachineID] = r.ID } - + if r.Identity.Hostname != "" { hostnameLower := strings.ToLower(r.Identity.Hostname) s.byHostname[hostnameLower] = append(s.byHostname[hostnameLower], r.ID) } - + for _, ip := range r.Identity.IPs { if !isNonUniqueIP(ip) { s.byIP[ip] = append(s.byIP[ip], r.ID) @@ -554,13 +553,13 @@ func (s *Store) removeFromIndexes(r *Resource) { if r.Identity == nil { return } - + if r.Identity.MachineID != "" { if s.byMachineID[r.Identity.MachineID] == r.ID { delete(s.byMachineID, r.Identity.MachineID) } } - + if r.Identity.Hostname != "" { hostnameLower := strings.ToLower(r.Identity.Hostname) s.byHostname[hostnameLower] = removeFromSlice(s.byHostname[hostnameLower], r.ID) @@ -568,7 +567,7 @@ func (s *Store) removeFromIndexes(r *Resource) { delete(s.byHostname, hostnameLower) } } - + for _, ip := range r.Identity.IPs { s.byIP[ip] = removeFromSlice(s.byIP[ip], r.ID) if len(s.byIP[ip]) == 0 { @@ -594,20 +593,20 @@ func isNonUniqueIP(ip string) bool { if ip == "127.0.0.1" || ip == "::1" || strings.HasPrefix(ip, "127.") { return true } - + // Docker bridge network - 172.17.0.1/16 exists on every Docker host // Also filter other Docker-assigned bridge networks (172.17-31.x.x) - if strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") || - strings.HasPrefix(ip, "172.19.") || strings.HasPrefix(ip, "172.20.") || - strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") { + if strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") || + strings.HasPrefix(ip, "172.19.") || strings.HasPrefix(ip, "172.20.") || + strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") { return true } - + // Link-local addresses (fe80::) if strings.HasPrefix(strings.ToLower(ip), "fe80:") { return true } - + return false } @@ -615,10 +614,10 @@ func isNonUniqueIP(ip string) bool { func (s *Store) MarkStale(threshold time.Duration) []string { s.mu.Lock() defer s.mu.Unlock() - + now := time.Now() var staleIDs []string - + for id, r := range s.resources { if now.Sub(r.LastSeen) > threshold { // Mark as offline/degraded @@ -628,7 +627,7 @@ func (s *Store) MarkStale(threshold time.Duration) []string { } } } - + return staleIDs } @@ -636,10 +635,10 @@ func (s *Store) MarkStale(threshold time.Duration) []string { func (s *Store) PruneStale(staleThreshold, removeThreshold time.Duration) []string { s.mu.Lock() defer s.mu.Unlock() - + now := time.Now() var removedIDs []string - + for id, r := range s.resources { if now.Sub(r.LastSeen) > removeThreshold { s.removeFromIndexes(r) @@ -647,7 +646,7 @@ func (s *Store) PruneStale(staleThreshold, removeThreshold time.Duration) []stri removedIDs = append(removedIDs, id) } } - + return removedIDs } @@ -658,17 +657,17 @@ func (s *Store) Query() *ResourceQuery { // ResourceQuery provides a fluent query interface. type ResourceQuery struct { - store *Store - types []ResourceType - platforms []PlatformType - statuses []ResourceStatus - parentID *string - clusterID *string - hasAlerts *bool - sortBy string - sortDesc bool - limit int - offset int + store *Store + types []ResourceType + platforms []PlatformType + statuses []ResourceStatus + parentID *string + clusterID *string + hasAlerts *bool + sortBy string + sortDesc bool + limit int + offset int } // OfType filters by resource types. @@ -731,17 +730,17 @@ func (q *ResourceQuery) Offset(n int) *ResourceQuery { func (q *ResourceQuery) Execute() []Resource { q.store.mu.RLock() defer q.store.mu.RUnlock() - + var results []Resource - + for _, r := range q.store.resources { if q.matches(r) { results = append(results, *r) } } - + // TODO: Implement sorting - + // Apply pagination if q.offset > 0 { if q.offset >= len(results) { @@ -749,11 +748,11 @@ func (q *ResourceQuery) Execute() []Resource { } results = results[q.offset:] } - + if q.limit > 0 && q.limit < len(results) { results = results[:q.limit] } - + return results } @@ -761,7 +760,7 @@ func (q *ResourceQuery) Execute() []Resource { func (q *ResourceQuery) Count() int { q.store.mu.RLock() defer q.store.mu.RUnlock() - + count := 0 for _, r := range q.store.resources { if q.matches(r) { @@ -785,7 +784,7 @@ func (q *ResourceQuery) matches(r *Resource) bool { return false } } - + // Platform filter if len(q.platforms) > 0 { found := false @@ -799,7 +798,7 @@ func (q *ResourceQuery) matches(r *Resource) bool { return false } } - + // Status filter if len(q.statuses) > 0 { found := false @@ -813,22 +812,22 @@ func (q *ResourceQuery) matches(r *Resource) bool { return false } } - + // Parent filter if q.parentID != nil && r.ParentID != *q.parentID { return false } - + // Cluster filter if q.clusterID != nil && r.ClusterID != *q.clusterID { return false } - + // Alerts filter if q.hasAlerts != nil && *q.hasAlerts && len(r.Alerts) == 0 { return false } - + return true } @@ -842,7 +841,7 @@ func (q *ResourceQuery) matches(r *Resource) bool { func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var resources []Resource for _, r := range s.resources { if r.CPU == nil || r.CPU.Current == 0 { @@ -862,7 +861,7 @@ func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource { } resources = append(resources, *r) } - + // Sort by CPU usage descending for i := 0; i < len(resources)-1; i++ { for j := i + 1; j < len(resources); j++ { @@ -871,7 +870,7 @@ func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource { } } } - + if limit > 0 && limit < len(resources) { return resources[:limit] } @@ -883,7 +882,7 @@ func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource { func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var resources []Resource for _, r := range s.resources { if r.Memory == nil || r.Memory.Current == 0 { @@ -903,7 +902,7 @@ func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource { } resources = append(resources, *r) } - + // Sort by memory usage descending for i := 0; i < len(resources)-1; i++ { for j := i + 1; j < len(resources); j++ { @@ -912,7 +911,7 @@ func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource { } } } - + if limit > 0 && limit < len(resources) { return resources[:limit] } @@ -924,7 +923,7 @@ func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource { func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource { s.mu.RLock() defer s.mu.RUnlock() - + var resources []Resource for _, r := range s.resources { if r.Disk == nil || r.Disk.Current == 0 { @@ -944,7 +943,7 @@ func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource { } resources = append(resources, *r) } - + // Sort by disk usage descending for i := 0; i < len(resources)-1; i++ { for j := i + 1; j < len(resources); j++ { @@ -953,7 +952,7 @@ func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource { } } } - + if limit > 0 && limit < len(resources) { return resources[:limit] } @@ -965,21 +964,21 @@ func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource { func (s *Store) GetRelated(resourceID string) map[string][]Resource { s.mu.RLock() defer s.mu.RUnlock() - + result := make(map[string][]Resource) - + r, ok := s.resources[resourceID] if !ok { return result } - + // Get parent if r.ParentID != "" { if parent, ok := s.resources[r.ParentID]; ok { result["parent"] = []Resource{*parent} } } - + // Get children var children []Resource for _, other := range s.resources { @@ -990,7 +989,7 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource { if len(children) > 0 { result["children"] = children } - + // Get siblings (same parent) if r.ParentID != "" { var siblings []Resource @@ -1003,7 +1002,7 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource { result["siblings"] = siblings } } - + // Get co-located resources (same cluster) if r.ClusterID != "" { var colocated []Resource @@ -1016,7 +1015,7 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource { result["cluster_members"] = colocated } } - + return result } @@ -1025,15 +1024,15 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource { func (s *Store) GetResourceSummary() ResourceSummary { s.mu.RLock() defer s.mu.RUnlock() - + summary := ResourceSummary{ ByType: make(map[ResourceType]TypeSummary), ByPlatform: make(map[PlatformType]PlatformSummary), } - + for _, r := range s.resources { summary.TotalResources++ - + // Count by status switch r.Status { case StatusOnline, StatusRunning: @@ -1043,12 +1042,12 @@ func (s *Store) GetResourceSummary() ResourceSummary { case StatusOffline, StatusStopped, StatusUnknown: summary.Offline++ } - + // Track alerts if len(r.Alerts) > 0 { summary.WithAlerts++ } - + // Aggregate by type ts := summary.ByType[r.Type] ts.Count++ @@ -1059,13 +1058,13 @@ func (s *Store) GetResourceSummary() ResourceSummary { ts.TotalMemoryPercent += r.MemoryPercent() } summary.ByType[r.Type] = ts - + // Aggregate by platform ps := summary.ByPlatform[r.PlatformType] ps.Count++ summary.ByPlatform[r.PlatformType] = ps } - + // Calculate averages for t, ts := range summary.ByType { if ts.Count > 0 { @@ -1074,7 +1073,7 @@ func (s *Store) GetResourceSummary() ResourceSummary { summary.ByType[t] = ts } } - + return summary } @@ -1153,6 +1152,31 @@ func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) { } } + // Convert Kubernetes clusters and their resources + for _, cluster := range snapshot.KubernetesClusters { + r := FromKubernetesCluster(cluster) + id := s.Upsert(r) + seenIDs[id] = true + + for _, node := range cluster.Nodes { + r := FromKubernetesNode(node, cluster) + id := s.Upsert(r) + seenIDs[id] = true + } + + for _, pod := range cluster.Pods { + r := FromKubernetesPod(pod, cluster) + id := s.Upsert(r) + seenIDs[id] = true + } + + for _, dep := range cluster.Deployments { + r := FromKubernetesDeployment(dep, cluster) + id := s.Upsert(r) + seenIDs[id] = true + } + } + // Convert PBS instances for _, pbs := range snapshot.PBSInstances { r := FromPBSInstance(pbs) @@ -1167,14 +1191,14 @@ func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) { seenIDs[id] = true } - // Remove resources that were NOT in this snapshot BUT were sourced from API - // (Agent-sourced resources like hosts from host-agent should persist independently) + // Remove resources that were NOT in this snapshot. + // The snapshot is the authoritative source for the in-memory store (including agent-sourced data). s.mu.Lock() defer s.mu.Unlock() var toRemove []string - for id, r := range s.resources { - if !seenIDs[id] && r.SourceType == SourceAPI { + for id := range s.resources { + if !seenIDs[id] { toRemove = append(toRemove, id) } } diff --git a/pkg/agents/kubernetes/report.go b/pkg/agents/kubernetes/report.go new file mode 100644 index 0000000..8de5329 --- /dev/null +++ b/pkg/agents/kubernetes/report.go @@ -0,0 +1,98 @@ +package kubernetes + +import "time" + +// Report represents a single heartbeat from the Kubernetes agent to Pulse. +// It is designed to be useful without requiring Metrics Server (metrics.k8s.io). +type Report struct { + Agent AgentInfo `json:"agent"` + Cluster ClusterInfo `json:"cluster"` + Nodes []Node `json:"nodes,omitempty"` + Pods []Pod `json:"pods,omitempty"` + Deployments []Deployment `json:"deployments,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// AgentInfo describes the reporting agent instance. +type AgentInfo struct { + ID string `json:"id"` + Version string `json:"version"` + Type string `json:"type,omitempty"` // "unified" when running as part of pulse-agent + IntervalSeconds int `json:"intervalSeconds"` +} + +// ClusterInfo identifies the Kubernetes cluster and provides basic metadata. +type ClusterInfo struct { + ID string `json:"id"` // Stable cluster identifier (agent-generated) + Name string `json:"name,omitempty"` // Optional human-friendly name + Server string `json:"server,omitempty"` // API server URL + Context string `json:"context,omitempty"` // Kubeconfig context name if applicable + Version string `json:"version,omitempty"` // Kubernetes version string + Provider string `json:"provider,omitempty"` // Optional: gke, eks, aks, etc (best-effort) + Namespace string `json:"namespace,omitempty"` // Optional agent namespace when running in-cluster +} + +// Node represents a Kubernetes node at report time. +type Node struct { + UID string `json:"uid"` + Name string `json:"name"` + Ready bool `json:"ready"` + Unschedulable bool `json:"unschedulable,omitempty"` + KubeletVersion string `json:"kubeletVersion,omitempty"` + ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"` + OSImage string `json:"osImage,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + Capacity NodeResources `json:"capacity,omitempty"` + Allocatable NodeResources `json:"allocatable,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +type NodeResources struct { + CPUCores int64 `json:"cpuCores,omitempty"` + MemoryBytes int64 `json:"memoryBytes,omitempty"` + Pods int64 `json:"pods,omitempty"` +} + +// Pod represents a Kubernetes pod at report time. +type Pod struct { + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + NodeName string `json:"nodeName,omitempty"` + Phase string `json:"phase,omitempty"` // Pending, Running, Succeeded, Failed, Unknown + Reason string `json:"reason,omitempty"` // Best-effort + Message string `json:"message,omitempty"` + QoSClass string `json:"qosClass,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + StartTime *time.Time `json:"startTime,omitempty"` + Restarts int `json:"restarts,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + + OwnerKind string `json:"ownerKind,omitempty"` + OwnerName string `json:"ownerName,omitempty"` + + Containers []PodContainer `json:"containers,omitempty"` +} + +type PodContainer struct { + Name string `json:"name"` + Image string `json:"image,omitempty"` + Ready bool `json:"ready"` + RestartCount int32 `json:"restartCount,omitempty"` + State string `json:"state,omitempty"` // waiting, running, terminated + Reason string `json:"reason,omitempty"` // waiting/terminated reason + Message string `json:"message,omitempty"` +} + +// Deployment represents a Kubernetes deployment at report time. +type Deployment struct { + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + UpdatedReplicas int32 `json:"updatedReplicas,omitempty"` + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + AvailableReplicas int32 `json:"availableReplicas,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index cd2ba42..dc8e8cb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -9,6 +9,7 @@ param ( [string]$Interval = "30s", [bool]$EnableHost = $true, [bool]$EnableDocker = $false, + [bool]$EnableKubernetes = $false, [bool]$Insecure = $false, [bool]$Uninstall = $false, [string]$AgentId = "" @@ -21,6 +22,43 @@ $InstallDir = "C:\Program Files\Pulse" $LogFile = "$env:ProgramData\Pulse\pulse-agent.log" $DownloadTimeoutSec = 300 +function Parse-Bool { + param( + [string]$Value, + [bool]$Default = $false + ) + if ([string]::IsNullOrWhiteSpace($Value)) { + return $Default + } + switch ($Value.Trim().ToLowerInvariant()) { + '1' { return $true } + 'true' { return $true } + 'yes' { return $true } + 'y' { return $true } + 'on' { return $true } + '0' { return $false } + 'false' { return $false } + 'no' { return $false } + 'n' { return $false } + 'off' { return $false } + default { return $Default } + } +} + +# Support env-var configuration for boolean flags (unless explicitly passed as parameters). +if (-not $PSBoundParameters.ContainsKey('EnableHost') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_HOST)) { + $EnableHost = Parse-Bool $env:PULSE_ENABLE_HOST $EnableHost +} +if (-not $PSBoundParameters.ContainsKey('EnableDocker') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_DOCKER)) { + $EnableDocker = Parse-Bool $env:PULSE_ENABLE_DOCKER $EnableDocker +} +if (-not $PSBoundParameters.ContainsKey('EnableKubernetes') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_KUBERNETES)) { + $EnableKubernetes = Parse-Bool $env:PULSE_ENABLE_KUBERNETES $EnableKubernetes +} +if (-not $PSBoundParameters.ContainsKey('Insecure') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_INSECURE_SKIP_VERIFY)) { + $Insecure = Parse-Bool $env:PULSE_INSECURE_SKIP_VERIFY $Insecure +} + # --- Administrator Check --- $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { @@ -309,6 +347,7 @@ $ServiceArgs = @( ) if ($EnableHost) { $ServiceArgs += "--enable-host" } if ($EnableDocker) { $ServiceArgs += "--enable-docker" } +if ($EnableKubernetes) { $ServiceArgs += "--enable-kubernetes" } if ($Insecure) { $ServiceArgs += "--insecure" } if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") } @@ -319,7 +358,7 @@ try { New-Service -Name $AgentName ` -BinaryPathName $BinPath ` -DisplayName "Pulse Unified Agent" ` - -Description "Pulse Unified Agent for Host and Docker monitoring" ` + -Description "Pulse Unified Agent for Host, Docker, and Kubernetes monitoring" ` -StartupType Automatic | Out-Null Write-Host "Service created successfully" -ForegroundColor Green } catch { diff --git a/scripts/install.sh b/scripts/install.sh index c4e97d0..5494ba8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,6 +9,7 @@ # Options: # --enable-host Enable host metrics (default: true) # --enable-docker Enable docker metrics (default: false) +# --enable-kubernetes Enable Kubernetes metrics (default: false) # --interval Reporting interval (default: 30s) # --agent-id Custom agent identifier (default: auto-generated) # --uninstall Remove the agent @@ -54,6 +55,7 @@ PULSE_TOKEN="" INTERVAL="30s" ENABLE_HOST="true" ENABLE_DOCKER="false" +ENABLE_KUBERNETES="false" ENABLE_PROXMOX="false" PROXMOX_TYPE="" UNINSTALL="false" @@ -85,6 +87,7 @@ build_exec_args() { EXEC_ARGS="$EXEC_ARGS --enable-host=false" fi if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-kubernetes"; fi if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-proxmox"; fi if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS="$EXEC_ARGS --proxmox-type ${PROXMOX_TYPE}"; fi if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi @@ -102,6 +105,7 @@ build_exec_args_array() { EXEC_ARGS_ARRAY+=(--enable-host=false) fi if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-kubernetes); fi if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-proxmox); fi if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS_ARRAY+=(--proxmox-type "$PROXMOX_TYPE"); fi if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi @@ -118,6 +122,8 @@ while [[ $# -gt 0 ]]; do --disable-host) ENABLE_HOST="false"; shift ;; --enable-docker) ENABLE_DOCKER="true"; shift ;; --disable-docker) ENABLE_DOCKER="false"; shift ;; + --enable-kubernetes) ENABLE_KUBERNETES="true"; shift ;; + --disable-kubernetes) ENABLE_KUBERNETES="false"; shift ;; --enable-proxmox) ENABLE_PROXMOX="true"; shift ;; --proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;; --insecure) INSECURE="true"; shift ;; @@ -406,6 +412,10 @@ if [[ "$OS" == "darwin" ]]; then PLIST_ARGS="${PLIST_ARGS} --enable-docker" fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-kubernetes" + fi if [[ "$INSECURE" == "true" ]]; then PLIST_ARGS="${PLIST_ARGS} --insecure" @@ -694,6 +704,7 @@ PULSE_TOKEN=${PULSE_TOKEN} PULSE_INTERVAL=${INTERVAL} PULSE_ENABLE_HOST=${ENABLE_HOST} PULSE_ENABLE_DOCKER=${ENABLE_DOCKER} +PULSE_ENABLE_KUBERNETES=${ENABLE_KUBERNETES} EOF chmod 600 "$TRUENAS_ENV_FILE"