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" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
@ -18,6 +19,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate" "github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "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/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
@ -30,7 +32,7 @@ var (
agentInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ agentInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "pulse_agent_info", Name: "pulse_agent_info",
Help: "Information about the Pulse agent", 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{ agentUp = promauto.NewGauge(prometheus.GaugeOpts{
Name: "pulse_agent_up", Name: "pulse_agent_up",
@ -78,6 +80,7 @@ func main() {
Str("pulse_url", cfg.PulseURL). Str("pulse_url", cfg.PulseURL).
Bool("host_agent", cfg.EnableHost). Bool("host_agent", cfg.EnableHost).
Bool("docker_agent", cfg.EnableDocker). Bool("docker_agent", cfg.EnableDocker).
Bool("kubernetes_agent", cfg.EnableKubernetes).
Bool("proxmox_mode", cfg.EnableProxmox). Bool("proxmox_mode", cfg.EnableProxmox).
Bool("auto_update", !cfg.DisableAutoUpdate). Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse Unified Agent") Msg("Starting Pulse Unified Agent")
@ -87,6 +90,7 @@ func main() {
Version, Version,
fmt.Sprintf("%t", cfg.EnableHost), fmt.Sprintf("%t", cfg.EnableHost),
fmt.Sprintf("%t", cfg.EnableDocker), fmt.Sprintf("%t", cfg.EnableDocker),
fmt.Sprintf("%t", cfg.EnableKubernetes),
).Set(1) ).Set(1)
agentUp.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 // Mark as ready after all agents started
ready.Store(true) 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 { if err := g.Wait(); err != nil && err != context.Canceled {
logger.Error().Err(err).Msg("Agent terminated with error") logger.Error().Err(err).Msg("Agent terminated with error")
agentUp.Set(0) agentUp.Set(0)
@ -198,7 +242,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// 11. Cleanup // 12. Cleanup
agentUp.Set(0) agentUp.Set(0)
cleanupDockerAgent(dockerAgent, &logger) cleanupDockerAgent(dockerAgent, &logger)
@ -274,16 +318,25 @@ type Config struct {
Logger *zerolog.Logger Logger *zerolog.Logger
// Module flags // Module flags
EnableHost bool EnableHost bool
EnableDocker bool EnableDocker bool
EnableProxmox bool EnableKubernetes bool
ProxmoxType string // "pve", "pbs", or "" for auto-detect EnableProxmox bool
ProxmoxType string // "pve", "pbs", or "" for auto-detect
// Auto-update // Auto-update
DisableAutoUpdate bool DisableAutoUpdate bool
// Health/metrics server // Health/metrics server
HealthAddr string HealthAddr string
// Kubernetes
KubeconfigPath string
KubeContext string
KubeIncludeNamespaces []string
KubeExcludeNamespaces []string
KubeIncludeAllPods bool
KubeMaxPods int
} }
func loadConfig() Config { func loadConfig() Config {
@ -298,10 +351,17 @@ func loadConfig() Config {
envLogLevel := utils.GetenvTrim("LOG_LEVEL") envLogLevel := utils.GetenvTrim("LOG_LEVEL")
envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST") envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST")
envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER") envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER")
envEnableKubernetes := utils.GetenvTrim("PULSE_ENABLE_KUBERNETES")
envEnableProxmox := utils.GetenvTrim("PULSE_ENABLE_PROXMOX") envEnableProxmox := utils.GetenvTrim("PULSE_ENABLE_PROXMOX")
envProxmoxType := utils.GetenvTrim("PULSE_PROXMOX_TYPE") envProxmoxType := utils.GetenvTrim("PULSE_PROXMOX_TYPE")
envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE") envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE")
envHealthAddr := utils.GetenvTrim("PULSE_HEALTH_ADDR") 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 // Defaults
defaultInterval := 30 * time.Second defaultInterval := 30 * time.Second
@ -321,6 +381,11 @@ func loadConfig() Config {
defaultEnableDocker = utils.ParseBool(envEnableDocker) defaultEnableDocker = utils.ParseBool(envEnableDocker)
} }
defaultEnableKubernetes := false
if envEnableKubernetes != "" {
defaultEnableKubernetes = utils.ParseBool(envEnableKubernetes)
}
defaultEnableProxmox := false defaultEnableProxmox := false
if envEnableProxmox != "" { if envEnableProxmox != "" {
defaultEnableProxmox = utils.ParseBool(envEnableProxmox) defaultEnableProxmox = utils.ParseBool(envEnableProxmox)
@ -342,14 +407,23 @@ func loadConfig() Config {
enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module") enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker 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)") 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)") 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") 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)") 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") showVersion := flag.Bool("version", false, "Print the agent version and exit")
var tagFlags multiValue var tagFlags multiValue
flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)") 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() flag.Parse()
@ -376,22 +450,31 @@ func loadConfig() Config {
} }
tags := gatherTags(envTags, tagFlags) tags := gatherTags(envTags, tagFlags)
kubeIncludeNamespaces := gatherCSV(envKubeIncludeNamespaces, kubeIncludeNamespaceFlags)
kubeExcludeNamespaces := gatherCSV(envKubeExcludeNamespaces, kubeExcludeNamespaceFlags)
return Config{ return Config{
PulseURL: pulseURL, PulseURL: pulseURL,
APIToken: token, APIToken: token,
Interval: *intervalFlag, Interval: *intervalFlag,
HostnameOverride: strings.TrimSpace(*hostnameFlag), HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag), AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags, Tags: tags,
InsecureSkipVerify: *insecureFlag, InsecureSkipVerify: *insecureFlag,
LogLevel: logLevel, LogLevel: logLevel,
EnableHost: *enableHostFlag, EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag, EnableDocker: *enableDockerFlag,
EnableProxmox: *enableProxmoxFlag, EnableKubernetes: *enableKubernetesFlag,
ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag), EnableProxmox: *enableProxmoxFlag,
DisableAutoUpdate: *disableAutoUpdateFlag, ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag),
HealthAddr: strings.TrimSpace(*healthAddrFlag), 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 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) { func parseLogLevel(value string) (zerolog.Level, error) {
normalized := strings.ToLower(strings.TrimSpace(value)) normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" { 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` `GET /download/pulse-agent`
Downloads the unified agent binary for the current platform. 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. 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 ### Submit Reports
`POST /api/agents/host/report` - Host metrics `POST /api/agents/host/report` - Host metrics
`POST /api/agents/docker/report` - Docker container 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 # 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 ## Quick Start
@ -29,6 +29,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
- **Host Metrics**: CPU, memory, disk, network I/O, temperatures - **Host Metrics**: CPU, memory, disk, network I/O, temperatures
- **Docker Monitoring**: Container metrics, health checks, Swarm support (when enabled) - **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 - **Auto-Update**: Automatically updates when a new version is released
- **Multi-Platform**: Linux, macOS, Windows support - **Multi-Platform**: Linux, macOS, Windows support
@ -41,6 +42,13 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
| `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` | | `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` |
| `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` | | `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` |
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` | | `--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` | | `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` |
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` | | `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* | | `--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 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 ### Docker Monitoring Only
```bash ```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \ curl -fsSL http://<pulse-ip>:7655/install.sh | \

View file

@ -1,7 +1,7 @@
/** /**
* Tests for Charts API types and interface * 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'; import type { ChartData, ChartsResponse, TimeRange, MetricPoint, ChartStats } from '@/api/charts';
// Note: We test the types and interfaces here since the actual API calls // 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> { static async deleteHostAgent(hostId: string): Promise<void> {
if (!hostId) { if (!hostId) {
throw new Error('Host ID is required to remove a host agent.'); throw new Error('Host ID is required to remove a host agent.');
@ -313,3 +498,9 @@ export interface DeleteDockerHostResponse {
message?: string; message?: string;
command?: DockerHostCommand; 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 { SecurityStatus } from '@/types/config';
import type { HostLookupResponse } from '@/types/api'; import type { HostLookupResponse } from '@/types/api';
import type { APITokenRecord } from '@/api/security'; 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 { copyToClipboard } from '@/utils/clipboard';
import { getPulseBaseUrl } from '@/utils/url'; import { getPulseBaseUrl } from '@/utils/url';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@ -108,6 +108,7 @@ export const UnifiedAgents: Component = () => {
const [lookupError, setLookupError] = createSignal<string | null>(null); const [lookupError, setLookupError] = createSignal<string | null>(null);
const [lookupLoading, setLookupLoading] = createSignal(false); const [lookupLoading, setLookupLoading] = createSignal(false);
const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring 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 [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) const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806)
@ -183,15 +184,15 @@ export const UnifiedAgents: Component = () => {
setIsGeneratingToken(true); setIsGeneratingToken(true);
try { try {
const desiredName = tokenName().trim() || buildDefaultTokenName(); const desiredName = tokenName().trim() || buildDefaultTokenName();
// Generate token with BOTH scopes // Generate token with unified agent reporting scopes
const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE]; const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE];
const { token, record } = await SecurityAPI.createToken(desiredName, scopes); const { token, record } = await SecurityAPI.createToken(desiredName, scopes);
setCurrentToken(token); setCurrentToken(token);
setLatestRecord(record); setLatestRecord(record);
setTokenName(''); setTokenName('');
setConfirmedNoToken(false); 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) { } catch (err) {
logger.error('Failed to generate agent token', 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); 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 getDockerFlag = () => enableDocker() ? ' --enable-docker' : '';
const getKubernetesFlag = () => enableKubernetes() ? ' --enable-kubernetes' : '';
const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : ''; const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : '';
const getInsecureFlag = () => insecureMode() ? ' --insecure' : ''; const getInsecureFlag = () => insecureMode() ? ' --insecure' : '';
const getCurlInsecureFlag = () => insecureMode() ? '-k' : ''; const getCurlInsecureFlag = () => insecureMode() ? '-k' : '';
@ -344,19 +346,32 @@ export const UnifiedAgents: Component = () => {
}); });
const hasRemovedDockerHosts = createMemo(() => removedDockerHosts().length > 0); 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 getUpgradeCommand = (_hostname: string) => {
const token = resolvedToken(); const token = resolvedToken();
return `curl ${getCurlInsecureFlag()}-fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${token}${getInsecureFlag()}`; 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; 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 { try {
if (type === 'host') { if (type === 'host') {
await MonitoringAPI.deleteHostAgent(id); await MonitoringAPI.deleteHostAgent(id);
} else { } else if (type === 'docker') {
await MonitoringAPI.deleteDockerHost(id); await MonitoringAPI.deleteDockerHost(id);
} else {
await MonitoringAPI.deleteKubernetesCluster(id);
} }
notificationStore.success('Agent removed from Pulse'); notificationStore.success('Agent removed from Pulse');
} catch (err) { } 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 ( return (
<div class="space-y-6"> <div class="space-y-6">
<Card padding="lg" class="space-y-5"> <Card padding="lg" class="space-y-5">
@ -391,7 +416,7 @@ export const UnifiedAgents: Component = () => {
<div class="space-y-1"> <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 font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
@ -466,6 +491,15 @@ export const UnifiedAgents: Component = () => {
/> />
Docker monitoring Docker monitoring
</label> </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"> <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 <input
type="checkbox" type="checkbox"
@ -524,6 +558,14 @@ export const UnifiedAgents: Component = () => {
cmd += getDockerFlag(); 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) // Append proxmox flag if enabled (Linux only - Proxmox doesn't run on Windows/macOS)
if (enableProxmox() && isBashScript) { if (enableProxmox() && isBashScript) {
cmd += getProxmoxFlag(); cmd += getProxmoxFlag();
@ -800,6 +842,68 @@ export const UnifiedAgents: Component = () => {
</Card> </Card>
</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()}> <Show when={hasRemovedDockerHosts()}>
<Card padding="lg" class="space-y-4"> <Card padding="lg" class="space-y-4">
<div class="space-y-1"> <div class="space-y-1">
@ -848,6 +952,55 @@ export const UnifiedAgents: Component = () => {
</Card> </Card>
</Card> </Card>
</Show> </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 > </div >
); );
}; };

View file

@ -177,7 +177,7 @@ describe('UnifiedAgents token generation', () => {
{ interval: 0 }, { interval: 0 },
); );
expect(notificationSuccessMock).toHaveBeenCalledWith( expect(notificationSuccessMock).toHaveBeenCalledWith(
'Token generated with Host and Docker permissions.', 'Token generated with Host, Docker, and Kubernetes permissions.',
4000, 4000,
); );
}); });

View file

@ -187,16 +187,10 @@ export const Sparkline: Component<SparklineProps> = (props) => {
// Redraw when data or dimensions change // Redraw when data or dimensions change
createEffect(() => { createEffect(() => {
// Read reactive values to track dependencies void props.data;
// This ensures the effect re-runs when data, metric, or dimensions change void props.metric;
// eslint-disable-next-line @typescript-eslint/no-unused-vars void width();
const _data = props.data; void height();
// 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();
// Unregister previous draw callback if it exists // Unregister previous draw callback if it exists
if (unregister) { if (unregister) {

View file

@ -8,6 +8,8 @@ export interface APIScopeOption {
export const HOST_AGENT_SCOPE = 'host-agent:report'; export const HOST_AGENT_SCOPE = 'host-agent:report';
export const DOCKER_REPORT_SCOPE = 'docker:report'; export const DOCKER_REPORT_SCOPE = 'docker:report';
export const DOCKER_MANAGE_SCOPE = 'docker:manage'; 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_READ_SCOPE = 'monitoring:read';
export const MONITORING_WRITE_SCOPE = 'monitoring:write'; export const MONITORING_WRITE_SCOPE = 'monitoring:write';
export const SETTINGS_READ_SCOPE = 'settings:read'; 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.', description: 'Enable agent-triggered runtime commands and host actions.',
group: 'Agents', 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, value: HOST_AGENT_SCOPE,
label: 'Host agent reporting', label: 'Host agent reporting',

View file

@ -3,7 +3,7 @@
* *
* These tests focus on the ring buffer operations and metric recording logic. * 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), // We can't directly test the module due to side effects (localStorage, window),
// so we test the core logic patterns used in the implementation. // so we test the core logic patterns used in the implementation.

View file

@ -8,6 +8,8 @@ export interface State {
containers: Container[]; containers: Container[];
dockerHosts: DockerHost[]; dockerHosts: DockerHost[];
removedDockerHosts?: RemovedDockerHost[]; removedDockerHosts?: RemovedDockerHost[];
kubernetesClusters?: KubernetesCluster[];
removedKubernetesClusters?: RemovedKubernetesCluster[];
hosts: Host[]; hosts: Host[];
replicationJobs: ReplicationJob[]; replicationJobs: ReplicationJob[];
storage: Storage[]; storage: Storage[];
@ -38,6 +40,34 @@ export interface RemovedDockerHost {
removedAt: number; 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 { export interface Node {
id: string; id: string;
name: string; name: string;

View file

@ -12,6 +12,7 @@ export type ResourceType =
| 'node' // Proxmox VE node | 'node' // Proxmox VE node
| 'host' // Standalone host (via host-agent) | 'host' // Standalone host (via host-agent)
| 'docker-host' // Docker/Podman host | 'docker-host' // Docker/Podman host
| 'k8s-cluster' // Kubernetes cluster
| 'k8s-node' // Kubernetes node | 'k8s-node' // Kubernetes node
| 'truenas' // TrueNAS system | 'truenas' // TrueNAS system
| 'vm' // Proxmox VM | 'vm' // Proxmox VM
@ -134,7 +135,7 @@ export interface Resource {
* Helper type guards * Helper type guards
*/ */
export function isInfrastructure(r: Resource): boolean { 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 { 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/rs/zerolog v1.34.0
github.com/shirou/gopsutil/v4 v4.25.11 github.com/shirou/gopsutil/v4 v4.25.11
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.33.0 golang.org/x/oauth2 v0.33.0
golang.org/x/sync v0.18.0 golang.org/x/sync v0.18.0
@ -27,6 +26,10 @@ require (
golang.org/x/term v0.37.0 golang.org/x/term v0.37.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1 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 ( require (
@ -36,34 +39,48 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.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/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.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/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-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // 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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/klauspost/compress v1.18.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // 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/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect
@ -71,6 +88,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // 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 github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.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.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // 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 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/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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 notificationHandlers *NotificationHandlers
notificationQueueHandlers *NotificationQueueHandlers notificationQueueHandlers *NotificationQueueHandlers
dockerAgentHandlers *DockerAgentHandlers dockerAgentHandlers *DockerAgentHandlers
kubernetesAgentHandlers *KubernetesAgentHandlers
hostAgentHandlers *HostAgentHandlers hostAgentHandlers *HostAgentHandlers
temperatureProxyHandlers *TemperatureProxyHandlers temperatureProxyHandlers *TemperatureProxyHandlers
systemSettingsHandler *SystemSettingsHandler systemSettingsHandler *SystemSettingsHandler
@ -188,6 +189,7 @@ func (r *Router) setupRoutes() {
r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings) r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings)
updateHandlers := NewUpdateHandlers(r.updateManager, r.updateHistory) updateHandlers := NewUpdateHandlers(r.updateManager, r.updateHistory)
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub) r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.monitor, r.wsHub)
r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub) r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub)
r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.config, r.persistence, r.reloadFunc) r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.config, r.persistence, r.reloadFunc)
r.resourceHandlers = NewResourceHandlers() 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/monitoring/scheduler/health", RequireAuth(r.config, r.handleSchedulerHealth))
r.mux.HandleFunc("/api/state", r.handleState) 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/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/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/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))) 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/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/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/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/version", r.handleVersion)
r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage))) 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))) r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts)))

