feat: add Kubernetes platform support

This commit is contained in:
rcourtman 2025-12-12 21:31:11 +00:00
parent a8188b92fb
commit d96942596f
33 changed files with 3437 additions and 445 deletions

View file

@ -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
}
}
}

View file

@ -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
---

View file

@ -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://<pulse-ip>: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://<pulse-ip>: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://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
```
### Host + Kubernetes Monitoring
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
```
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \

View file

@ -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

View file

@ -216,6 +216,191 @@ export class MonitoringAPI {
}
}
static async deleteKubernetesCluster(clusterId: string): Promise<DeleteKubernetesClusterResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
}

View file

@ -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<string | null>(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 (
<div class="space-y-6">
<Card padding="lg" class="space-y-5">
@ -391,7 +416,7 @@ export const UnifiedAgents: Component = () => {
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a fresh token scoped for both Host and Docker monitoring.
Create a fresh token scoped for Host, Docker, and Kubernetes monitoring.
</p>
</div>
@ -466,6 +491,15 @@ export const UnifiedAgents: Component = () => {
/>
Docker monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={enableKubernetes()}
onChange={(e) => setEnableKubernetes(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Kubernetes monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="For Proxmox VE/PBS nodes - auto-creates API token and registers the node">
<input
type="checkbox"
@ -524,6 +558,14 @@ export const UnifiedAgents: Component = () => {
cmd += getDockerFlag();
}
}
// Append kubernetes flag if enabled
if (enableKubernetes()) {
if (cmd.includes('$env:PULSE_URL')) {
cmd = `$env:PULSE_ENABLE_KUBERNETES="true"; ` + cmd;
} else if (isBashScript) {
cmd += getKubernetesFlag();
}
}
// Append proxmox flag if enabled (Linux only - Proxmox doesn't run on Windows/macOS)
if (enableProxmox() && isBashScript) {
cmd += getProxmoxFlag();
@ -800,6 +842,68 @@ export const UnifiedAgents: Component = () => {
</Card>
</Card>
<Card padding="lg" class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Kubernetes Clusters</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Kubernetes clusters currently reporting to Pulse.
</p>
</div>
<Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Version</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Last Seen</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<For each={kubernetesClusters()} fallback={
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No Kubernetes clusters reporting yet.
</td>
</tr>
}>
{(cluster) => (
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${connectedFromStatus(cluster.status)
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}>
{cluster.status}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{cluster.version || cluster.agentVersion || '—'}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
<button
onClick={() => handleRemoveAgent(cluster.id, 'kubernetes')}
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
Remove
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Card>
</Card>
<Show when={hasRemovedDockerHosts()}>
<Card padding="lg" class="space-y-4">
<div class="space-y-1">
@ -848,6 +952,55 @@ export const UnifiedAgents: Component = () => {
</Card>
</Card>
</Show>
<Show when={hasRemovedKubernetesClusters()}>
<Card padding="lg" class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Removed Kubernetes Clusters</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Kubernetes clusters that were removed and are blocked from re-enrolling. Allow re-enrollment to let them report again.
</p>
</div>
<Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster ID</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Removed</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<For each={removedKubernetesClusters()}>
{(cluster) => (
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{cluster.displayName || cluster.name || 'Unknown'}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-mono text-xs">
{cluster.id.slice(0, 8)}...
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{formatRelativeTime(cluster.removedAt)}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
<button
onClick={() => handleAllowKubernetesReenroll(cluster.id, cluster.displayName || cluster.name)}
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
Allow re-enroll
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Card>
</Card>
</Show>
</div >
);
};

View file

@ -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,
);
});

View file

@ -187,16 +187,10 @@ export const Sparkline: Component<SparklineProps> = (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) {

View file

@ -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',

View file

@ -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.

View file

@ -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;

View file

@ -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 {

35
go.mod
View file

@ -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
)

BIN
go.sum

Binary file not shown.

View file

@ -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")
}
}

View file

@ -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) {

View file

@ -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,

View file

@ -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},

View file

@ -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
}

View file

@ -0,0 +1,4 @@
package kubernetesagent
// Version is overridden at build time.
var Version = "dev"

View file

@ -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{

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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")
}
}
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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"`
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"`
}

View file

@ -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 {

View file

@ -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 <dur> Reporting interval (default: 30s)
# --agent-id <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}
<string>--enable-docker</string>"
fi
if [[ "$ENABLE_KUBERNETES" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--enable-kubernetes</string>"
fi
if [[ "$INSECURE" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--insecure</string>"
@ -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"