View file

@ -11,15 +11,17 @@ import (
// Canonical API token scope strings. // Canonical API token scope strings.
const ( const (
ScopeWildcard = "*" ScopeWildcard = "*"
ScopeMonitoringRead = "monitoring:read" ScopeMonitoringRead = "monitoring:read"
ScopeMonitoringWrite = "monitoring:write" ScopeMonitoringWrite = "monitoring:write"
ScopeDockerReport = "docker:report" ScopeDockerReport = "docker:report"
ScopeDockerManage = "docker:manage" ScopeDockerManage = "docker:manage"
ScopeHostReport = "host-agent:report" ScopeKubernetesReport = "kubernetes:report"
ScopeHostManage = "host-agent:manage" ScopeKubernetesManage = "kubernetes:manage"
ScopeSettingsRead = "settings:read" ScopeHostReport = "host-agent:report"
ScopeSettingsWrite = "settings:write" ScopeHostManage = "host-agent:manage"
ScopeSettingsRead = "settings:read"
ScopeSettingsWrite = "settings:write"
) )
// AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel). // AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel).
@ -28,6 +30,8 @@ var AllKnownScopes = []string{
ScopeMonitoringWrite, ScopeMonitoringWrite,
ScopeDockerReport, ScopeDockerReport,
ScopeDockerManage, ScopeDockerManage,
ScopeKubernetesReport,
ScopeKubernetesManage,
ScopeHostReport, ScopeHostReport,
ScopeHostManage, ScopeHostManage,
ScopeSettingsRead, ScopeSettingsRead,

View file

@ -167,6 +167,8 @@ func TestIsKnownScope(t *testing.T) {
{name: "monitoring:write", scope: ScopeMonitoringWrite, expected: true}, {name: "monitoring:write", scope: ScopeMonitoringWrite, expected: true},
{name: "docker:report", scope: ScopeDockerReport, expected: true}, {name: "docker:report", scope: ScopeDockerReport, expected: true},
{name: "docker:manage", scope: ScopeDockerManage, 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:report", scope: ScopeHostReport, expected: true},
{name: "host-agent:manage", scope: ScopeHostManage, expected: true}, {name: "host-agent:manage", scope: ScopeHostManage, expected: true},
{name: "settings:read", scope: ScopeSettingsRead, 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. // ToFrontend converts a Host to HostFrontend.
func (h Host) ToFrontend() HostFrontend { func (h Host) ToFrontend() HostFrontend {
host := HostFrontend{ host := HostFrontend{

View file

@ -11,30 +11,32 @@ import (
// State represents the current state of all monitored resources // State represents the current state of all monitored resources
type State struct { type State struct {
mu sync.RWMutex mu sync.RWMutex
Nodes []Node `json:"nodes"` Nodes []Node `json:"nodes"`
VMs []VM `json:"vms"` VMs []VM `json:"vms"`
Containers []Container `json:"containers"` Containers []Container `json:"containers"`
DockerHosts []DockerHost `json:"dockerHosts"` DockerHosts []DockerHost `json:"dockerHosts"`
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
Hosts []Host `json:"hosts"` KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"`
Storage []Storage `json:"storage"` RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"`
CephClusters []CephCluster `json:"cephClusters"` Hosts []Host `json:"hosts"`
PhysicalDisks []PhysicalDisk `json:"physicalDisks"` Storage []Storage `json:"storage"`
PBSInstances []PBSInstance `json:"pbs"` CephClusters []CephCluster `json:"cephClusters"`
PMGInstances []PMGInstance `json:"pmg"` PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
PBSBackups []PBSBackup `json:"pbsBackups"` PBSInstances []PBSInstance `json:"pbs"`
PMGBackups []PMGBackup `json:"pmgBackups"` PMGInstances []PMGInstance `json:"pmg"`
Backups Backups `json:"backups"` PBSBackups []PBSBackup `json:"pbsBackups"`
ReplicationJobs []ReplicationJob `json:"replicationJobs"` PMGBackups []PMGBackup `json:"pmgBackups"`
Metrics []Metric `json:"metrics"` Backups Backups `json:"backups"`
PVEBackups PVEBackups `json:"pveBackups"` ReplicationJobs []ReplicationJob `json:"replicationJobs"`
Performance Performance `json:"performance"` Metrics []Metric `json:"metrics"`
ConnectionHealth map[string]bool `json:"connectionHealth"` PVEBackups PVEBackups `json:"pveBackups"`
Stats Stats `json:"stats"` Performance Performance `json:"performance"`
ActiveAlerts []Alert `json:"activeAlerts"` ConnectionHealth map[string]bool `json:"connectionHealth"`
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` Stats Stats `json:"stats"`
LastUpdate time.Time `json:"lastUpdate"` ActiveAlerts []Alert `json:"activeAlerts"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
LastUpdate time.Time `json:"lastUpdate"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
} }
// Alert represents an active alert (simplified for State) // 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. // HostCephCluster represents Ceph cluster status collected directly by the host agent.
// This is separate from CephCluster which comes from the Proxmox API. // This is separate from CephCluster which comes from the Proxmox API.
type HostCephCluster struct { type HostCephCluster struct {
FSID string `json:"fsid"` FSID string `json:"fsid"`
Health HostCephHealth `json:"health"` Health HostCephHealth `json:"health"`
MonMap HostCephMonitorMap `json:"monMap,omitempty"` MonMap HostCephMonitorMap `json:"monMap,omitempty"`
MgrMap HostCephManagerMap `json:"mgrMap,omitempty"` MgrMap HostCephManagerMap `json:"mgrMap,omitempty"`
OSDMap HostCephOSDMap `json:"osdMap"` OSDMap HostCephOSDMap `json:"osdMap"`
PGMap HostCephPGMap `json:"pgMap"` PGMap HostCephPGMap `json:"pgMap"`
Pools []HostCephPool `json:"pools,omitempty"` Pools []HostCephPool `json:"pools,omitempty"`
Services []HostCephService `json:"services,omitempty"` Services []HostCephService `json:"services,omitempty"`
CollectedAt time.Time `json:"collectedAt"` CollectedAt time.Time `json:"collectedAt"`
} }
// HostCephHealth represents Ceph cluster health status. // HostCephHealth represents Ceph cluster health status.
type HostCephHealth struct { type HostCephHealth struct {
Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR
Checks map[string]HostCephCheck `json:"checks,omitempty"` Checks map[string]HostCephCheck `json:"checks,omitempty"`
Summary []HostCephHealthSummary `json:"summary,omitempty"` Summary []HostCephHealthSummary `json:"summary,omitempty"`
} }
// HostCephCheck represents a health check detail. // HostCephCheck represents a health check detail.
@ -464,6 +466,101 @@ type DockerContainerMount struct {
Driver string `json:"driver,omitempty"` 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. // DockerService summarises a Docker Swarm service.
type DockerService struct { type DockerService struct {
ID string `json:"id"` ID string `json:"id"`
@ -1577,6 +1674,185 @@ func (s *State) GetRemovedDockerHosts() []RemovedDockerHost {
return entries 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. // UpsertHost inserts or updates a generic host in state.
func (s *State) UpsertHost(host Host) { func (s *State) UpsertHost(host Host) {
s.mu.Lock() s.mu.Lock()
@ -1654,7 +1930,6 @@ func (s *State) UpsertCephCluster(cluster CephCluster) {
s.LastUpdate = time.Now() s.LastUpdate = time.Now()
} }
// SetHostStatus updates the status of a host if present. // SetHostStatus updates the status of a host if present.
func (s *State) SetHostStatus(hostID, status string) bool { func (s *State) SetHostStatus(hostID, status string) bool {
s.mu.Lock() s.mu.Lock()

View file

@ -68,16 +68,16 @@ type VMFrontend struct {
// ContainerFrontend represents a Container with frontend-friendly field names // ContainerFrontend represents a Container with frontend-friendly field names
type ContainerFrontend struct { type ContainerFrontend struct {
ID string `json:"id"` ID string `json:"id"`
VMID int `json:"vmid"` VMID int `json:"vmid"`
Name string `json:"name"` Name string `json:"name"`
Node string `json:"node"` Node string `json:"node"`
Instance string `json:"instance"` Instance string `json:"instance"`
Status string `json:"status"` Status string `json:"status"`
Type string `json:"type"` Type string `json:"type"`
// OCI container support (Proxmox VE 9.1+) // OCI container support (Proxmox VE 9.1+)
IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container 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") OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest")
CPU float64 `json:"cpu"` CPU float64 `json:"cpu"`
CPUs int `json:"cpus"` CPUs int `json:"cpus"`
Memory *Memory `json:"memory,omitempty"` // Full memory object Memory *Memory `json:"memory,omitempty"` // Full memory object
@ -146,6 +146,98 @@ type RemovedDockerHostFrontend struct {
RemovedAt int64 `json:"removedAt"` 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 // DockerContainerFrontend represents a Docker container for the frontend
type DockerContainerFrontend struct { type DockerContainerFrontend struct {
ID string `json:"id"` ID string `json:"id"`
@ -426,29 +518,31 @@ type ReplicationJobFrontend struct {
// StateFrontend represents the state with frontend-friendly field names // StateFrontend represents the state with frontend-friendly field names
type StateFrontend struct { type StateFrontend struct {
Nodes []NodeFrontend `json:"nodes"` Nodes []NodeFrontend `json:"nodes"`
VMs []VMFrontend `json:"vms"` VMs []VMFrontend `json:"vms"`
Containers []ContainerFrontend `json:"containers"` Containers []ContainerFrontend `json:"containers"`
DockerHosts []DockerHostFrontend `json:"dockerHosts"` DockerHosts []DockerHostFrontend `json:"dockerHosts"`
RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"` RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"`
Hosts []HostFrontend `json:"hosts"` KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters,omitempty"`
Storage []StorageFrontend `json:"storage"` RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters,omitempty"`
CephClusters []CephClusterFrontend `json:"cephClusters"` Hosts []HostFrontend `json:"hosts"`
PhysicalDisks []PhysicalDisk `json:"physicalDisks"` Storage []StorageFrontend `json:"storage"`
PBS []PBSInstance `json:"pbs"` // Keep as is CephClusters []CephClusterFrontend `json:"cephClusters"`
PMG []PMGInstance `json:"pmg"` PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
PBSBackups []PBSBackup `json:"pbsBackups"` PBS []PBSInstance `json:"pbs"` // Keep as is
PMGBackups []PMGBackup `json:"pmgBackups"` PMG []PMGInstance `json:"pmg"`
Backups Backups `json:"backups"` PBSBackups []PBSBackup `json:"pbsBackups"`
ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"` PMGBackups []PMGBackup `json:"pmgBackups"`
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts Backups Backups `json:"backups"`
Metrics map[string]any `json:"metrics"` // Empty object for now ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"`
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
Performance map[string]any `json:"performance"` // Empty object for now Metrics map[string]any `json:"metrics"` // Empty object for now
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is PVEBackups PVEBackups `json:"pveBackups"` // Keep as is
Stats map[string]any `json:"stats"` // Empty object for now Performance map[string]any `json:"performance"` // Empty object for now
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting 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 // Unified resources - the new way to access all monitored entities
Resources []ResourceFrontend `json:"resources,omitempty"` Resources []ResourceFrontend `json:"resources,omitempty"`
} }
@ -481,9 +575,9 @@ type ResourceFrontend struct {
Uptime *int64 `json:"uptime,omitempty"` Uptime *int64 `json:"uptime,omitempty"`
// Metadata // Metadata
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty"`
LastSeen int64 `json:"lastSeen"` // Unix milliseconds LastSeen int64 `json:"lastSeen"` // Unix milliseconds
Alerts []ResourceAlertFrontend `json:"alerts,omitempty"` Alerts []ResourceAlertFrontend `json:"alerts,omitempty"`
// Identity for deduplication // Identity for deduplication

View file

@ -4,30 +4,32 @@ import "time"
// StateSnapshot represents a snapshot of the state without mutex // StateSnapshot represents a snapshot of the state without mutex
type StateSnapshot struct { type StateSnapshot struct {
Nodes []Node `json:"nodes"` Nodes []Node `json:"nodes"`
VMs []VM `json:"vms"` VMs []VM `json:"vms"`
Containers []Container `json:"containers"` Containers []Container `json:"containers"`
DockerHosts []DockerHost `json:"dockerHosts"` DockerHosts []DockerHost `json:"dockerHosts"`
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
Hosts []Host `json:"hosts"` KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"`
Storage []Storage `json:"storage"` RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"`
CephClusters []CephCluster `json:"cephClusters"` Hosts []Host `json:"hosts"`
PhysicalDisks []PhysicalDisk `json:"physicalDisks"` Storage []Storage `json:"storage"`
PBSInstances []PBSInstance `json:"pbs"` CephClusters []CephCluster `json:"cephClusters"`
PMGInstances []PMGInstance `json:"pmg"` PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
PBSBackups []PBSBackup `json:"pbsBackups"` PBSInstances []PBSInstance `json:"pbs"`
PMGBackups []PMGBackup `json:"pmgBackups"` PMGInstances []PMGInstance `json:"pmg"`
Backups Backups `json:"backups"` PBSBackups []PBSBackup `json:"pbsBackups"`
ReplicationJobs []ReplicationJob `json:"replicationJobs"` PMGBackups []PMGBackup `json:"pmgBackups"`
Metrics []Metric `json:"metrics"` Backups Backups `json:"backups"`
PVEBackups PVEBackups `json:"pveBackups"` ReplicationJobs []ReplicationJob `json:"replicationJobs"`
Performance Performance `json:"performance"` Metrics []Metric `json:"metrics"`
ConnectionHealth map[string]bool `json:"connectionHealth"` PVEBackups PVEBackups `json:"pveBackups"`
Stats Stats `json:"stats"` Performance Performance `json:"performance"`
ActiveAlerts []Alert `json:"activeAlerts"` ConnectionHealth map[string]bool `json:"connectionHealth"`
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` Stats Stats `json:"stats"`
LastUpdate time.Time `json:"lastUpdate"` ActiveAlerts []Alert `json:"activeAlerts"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
LastUpdate time.Time `json:"lastUpdate"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
} }
// GetSnapshot returns a snapshot of the current state without mutex // GetSnapshot returns a snapshot of the current state without mutex
@ -45,19 +47,21 @@ func (s *State) GetSnapshot() StateSnapshot {
// Create a snapshot without mutex // Create a snapshot without mutex
snapshot := StateSnapshot{ snapshot := StateSnapshot{
Nodes: append([]Node{}, s.Nodes...), Nodes: append([]Node{}, s.Nodes...),
VMs: append([]VM{}, s.VMs...), VMs: append([]VM{}, s.VMs...),
Containers: append([]Container{}, s.Containers...), Containers: append([]Container{}, s.Containers...),
DockerHosts: append([]DockerHost{}, s.DockerHosts...), DockerHosts: append([]DockerHost{}, s.DockerHosts...),
RemovedDockerHosts: append([]RemovedDockerHost{}, s.RemovedDockerHosts...), RemovedDockerHosts: append([]RemovedDockerHost{}, s.RemovedDockerHosts...),
Hosts: append([]Host{}, s.Hosts...), KubernetesClusters: append([]KubernetesCluster{}, s.KubernetesClusters...),
Storage: append([]Storage{}, s.Storage...), RemovedKubernetesClusters: append([]RemovedKubernetesCluster{}, s.RemovedKubernetesClusters...),
CephClusters: append([]CephCluster{}, s.CephClusters...), Hosts: append([]Host{}, s.Hosts...),
PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...), Storage: append([]Storage{}, s.Storage...),
PBSInstances: append([]PBSInstance{}, s.PBSInstances...), CephClusters: append([]CephCluster{}, s.CephClusters...),
PMGInstances: append([]PMGInstance{}, s.PMGInstances...), PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...),
PBSBackups: pbsBackups, PBSInstances: append([]PBSInstance{}, s.PBSInstances...),
PMGBackups: pmgBackups, PMGInstances: append([]PMGInstance{}, s.PMGInstances...),
PBSBackups: pbsBackups,
PMGBackups: pmgBackups,
Backups: Backups{ Backups: Backups{
PVE: pveBackups, PVE: pveBackups,
PBS: pbsBackups, PBS: pbsBackups,
@ -113,6 +117,16 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
removedDockerHosts[i] = entry.ToFrontend() 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)) hosts := make([]HostFrontend, len(s.Hosts))
for i, host := range s.Hosts { for i, host := range s.Hosts {
hosts[i] = host.ToFrontend() hosts[i] = host.ToFrontend()
@ -141,6 +155,8 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
Containers: containers, Containers: containers,
DockerHosts: dockerHosts, DockerHosts: dockerHosts,
RemovedDockerHosts: removedDockerHosts, RemovedDockerHosts: removedDockerHosts,
KubernetesClusters: kubernetesClusters,
RemovedKubernetesClusters: removedKubernetesClusters,
Hosts: hosts, Hosts: hosts,
Storage: storage, Storage: storage,
CephClusters: cephClusters, 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 startTime time.Time
rateTracker *RateTracker rateTracker *RateTracker
metricsHistory *MetricsHistory metricsHistory *MetricsHistory
metricsStore *metrics.Store // Persistent SQLite metrics storage metricsStore *metrics.Store // Persistent SQLite metrics storage
alertManager *alerts.Manager alertManager *alerts.Manager
notificationMgr *notifications.NotificationManager notificationMgr *notifications.NotificationManager
configPersist *config.ConfigPersistence configPersist *config.ConfigPersistence
@ -626,6 +626,8 @@ type Monitor struct {
nodeRRDMemCache map[string]rrdMemCacheEntry nodeRRDMemCache map[string]rrdMemCacheEntry
removedDockerHosts map[string]time.Time // Track deliberately removed Docker hosts (ID -> removal time) 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 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 hostTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness
dockerCommands map[string]*dockerHostCommand dockerCommands map[string]*dockerHostCommand
dockerCommandIndex map[string]string dockerCommandIndex map[string]string
@ -651,7 +653,7 @@ type Monitor struct {
instanceInfoCache map[string]*instanceInfo instanceInfoCache map[string]*instanceInfo
pollStatusMap map[string]*pollStatus pollStatusMap map[string]*pollStatus
dlqInsightMap map[string]*dlqInsight 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 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 ( const (
dockerConnectionPrefix = "docker-" dockerConnectionPrefix = "docker-"
hostConnectionPrefix = "host-" kubernetesConnectionPrefix = "kubernetes-"
dockerOfflineGraceMultiplier = 4 hostConnectionPrefix = "host-"
dockerMinimumHealthWindow = 30 * time.Second dockerOfflineGraceMultiplier = 4
dockerMaximumHealthWindow = 10 * time.Minute dockerMinimumHealthWindow = 30 * time.Second
hostOfflineGraceMultiplier = 6 dockerMaximumHealthWindow = 10 * time.Minute
hostMinimumHealthWindow = 60 * time.Second kubernetesOfflineGraceMultiplier = 4
hostMaximumHealthWindow = 10 * time.Minute kubernetesMinimumHealthWindow = 30 * time.Second
nodeOfflineGracePeriod = 60 * time.Second // Grace period before marking Proxmox nodes offline kubernetesMaximumHealthWindow = 10 * time.Minute
nodeRRDCacheTTL = 30 * time.Second hostOfflineGraceMultiplier = 6
nodeRRDRequestTimeout = 2 * time.Second 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 { type taskOutcome struct {
@ -2875,6 +2881,8 @@ func New(cfg *config.Config) (*Monitor, error) {
nodeRRDMemCache: make(map[string]rrdMemCacheEntry), nodeRRDMemCache: make(map[string]rrdMemCacheEntry),
removedDockerHosts: make(map[string]time.Time), removedDockerHosts: make(map[string]time.Time),
dockerTokenBindings: make(map[string]string), dockerTokenBindings: make(map[string]string),
removedKubernetesClusters: make(map[string]time.Time),
kubernetesTokenBindings: make(map[string]string),
hostTokenBindings: make(map[string]string), hostTokenBindings: make(map[string]string),
dockerCommands: make(map[string]*dockerHostCommand), dockerCommands: make(map[string]*dockerHostCommand),
dockerCommandIndex: make(map[string]string), dockerCommandIndex: make(map[string]string),
@ -3518,8 +3526,10 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
case <-pollTicker.C: case <-pollTicker.C:
now := time.Now() now := time.Now()
m.evaluateDockerAgents(now) m.evaluateDockerAgents(now)
m.evaluateKubernetesAgents(now)
m.evaluateHostAgents(now) m.evaluateHostAgents(now)
m.cleanupRemovedDockerHosts(now) m.cleanupRemovedDockerHosts(now)
m.cleanupRemovedKubernetesClusters(now)
m.cleanupGuestMetadataCache(now) m.cleanupGuestMetadataCache(now)
m.cleanupDiagnosticSnapshots(now) m.cleanupDiagnosticSnapshots(now)
m.cleanupRRDCache(now) m.cleanupRRDCache(now)

View file

@ -169,13 +169,13 @@ func FromVM(vm models.VM) Resource {
CPU: &MetricValue{ CPU: &MetricValue{
Current: vm.CPU * 100, // VM CPU is 0-1, convert to percentage Current: vm.CPU * 100, // VM CPU is 0-1, convert to percentage
}, },
Memory: memory, Memory: memory,
Disk: disk, Disk: disk,
Network: network, Network: network,
Uptime: &vm.Uptime, Uptime: &vm.Uptime,
Tags: vm.Tags, Tags: vm.Tags,
LastSeen: vm.LastSeen, LastSeen: vm.LastSeen,
PlatformData: platformDataJSON, PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion, SchemaVersion: CurrentSchemaVersion,
} }
} }
@ -255,13 +255,13 @@ func FromContainer(ct models.Container) Resource {
CPU: &MetricValue{ CPU: &MetricValue{
Current: ct.CPU * 100, // Container CPU is 0-1, convert to percentage Current: ct.CPU * 100, // Container CPU is 0-1, convert to percentage
}, },
Memory: memory, Memory: memory,
Disk: disk, Disk: disk,
Network: network, Network: network,
Uptime: &ct.Uptime, Uptime: &ct.Uptime,
Tags: ct.Tags, Tags: ct.Tags,
LastSeen: ct.LastSeen, LastSeen: ct.LastSeen,
PlatformData: platformDataJSON, PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion, SchemaVersion: CurrentSchemaVersion,
} }
} }
@ -347,14 +347,14 @@ func FromHost(h models.Host) Resource {
diskIO := make([]DiskIOStats, len(h.DiskIO)) diskIO := make([]DiskIOStats, len(h.DiskIO))
for i, d := range h.DiskIO { for i, d := range h.DiskIO {
diskIO[i] = DiskIOStats{ diskIO[i] = DiskIOStats{
Device: d.Device, Device: d.Device,
ReadBytes: d.ReadBytes, ReadBytes: d.ReadBytes,
WriteBytes: d.WriteBytes, WriteBytes: d.WriteBytes,
ReadOps: d.ReadOps, ReadOps: d.ReadOps,
WriteOps: d.WriteOps, WriteOps: d.WriteOps,
ReadTimeMs: d.ReadTime, ReadTimeMs: d.ReadTime,
WriteTimeMs: d.WriteTime, WriteTimeMs: d.WriteTime,
IOTimeMs: d.IOTime, IOTimeMs: d.IOTime,
} }
} }
@ -678,11 +678,217 @@ func FromDockerContainer(dc models.DockerContainer, hostID, hostName string) Res
CPU: &MetricValue{ CPU: &MetricValue{
Current: dc.CPUPercent, Current: dc.CPUPercent,
}, },
Memory: memory, Memory: memory,
Uptime: &dc.UptimeSeconds, Uptime: &dc.UptimeSeconds,
Labels: dc.Labels, Labels: dc.Labels,
LastSeen: time.Now(), // Containers don't have their own LastSeen LastSeen: time.Now(), // Containers don't have their own LastSeen
PlatformData: platformDataJSON, 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, 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 { func mapPBSStatus(status, connectionHealth string) ResourceStatus {
if connectionHealth != "healthy" { if connectionHealth != "healthy" {
return StatusDegraded return StatusDegraded

View file

@ -56,10 +56,10 @@ type VMPlatformData struct {
// Stored in Resource.PlatformData when Type is ResourceTypeContainer or ResourceTypeOCIContainer. // Stored in Resource.PlatformData when Type is ResourceTypeContainer or ResourceTypeOCIContainer.
type ContainerPlatformData struct { type ContainerPlatformData struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
Node string `json:"node"` // Proxmox node hosting this container Node string `json:"node"` // Proxmox node hosting this container
Instance string `json:"instance"` // Proxmox instance URL Instance string `json:"instance"` // Proxmox instance URL
Type string `json:"type,omitempty"` // lxc or oci Type string `json:"type,omitempty"` // lxc or oci
CPUs int `json:"cpus"` // Number of vCPUs CPUs int `json:"cpus"` // Number of vCPUs
Template bool `json:"template"` Template bool `json:"template"`
Lock string `json:"lock,omitempty"` Lock string `json:"lock,omitempty"`
OSName string `json:"osName,omitempty"` OSName string `json:"osName,omitempty"`
@ -81,20 +81,20 @@ type ContainerPlatformData struct {
// HostPlatformData contains host-agent specific fields. // HostPlatformData contains host-agent specific fields.
// Stored in Resource.PlatformData when Type is ResourceTypeHost. // Stored in Resource.PlatformData when Type is ResourceTypeHost.
type HostPlatformData struct { type HostPlatformData struct {
Platform string `json:"platform,omitempty"` // linux, windows, darwin Platform string `json:"platform,omitempty"` // linux, windows, darwin
OSName string `json:"osName,omitempty"` // e.g., "Ubuntu 22.04" OSName string `json:"osName,omitempty"` // e.g., "Ubuntu 22.04"
OSVersion string `json:"osVersion,omitempty"` // OS version string OSVersion string `json:"osVersion,omitempty"` // OS version string
KernelVersion string `json:"kernelVersion,omitempty"` // Kernel version KernelVersion string `json:"kernelVersion,omitempty"` // Kernel version
Architecture string `json:"architecture,omitempty"` // amd64, arm64, etc. Architecture string `json:"architecture,omitempty"` // amd64, arm64, etc.
CPUCount int `json:"cpuCount,omitempty"` // Number of CPUs CPUCount int `json:"cpuCount,omitempty"` // Number of CPUs
LoadAverage []float64 `json:"loadAverage,omitempty"` // 1, 5, 15 minute loads LoadAverage []float64 `json:"loadAverage,omitempty"` // 1, 5, 15 minute loads
AgentVersion string `json:"agentVersion,omitempty"` // Pulse agent version AgentVersion string `json:"agentVersion,omitempty"` // Pulse agent version
IsLegacy bool `json:"isLegacy,omitempty"` // Legacy agent indicator IsLegacy bool `json:"isLegacy,omitempty"` // Legacy agent indicator
Sensors HostSensorSummary `json:"sensors,omitempty"` // Temperature/fan sensors Sensors HostSensorSummary `json:"sensors,omitempty"` // Temperature/fan sensors
RAID []HostRAIDArray `json:"raid,omitempty"` // RAID arrays RAID []HostRAIDArray `json:"raid,omitempty"` // RAID arrays
DiskIO []DiskIOStats `json:"diskIO,omitempty"` // Per-disk I/O stats DiskIO []DiskIOStats `json:"diskIO,omitempty"` // Per-disk I/O stats
Disks []DiskInfo `json:"disks,omitempty"` // Disk usage info Disks []DiskInfo `json:"disks,omitempty"` // Disk usage info
Interfaces []NetworkInterface `json:"interfaces,omitempty"` // Network interfaces Interfaces []NetworkInterface `json:"interfaces,omitempty"` // Network interfaces
// Token information // Token information
TokenID string `json:"tokenId,omitempty"` TokenID string `json:"tokenId,omitempty"`
@ -136,14 +136,14 @@ type HostRAIDDevice struct {
// DiskIOStats captures I/O statistics for a disk device. // DiskIOStats captures I/O statistics for a disk device.
type DiskIOStats struct { type DiskIOStats struct {
Device string `json:"device"` Device string `json:"device"`
ReadBytes uint64 `json:"readBytes,omitempty"` ReadBytes uint64 `json:"readBytes,omitempty"`
WriteBytes uint64 `json:"writeBytes,omitempty"` WriteBytes uint64 `json:"writeBytes,omitempty"`
ReadOps uint64 `json:"readOps,omitempty"` ReadOps uint64 `json:"readOps,omitempty"`
WriteOps uint64 `json:"writeOps,omitempty"` WriteOps uint64 `json:"writeOps,omitempty"`
ReadTimeMs uint64 `json:"readTimeMs,omitempty"` ReadTimeMs uint64 `json:"readTimeMs,omitempty"`
WriteTimeMs uint64 `json:"writeTimeMs,omitempty"` WriteTimeMs uint64 `json:"writeTimeMs,omitempty"`
IOTimeMs uint64 `json:"ioTimeMs,omitempty"` IOTimeMs uint64 `json:"ioTimeMs,omitempty"`
} }
// DiskInfo represents disk/partition usage. // DiskInfo represents disk/partition usage.
@ -170,23 +170,23 @@ type NetworkInterface struct {
// DockerHostPlatformData contains Docker host-specific fields. // DockerHostPlatformData contains Docker host-specific fields.
// Stored in Resource.PlatformData when Type is ResourceTypeDockerHost. // Stored in Resource.PlatformData when Type is ResourceTypeDockerHost.
type DockerHostPlatformData struct { type DockerHostPlatformData struct {
AgentID string `json:"agentId"` AgentID string `json:"agentId"`
MachineID string `json:"machineId,omitempty"` MachineID string `json:"machineId,omitempty"`
OS string `json:"os,omitempty"` OS string `json:"os,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"` KernelVersion string `json:"kernelVersion,omitempty"`
Architecture string `json:"architecture,omitempty"` Architecture string `json:"architecture,omitempty"`
Runtime string `json:"runtime,omitempty"` // docker, podman Runtime string `json:"runtime,omitempty"` // docker, podman
RuntimeVersion string `json:"runtimeVersion,omitempty"` RuntimeVersion string `json:"runtimeVersion,omitempty"`
DockerVersion string `json:"dockerVersion,omitempty"` DockerVersion string `json:"dockerVersion,omitempty"`
LoadAverage []float64 `json:"loadAverage,omitempty"` LoadAverage []float64 `json:"loadAverage,omitempty"`
AgentVersion string `json:"agentVersion,omitempty"` AgentVersion string `json:"agentVersion,omitempty"`
CPUs int `json:"cpus"` CPUs int `json:"cpus"`
IsLegacy bool `json:"isLegacy,omitempty"` IsLegacy bool `json:"isLegacy,omitempty"`
Disks []DiskInfo `json:"disks,omitempty"` Disks []DiskInfo `json:"disks,omitempty"`
Interfaces []NetworkInterface `json:"interfaces,omitempty"` Interfaces []NetworkInterface `json:"interfaces,omitempty"`
CustomDisplayName string `json:"customDisplayName,omitempty"` CustomDisplayName string `json:"customDisplayName,omitempty"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
PendingUninstall bool `json:"pendingUninstall"` PendingUninstall bool `json:"pendingUninstall"`
// Swarm information // Swarm information
Swarm *DockerSwarmInfo `json:"swarm,omitempty"` Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
@ -213,25 +213,107 @@ type DockerSwarmInfo struct {
// DockerContainerPlatformData contains Docker container-specific fields. // DockerContainerPlatformData contains Docker container-specific fields.
// Stored in Resource.PlatformData when Type is ResourceTypeDockerContainer. // Stored in Resource.PlatformData when Type is ResourceTypeDockerContainer.
type DockerContainerPlatformData struct { type DockerContainerPlatformData struct {
HostID string `json:"hostId"` // Parent Docker host ID HostID string `json:"hostId"` // Parent Docker host ID
HostName string `json:"hostName"` // Parent Docker host name HostName string `json:"hostName"` // Parent Docker host name
Image string `json:"image"` // Container image Image string `json:"image"` // Container image
State string `json:"state"` // created, running, paused, restarting, exited, dead State string `json:"state"` // created, running, paused, restarting, exited, dead
Status string `json:"status"` // Human-readable status Status string `json:"status"` // Human-readable status
Health string `json:"health"` // healthy, unhealthy, starting, none Health string `json:"health"` // healthy, unhealthy, starting, none
RestartCount int `json:"restartCount"` RestartCount int `json:"restartCount"`
ExitCode int `json:"exitCode"` ExitCode int `json:"exitCode"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty"`
Ports []ContainerPort `json:"ports,omitempty"` Ports []ContainerPort `json:"ports,omitempty"`
Networks []ContainerNetwork `json:"networks,omitempty"` Networks []ContainerNetwork `json:"networks,omitempty"`
// Podman-specific // Podman-specific
Podman *PodmanContainerInfo `json:"podman,omitempty"` 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. // ContainerPort describes a port mapping.
type ContainerPort struct { type ContainerPort struct {
PrivatePort int `json:"privatePort"` PrivatePort int `json:"privatePort"`
@ -280,12 +362,12 @@ type DatastorePlatformData struct {
// StoragePlatformData contains Proxmox storage-specific fields. // StoragePlatformData contains Proxmox storage-specific fields.
// Stored in Resource.PlatformData when Type is ResourceTypeStorage. // Stored in Resource.PlatformData when Type is ResourceTypeStorage.
type StoragePlatformData struct { type StoragePlatformData struct {
Instance string `json:"instance"` Instance string `json:"instance"`
Node string `json:"node"` // Primary node Node string `json:"node"` // Primary node
Nodes []string `json:"nodes,omitempty"` // All nodes (for shared storage) Nodes []string `json:"nodes,omitempty"` // All nodes (for shared storage)
Type string `json:"type"` // zfspool, lvmthin, cephfs, etc. Type string `json:"type"` // zfspool, lvmthin, cephfs, etc.
Content string `json:"content"` Content string `json:"content"`
Shared bool `json:"shared"` Shared bool `json:"shared"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Active bool `json:"active"` Active bool `json:"active"`
} }

View file

@ -35,13 +35,13 @@ type Resource struct {
ClusterID string `json:"clusterId,omitempty"` // Cluster membership ClusterID string `json:"clusterId,omitempty"` // Cluster membership
// Universal Metrics (nullable - not all resources have all metrics) // Universal Metrics (nullable - not all resources have all metrics)
Status ResourceStatus `json:"status"` // online, offline, running, stopped, degraded Status ResourceStatus `json:"status"` // online, offline, running, stopped, degraded
CPU *MetricValue `json:"cpu,omitempty"` // CPU usage percentage CPU *MetricValue `json:"cpu,omitempty"` // CPU usage percentage
Memory *MetricValue `json:"memory,omitempty"` // Memory usage Memory *MetricValue `json:"memory,omitempty"` // Memory usage
Disk *MetricValue `json:"disk,omitempty"` // Primary disk usage Disk *MetricValue `json:"disk,omitempty"` // Primary disk usage
Network *NetworkMetric `json:"network,omitempty"` // Network I/O Network *NetworkMetric `json:"network,omitempty"` // Network I/O
Temperature *float64 `json:"temperature,omitempty"` // Temperature in Celsius 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 // Universal Metadata
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
@ -69,6 +69,7 @@ const (
ResourceTypeNode ResourceType = "node" // Proxmox VE node ResourceTypeNode ResourceType = "node" // Proxmox VE node
ResourceTypeHost ResourceType = "host" // Standalone host (via host-agent) ResourceTypeHost ResourceType = "host" // Standalone host (via host-agent)
ResourceTypeDockerHost ResourceType = "docker-host" // Docker/Podman host ResourceTypeDockerHost ResourceType = "docker-host" // Docker/Podman host
ResourceTypeK8sCluster ResourceType = "k8s-cluster" // Kubernetes cluster
ResourceTypeK8sNode ResourceType = "k8s-node" // Kubernetes node ResourceTypeK8sNode ResourceType = "k8s-node" // Kubernetes node
ResourceTypeTrueNAS ResourceType = "truenas" // TrueNAS system ResourceTypeTrueNAS ResourceType = "truenas" // TrueNAS system
@ -148,8 +149,8 @@ type NetworkMetric struct {
// ResourceAlert represents an alert associated with a resource. // ResourceAlert represents an alert associated with a resource.
type ResourceAlert struct { type ResourceAlert struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // cpu, memory, disk, temperature, etc. Type string `json:"type"` // cpu, memory, disk, temperature, etc.
Level string `json:"level"` // warning, critical Level string `json:"level"` // warning, critical
Message string `json:"message"` Message string `json:"message"`
Value float64 `json:"value"` Value float64 `json:"value"`
Threshold float64 `json:"threshold"` 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). // (node, host, docker-host) rather than a workload (vm, container).
func (r *Resource) IsInfrastructure() bool { func (r *Resource) IsInfrastructure() bool {
switch r.Type { switch r.Type {
case ResourceTypeNode, ResourceTypeHost, ResourceTypeDockerHost, ResourceTypeK8sNode, ResourceTypeTrueNAS: case ResourceTypeNode, ResourceTypeHost, ResourceTypeDockerHost, ResourceTypeK8sCluster, ResourceTypeK8sNode, ResourceTypeTrueNAS:
return true return true
default: default:
return false return false

View file

@ -185,9 +185,9 @@ func (s *Store) FindContainerHost(containerNameOrID string) string {
} }
// Match by name or ID (case-insensitive) // Match by name or ID (case-insensitive)
if strings.EqualFold(r.Name, containerNameOrID) || if strings.EqualFold(r.Name, containerNameOrID) ||
strings.EqualFold(r.ID, containerNameOrID) || strings.EqualFold(r.ID, containerNameOrID) ||
strings.Contains(strings.ToLower(r.Name), containerNameLower) || strings.Contains(strings.ToLower(r.Name), containerNameLower) ||
strings.Contains(strings.ToLower(r.ID), containerNameLower) { strings.Contains(strings.ToLower(r.ID), containerNameLower) {
container = r container = r
break break
} }
@ -210,7 +210,6 @@ func (s *Store) FindContainerHost(containerNameOrID string) string {
return parent.Name return parent.Name
} }
// Remove removes a resource from the store. // Remove removes a resource from the store.
func (s *Store) Remove(id string) { func (s *Store) Remove(id string) {
s.mu.Lock() s.mu.Lock()
@ -279,13 +278,13 @@ func (s *Store) GetStats() StoreStats {
// StoreStats contains statistics about the resource store. // StoreStats contains statistics about the resource store.
type StoreStats struct { type StoreStats struct {
TotalResources int `json:"totalResources"` TotalResources int `json:"totalResources"`
SuppressedResources int `json:"suppressedResources"` SuppressedResources int `json:"suppressedResources"`
ByType map[ResourceType]int `json:"byType"` ByType map[ResourceType]int `json:"byType"`
ByPlatform map[PlatformType]int `json:"byPlatform"` ByPlatform map[PlatformType]int `json:"byPlatform"`
ByStatus map[ResourceStatus]int `json:"byStatus"` ByStatus map[ResourceStatus]int `json:"byStatus"`
WithAlerts int `json:"withAlerts"` WithAlerts int `json:"withAlerts"`
LastUpdated string `json:"lastUpdated"` LastUpdated string `json:"lastUpdated"`
} }
// GetPreferredResourceFor returns the preferred resource for a given ID. // GetPreferredResourceFor returns the preferred resource for a given ID.
@ -598,8 +597,8 @@ func isNonUniqueIP(ip string) bool {
// Docker bridge network - 172.17.0.1/16 exists on every Docker host // 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) // Also filter other Docker-assigned bridge networks (172.17-31.x.x)
if strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") || 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.19.") || strings.HasPrefix(ip, "172.20.") ||
strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") { strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") {
return true return true
} }
@ -658,17 +657,17 @@ func (s *Store) Query() *ResourceQuery {
// ResourceQuery provides a fluent query interface. // ResourceQuery provides a fluent query interface.
type ResourceQuery struct { type ResourceQuery struct {
store *Store store *Store
types []ResourceType types []ResourceType
platforms []PlatformType platforms []PlatformType
statuses []ResourceStatus statuses []ResourceStatus
parentID *string parentID *string
clusterID *string clusterID *string
hasAlerts *bool hasAlerts *bool
sortBy string sortBy string
sortDesc bool sortDesc bool
limit int limit int
offset int offset int
} }
// OfType filters by resource types. // OfType filters by resource types.
@ -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 // Convert PBS instances
for _, pbs := range snapshot.PBSInstances { for _, pbs := range snapshot.PBSInstances {
r := FromPBSInstance(pbs) r := FromPBSInstance(pbs)
@ -1167,14 +1191,14 @@ func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) {
seenIDs[id] = true seenIDs[id] = true
} }
// Remove resources that were NOT in this snapshot BUT were sourced from API // Remove resources that were NOT in this snapshot.
// (Agent-sourced resources like hosts from host-agent should persist independently) // The snapshot is the authoritative source for the in-memory store (including agent-sourced data).
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
var toRemove []string var toRemove []string
for id, r := range s.resources { for id := range s.resources {
if !seenIDs[id] && r.SourceType == SourceAPI { if !seenIDs[id] {
toRemove = append(toRemove, 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", [string]$Interval = "30s",
[bool]$EnableHost = $true, [bool]$EnableHost = $true,
[bool]$EnableDocker = $false, [bool]$EnableDocker = $false,
[bool]$EnableKubernetes = $false,
[bool]$Insecure = $false, [bool]$Insecure = $false,
[bool]$Uninstall = $false, [bool]$Uninstall = $false,
[string]$AgentId = "" [string]$AgentId = ""
@ -21,6 +22,43 @@ $InstallDir = "C:\Program Files\Pulse"
$LogFile = "$env:ProgramData\Pulse\pulse-agent.log" $LogFile = "$env:ProgramData\Pulse\pulse-agent.log"
$DownloadTimeoutSec = 300 $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 --- # --- Administrator Check ---
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) { if (-not $isAdmin) {
@ -309,6 +347,7 @@ $ServiceArgs = @(
) )
if ($EnableHost) { $ServiceArgs += "--enable-host" } if ($EnableHost) { $ServiceArgs += "--enable-host" }
if ($EnableDocker) { $ServiceArgs += "--enable-docker" } if ($EnableDocker) { $ServiceArgs += "--enable-docker" }
if ($EnableKubernetes) { $ServiceArgs += "--enable-kubernetes" }
if ($Insecure) { $ServiceArgs += "--insecure" } if ($Insecure) { $ServiceArgs += "--insecure" }
if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") } if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") }
@ -319,7 +358,7 @@ try {
New-Service -Name $AgentName ` New-Service -Name $AgentName `
-BinaryPathName $BinPath ` -BinaryPathName $BinPath `
-DisplayName "Pulse Unified Agent" ` -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 -StartupType Automatic | Out-Null
Write-Host "Service created successfully" -ForegroundColor Green Write-Host "Service created successfully" -ForegroundColor Green
} catch { } catch {

View file

@ -9,6 +9,7 @@
# Options: # Options:
# --enable-host Enable host metrics (default: true) # --enable-host Enable host metrics (default: true)
# --enable-docker Enable docker metrics (default: false) # --enable-docker Enable docker metrics (default: false)
# --enable-kubernetes Enable Kubernetes metrics (default: false)
# --interval <dur> Reporting interval (default: 30s) # --interval <dur> Reporting interval (default: 30s)
# --agent-id <id> Custom agent identifier (default: auto-generated) # --agent-id <id> Custom agent identifier (default: auto-generated)
# --uninstall Remove the agent # --uninstall Remove the agent
@ -54,6 +55,7 @@ PULSE_TOKEN=""
INTERVAL="30s" INTERVAL="30s"
ENABLE_HOST="true" ENABLE_HOST="true"
ENABLE_DOCKER="false" ENABLE_DOCKER="false"
ENABLE_KUBERNETES="false"
ENABLE_PROXMOX="false" ENABLE_PROXMOX="false"
PROXMOX_TYPE="" PROXMOX_TYPE=""
UNINSTALL="false" UNINSTALL="false"
@ -85,6 +87,7 @@ build_exec_args() {
EXEC_ARGS="$EXEC_ARGS --enable-host=false" EXEC_ARGS="$EXEC_ARGS --enable-host=false"
fi fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; 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 [[ "$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 [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS="$EXEC_ARGS --proxmox-type ${PROXMOX_TYPE}"; fi
if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; 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) EXEC_ARGS_ARRAY+=(--enable-host=false)
fi fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); 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 [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-proxmox); fi
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS_ARRAY+=(--proxmox-type "$PROXMOX_TYPE"); fi if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS_ARRAY+=(--proxmox-type "$PROXMOX_TYPE"); fi
if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi
@ -118,6 +122,8 @@ while [[ $# -gt 0 ]]; do
--disable-host) ENABLE_HOST="false"; shift ;; --disable-host) ENABLE_HOST="false"; shift ;;
--enable-docker) ENABLE_DOCKER="true"; shift ;; --enable-docker) ENABLE_DOCKER="true"; shift ;;
--disable-docker) ENABLE_DOCKER="false"; 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 ;; --enable-proxmox) ENABLE_PROXMOX="true"; shift ;;
--proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;; --proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;;
--insecure) INSECURE="true"; shift ;; --insecure) INSECURE="true"; shift ;;
@ -406,6 +412,10 @@ if [[ "$OS" == "darwin" ]]; then
PLIST_ARGS="${PLIST_ARGS} PLIST_ARGS="${PLIST_ARGS}
<string>--enable-docker</string>" <string>--enable-docker</string>"
fi fi
if [[ "$ENABLE_KUBERNETES" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--enable-kubernetes</string>"
fi
if [[ "$INSECURE" == "true" ]]; then if [[ "$INSECURE" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS} PLIST_ARGS="${PLIST_ARGS}
<string>--insecure</string>" <string>--insecure</string>"
@ -694,6 +704,7 @@ PULSE_TOKEN=${PULSE_TOKEN}
PULSE_INTERVAL=${INTERVAL} PULSE_INTERVAL=${INTERVAL}
PULSE_ENABLE_HOST=${ENABLE_HOST} PULSE_ENABLE_HOST=${ENABLE_HOST}
PULSE_ENABLE_DOCKER=${ENABLE_DOCKER} PULSE_ENABLE_DOCKER=${ENABLE_DOCKER}
PULSE_ENABLE_KUBERNETES=${ENABLE_KUBERNETES}
EOF EOF
chmod 600 "$TRUENAS_ENV_FILE" chmod 600 "$TRUENAS_ENV_FILE"