feat: add Kubernetes platform support
This commit is contained in:
parent
a8188b92fb
commit
d96942596f
33 changed files with 3437 additions and 445 deletions
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
|
@ -18,6 +19,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
|
@ -30,7 +32,7 @@ var (
|
|||
agentInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "pulse_agent_info",
|
||||
Help: "Information about the Pulse agent",
|
||||
}, []string{"version", "host_enabled", "docker_enabled"})
|
||||
}, []string{"version", "host_enabled", "docker_enabled", "kubernetes_enabled"})
|
||||
|
||||
agentUp = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pulse_agent_up",
|
||||
|
|
@ -78,6 +80,7 @@ func main() {
|
|||
Str("pulse_url", cfg.PulseURL).
|
||||
Bool("host_agent", cfg.EnableHost).
|
||||
Bool("docker_agent", cfg.EnableDocker).
|
||||
Bool("kubernetes_agent", cfg.EnableKubernetes).
|
||||
Bool("proxmox_mode", cfg.EnableProxmox).
|
||||
Bool("auto_update", !cfg.DisableAutoUpdate).
|
||||
Msg("Starting Pulse Unified Agent")
|
||||
|
|
@ -87,6 +90,7 @@ func main() {
|
|||
Version,
|
||||
fmt.Sprintf("%t", cfg.EnableHost),
|
||||
fmt.Sprintf("%t", cfg.EnableDocker),
|
||||
fmt.Sprintf("%t", cfg.EnableKubernetes),
|
||||
).Set(1)
|
||||
agentUp.Set(1)
|
||||
|
||||
|
|
@ -187,10 +191,50 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// 10. Start Kubernetes Agent (if enabled)
|
||||
if cfg.EnableKubernetes {
|
||||
kubeCfg := kubernetesagent.Config{
|
||||
PulseURL: cfg.PulseURL,
|
||||
APIToken: cfg.APIToken,
|
||||
Interval: cfg.Interval,
|
||||
AgentID: cfg.AgentID,
|
||||
AgentType: "unified",
|
||||
AgentVersion: Version,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
LogLevel: cfg.LogLevel,
|
||||
Logger: &logger,
|
||||
KubeconfigPath: cfg.KubeconfigPath,
|
||||
KubeContext: cfg.KubeContext,
|
||||
IncludeNamespaces: cfg.KubeIncludeNamespaces,
|
||||
ExcludeNamespaces: cfg.KubeExcludeNamespaces,
|
||||
IncludeAllPods: cfg.KubeIncludeAllPods,
|
||||
MaxPods: cfg.KubeMaxPods,
|
||||
}
|
||||
|
||||
agent, err := kubernetesagent.New(kubeCfg)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Kubernetes not available, will retry with exponential backoff")
|
||||
|
||||
g.Go(func() error {
|
||||
retried := initKubernetesWithRetry(ctx, kubeCfg, &logger)
|
||||
if retried != nil {
|
||||
logger.Info().Msg("Kubernetes agent module started (after retry)")
|
||||
return retried.Run(ctx)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
g.Go(func() error {
|
||||
logger.Info().Msg("Kubernetes agent module started")
|
||||
return agent.Run(ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as ready after all agents started
|
||||
ready.Store(true)
|
||||
|
||||
// 10. Wait for all agents to exit
|
||||
// 11. Wait for all agents to exit
|
||||
if err := g.Wait(); err != nil && err != context.Canceled {
|
||||
logger.Error().Err(err).Msg("Agent terminated with error")
|
||||
agentUp.Set(0)
|
||||
|
|
@ -198,7 +242,7 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 11. Cleanup
|
||||
// 12. Cleanup
|
||||
agentUp.Set(0)
|
||||
cleanupDockerAgent(dockerAgent, &logger)
|
||||
|
||||
|
|
@ -274,16 +318,25 @@ type Config struct {
|
|||
Logger *zerolog.Logger
|
||||
|
||||
// Module flags
|
||||
EnableHost bool
|
||||
EnableDocker bool
|
||||
EnableProxmox bool
|
||||
ProxmoxType string // "pve", "pbs", or "" for auto-detect
|
||||
EnableHost bool
|
||||
EnableDocker bool
|
||||
EnableKubernetes bool
|
||||
EnableProxmox bool
|
||||
ProxmoxType string // "pve", "pbs", or "" for auto-detect
|
||||
|
||||
// Auto-update
|
||||
DisableAutoUpdate bool
|
||||
|
||||
// Health/metrics server
|
||||
HealthAddr string
|
||||
|
||||
// Kubernetes
|
||||
KubeconfigPath string
|
||||
KubeContext string
|
||||
KubeIncludeNamespaces []string
|
||||
KubeExcludeNamespaces []string
|
||||
KubeIncludeAllPods bool
|
||||
KubeMaxPods int
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
|
|
@ -298,10 +351,17 @@ func loadConfig() Config {
|
|||
envLogLevel := utils.GetenvTrim("LOG_LEVEL")
|
||||
envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST")
|
||||
envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER")
|
||||
envEnableKubernetes := utils.GetenvTrim("PULSE_ENABLE_KUBERNETES")
|
||||
envEnableProxmox := utils.GetenvTrim("PULSE_ENABLE_PROXMOX")
|
||||
envProxmoxType := utils.GetenvTrim("PULSE_PROXMOX_TYPE")
|
||||
envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE")
|
||||
envHealthAddr := utils.GetenvTrim("PULSE_HEALTH_ADDR")
|
||||
envKubeconfig := utils.GetenvTrim("PULSE_KUBECONFIG")
|
||||
envKubeContext := utils.GetenvTrim("PULSE_KUBE_CONTEXT")
|
||||
envKubeIncludeNamespaces := utils.GetenvTrim("PULSE_KUBE_INCLUDE_NAMESPACES")
|
||||
envKubeExcludeNamespaces := utils.GetenvTrim("PULSE_KUBE_EXCLUDE_NAMESPACES")
|
||||
envKubeIncludeAllPods := utils.GetenvTrim("PULSE_KUBE_INCLUDE_ALL_PODS")
|
||||
envKubeMaxPods := utils.GetenvTrim("PULSE_KUBE_MAX_PODS")
|
||||
|
||||
// Defaults
|
||||
defaultInterval := 30 * time.Second
|
||||
|
|
@ -321,6 +381,11 @@ func loadConfig() Config {
|
|||
defaultEnableDocker = utils.ParseBool(envEnableDocker)
|
||||
}
|
||||
|
||||
defaultEnableKubernetes := false
|
||||
if envEnableKubernetes != "" {
|
||||
defaultEnableKubernetes = utils.ParseBool(envEnableKubernetes)
|
||||
}
|
||||
|
||||
defaultEnableProxmox := false
|
||||
if envEnableProxmox != "" {
|
||||
defaultEnableProxmox = utils.ParseBool(envEnableProxmox)
|
||||
|
|
@ -342,14 +407,23 @@ func loadConfig() Config {
|
|||
|
||||
enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
|
||||
enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module")
|
||||
enableKubernetesFlag := flag.Bool("enable-kubernetes", defaultEnableKubernetes, "Enable Kubernetes Agent module")
|
||||
enableProxmoxFlag := flag.Bool("enable-proxmox", defaultEnableProxmox, "Enable Proxmox mode (creates API token, registers node)")
|
||||
proxmoxTypeFlag := flag.String("proxmox-type", envProxmoxType, "Proxmox type: pve or pbs (auto-detected if not specified)")
|
||||
disableAutoUpdateFlag := flag.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates")
|
||||
healthAddrFlag := flag.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)")
|
||||
kubeconfigFlag := flag.String("kubeconfig", envKubeconfig, "Path to kubeconfig (optional; uses in-cluster config if available)")
|
||||
kubeContextFlag := flag.String("kube-context", envKubeContext, "Kubeconfig context (optional)")
|
||||
kubeIncludeAllPodsFlag := flag.Bool("kube-include-all-pods", utils.ParseBool(envKubeIncludeAllPods), "Include all non-succeeded pods (may be large)")
|
||||
kubeMaxPodsFlag := flag.Int("kube-max-pods", defaultInt(envKubeMaxPods, 200), "Max pods included in report")
|
||||
showVersion := flag.Bool("version", false, "Print the agent version and exit")
|
||||
|
||||
var tagFlags multiValue
|
||||
flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)")
|
||||
var kubeIncludeNamespaceFlags multiValue
|
||||
flag.Var(&kubeIncludeNamespaceFlags, "kube-include-namespace", "Namespace to include (repeatable; default is all)")
|
||||
var kubeExcludeNamespaceFlags multiValue
|
||||
flag.Var(&kubeExcludeNamespaceFlags, "kube-exclude-namespace", "Namespace to exclude (repeatable)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
|
|
@ -376,22 +450,31 @@ func loadConfig() Config {
|
|||
}
|
||||
|
||||
tags := gatherTags(envTags, tagFlags)
|
||||
kubeIncludeNamespaces := gatherCSV(envKubeIncludeNamespaces, kubeIncludeNamespaceFlags)
|
||||
kubeExcludeNamespaces := gatherCSV(envKubeExcludeNamespaces, kubeExcludeNamespaceFlags)
|
||||
|
||||
return Config{
|
||||
PulseURL: pulseURL,
|
||||
APIToken: token,
|
||||
Interval: *intervalFlag,
|
||||
HostnameOverride: strings.TrimSpace(*hostnameFlag),
|
||||
AgentID: strings.TrimSpace(*agentIDFlag),
|
||||
Tags: tags,
|
||||
InsecureSkipVerify: *insecureFlag,
|
||||
LogLevel: logLevel,
|
||||
EnableHost: *enableHostFlag,
|
||||
EnableDocker: *enableDockerFlag,
|
||||
EnableProxmox: *enableProxmoxFlag,
|
||||
ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag),
|
||||
DisableAutoUpdate: *disableAutoUpdateFlag,
|
||||
HealthAddr: strings.TrimSpace(*healthAddrFlag),
|
||||
PulseURL: pulseURL,
|
||||
APIToken: token,
|
||||
Interval: *intervalFlag,
|
||||
HostnameOverride: strings.TrimSpace(*hostnameFlag),
|
||||
AgentID: strings.TrimSpace(*agentIDFlag),
|
||||
Tags: tags,
|
||||
InsecureSkipVerify: *insecureFlag,
|
||||
LogLevel: logLevel,
|
||||
EnableHost: *enableHostFlag,
|
||||
EnableDocker: *enableDockerFlag,
|
||||
EnableKubernetes: *enableKubernetesFlag,
|
||||
EnableProxmox: *enableProxmoxFlag,
|
||||
ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag),
|
||||
DisableAutoUpdate: *disableAutoUpdateFlag,
|
||||
HealthAddr: strings.TrimSpace(*healthAddrFlag),
|
||||
KubeconfigPath: strings.TrimSpace(*kubeconfigFlag),
|
||||
KubeContext: strings.TrimSpace(*kubeContextFlag),
|
||||
KubeIncludeNamespaces: kubeIncludeNamespaces,
|
||||
KubeExcludeNamespaces: kubeExcludeNamespaces,
|
||||
KubeIncludeAllPods: *kubeIncludeAllPodsFlag,
|
||||
KubeMaxPods: *kubeMaxPodsFlag,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +497,37 @@ func gatherTags(env string, flags []string) []string {
|
|||
return tags
|
||||
}
|
||||
|
||||
func gatherCSV(env string, flags []string) []string {
|
||||
values := make([]string, 0)
|
||||
if env != "" {
|
||||
for _, value := range strings.Split(env, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range flags {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func defaultInt(value string, fallback int) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseLogLevel(value string) (zerolog.Level, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
|
|
@ -484,3 +598,58 @@ func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *ze
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initKubernetesWithRetry attempts to initialize the Kubernetes agent with exponential backoff.
|
||||
// It returns the agent when Kubernetes becomes available, or nil if the context is cancelled.
|
||||
// Retry intervals: 5s, 10s, 20s, 40s, 80s, 160s, then cap at 5 minutes.
|
||||
func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, logger *zerolog.Logger) *kubernetesagent.Agent {
|
||||
const (
|
||||
initialDelay = 5 * time.Second
|
||||
maxDelay = 5 * time.Minute
|
||||
multiplier = 2.0
|
||||
)
|
||||
|
||||
delay := initialDelay
|
||||
attempt := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Kubernetes retry cancelled, context done")
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
attempt++
|
||||
logger.Info().
|
||||
Int("attempt", attempt).
|
||||
Str("next_retry", delay.String()).
|
||||
Msg("Waiting to retry Kubernetes connection")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Kubernetes retry cancelled during wait")
|
||||
return nil
|
||||
case <-time.After(delay):
|
||||
}
|
||||
|
||||
agent, err := kubernetesagent.New(cfg)
|
||||
if err == nil {
|
||||
logger.Info().
|
||||
Int("attempts", attempt).
|
||||
Msg("Successfully connected to Kubernetes after retry")
|
||||
return agent
|
||||
}
|
||||
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Int("attempt", attempt).
|
||||
Str("next_retry", (time.Duration(float64(delay) * multiplier)).String()).
|
||||
Msg("Kubernetes still not available, will retry")
|
||||
|
||||
delay = time.Duration(float64(delay) * multiplier)
|
||||
if delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ Initiate OIDC login flow.
|
|||
`GET /download/pulse-agent`
|
||||
Downloads the unified agent binary for the current platform.
|
||||
|
||||
The unified agent combines host and Docker monitoring. Use `--enable-docker` to enable Docker metrics.
|
||||
The unified agent combines host, Docker, and Kubernetes monitoring. Use `--enable-docker` or `--enable-kubernetes` to enable additional metrics.
|
||||
|
||||
See [UNIFIED_AGENT.md](UNIFIED_AGENT.md) for installation instructions.
|
||||
|
||||
|
|
@ -157,6 +157,7 @@ See [UNIFIED_AGENT.md](UNIFIED_AGENT.md) for installation instructions.
|
|||
### Submit Reports
|
||||
`POST /api/agents/host/report` - Host metrics
|
||||
`POST /api/agents/docker/report` - Docker container metrics
|
||||
`POST /api/agents/kubernetes/report` - Kubernetes cluster metrics
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Pulse Unified Agent
|
||||
|
||||
The unified agent (`pulse-agent`) combines host and Docker monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management.
|
||||
The unified agent (`pulse-agent`) combines host, Docker, and Kubernetes monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
|||
|
||||
- **Host Metrics**: CPU, memory, disk, network I/O, temperatures
|
||||
- **Docker Monitoring**: Container metrics, health checks, Swarm support (when enabled)
|
||||
- **Kubernetes Monitoring**: Cluster, node, pod, and deployment health (when enabled)
|
||||
- **Auto-Update**: Automatically updates when a new version is released
|
||||
- **Multi-Platform**: Linux, macOS, Windows support
|
||||
|
||||
|
|
@ -41,6 +42,13 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
|||
| `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` |
|
||||
| `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` |
|
||||
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` |
|
||||
| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Enable Kubernetes metrics | `false` |
|
||||
| `--kubeconfig` | `PULSE_KUBECONFIG` | Kubeconfig path (optional) | *(auto)* |
|
||||
| `--kube-context` | `PULSE_KUBE_CONTEXT` | Kubeconfig context (optional) | *(auto)* |
|
||||
| `--kube-include-namespace` | `PULSE_KUBE_INCLUDE_NAMESPACES` | Limit namespaces (repeatable or CSV) | *(all)* |
|
||||
| `--kube-exclude-namespace` | `PULSE_KUBE_EXCLUDE_NAMESPACES` | Exclude namespaces (repeatable or CSV) | *(none)* |
|
||||
| `--kube-include-all-pods` | `PULSE_KUBE_INCLUDE_ALL_PODS` | Include all non-succeeded pods | `false` |
|
||||
| `--kube-max-pods` | `PULSE_KUBE_MAX_PODS` | Max pods per report | `200` |
|
||||
| `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` |
|
||||
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
|
||||
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* |
|
||||
|
|
@ -61,6 +69,12 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
|||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
|
||||
```
|
||||
|
||||
### Host + Kubernetes Monitoring
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
|
||||
```
|
||||
|
||||
### Docker Monitoring Only
|
||||
```bash
|
||||
curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Tests for Charts API types and interface
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChartData, ChartsResponse, TimeRange, MetricPoint, ChartStats } from '@/api/charts';
|
||||
|
||||
// Note: We test the types and interfaces here since the actual API calls
|
||||
|
|
|
|||
|
|
@ -216,6 +216,191 @@ export class MonitoringAPI {
|
|||
}
|
||||
}
|
||||
|
||||
static async deleteKubernetesCluster(clusterId: string): Promise<DeleteKubernetesClusterResponse> {
|
||||
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let message = `Failed with status ${response.status}`;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text?.trim()) {
|
||||
message = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch (_jsonErr) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore read error
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text?.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as DeleteKubernetesClusterResponse;
|
||||
} catch (err) {
|
||||
throw new Error((err as Error).message || 'Failed to parse delete kubernetes cluster response');
|
||||
}
|
||||
}
|
||||
|
||||
static async unhideKubernetesCluster(clusterId: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/unhide`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `Failed with status ${response.status}`;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text?.trim()) {
|
||||
message = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch (_jsonErr) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore read error
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
static async markKubernetesClusterPendingUninstall(clusterId: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/pending-uninstall`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `Failed with status ${response.status}`;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text?.trim()) {
|
||||
message = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch (_jsonErr) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore read error
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
static async setKubernetesClusterDisplayName(clusterId: string, displayName: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/display-name`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Kubernetes cluster not found');
|
||||
}
|
||||
|
||||
let message = `Failed with status ${response.status}`;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text?.trim()) {
|
||||
message = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch (_jsonErr) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore read error
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
static async allowKubernetesClusterReenroll(clusterId: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/allow-reenroll`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Failed with status ${response.status}`;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text?.trim()) {
|
||||
message = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore read error
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteHostAgent(hostId: string): Promise<void> {
|
||||
if (!hostId) {
|
||||
throw new Error('Host ID is required to remove a host agent.');
|
||||
|
|
@ -313,3 +498,9 @@ export interface DeleteDockerHostResponse {
|
|||
message?: string;
|
||||
command?: DockerHostCommand;
|
||||
}
|
||||
|
||||
export interface DeleteKubernetesClusterResponse {
|
||||
success?: boolean;
|
||||
clusterId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { notificationStore } from '@/stores/notifications';
|
|||
import type { SecurityStatus } from '@/types/config';
|
||||
import type { HostLookupResponse } from '@/types/api';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
|
||||
import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE } from '@/constants/apiScopes';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { getPulseBaseUrl } from '@/utils/url';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
|
@ -108,6 +108,7 @@ export const UnifiedAgents: Component = () => {
|
|||
const [lookupError, setLookupError] = createSignal<string | null>(null);
|
||||
const [lookupLoading, setLookupLoading] = createSignal(false);
|
||||
const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring
|
||||
const [enableKubernetes, setEnableKubernetes] = createSignal(false); // Default to false - user must opt-in for Kubernetes monitoring
|
||||
const [enableProxmox, setEnableProxmox] = createSignal(false); // For Proxmox VE/PBS nodes - creates API token and auto-registers
|
||||
const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806)
|
||||
|
||||
|
|
@ -183,15 +184,15 @@ export const UnifiedAgents: Component = () => {
|
|||
setIsGeneratingToken(true);
|
||||
try {
|
||||
const desiredName = tokenName().trim() || buildDefaultTokenName();
|
||||
// Generate token with BOTH scopes
|
||||
const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE];
|
||||
// Generate token with unified agent reporting scopes
|
||||
const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE];
|
||||
const { token, record } = await SecurityAPI.createToken(desiredName, scopes);
|
||||
|
||||
setCurrentToken(token);
|
||||
setLatestRecord(record);
|
||||
setTokenName('');
|
||||
setConfirmedNoToken(false);
|
||||
notificationStore.success('Token generated with Host and Docker permissions.', 4000);
|
||||
notificationStore.success('Token generated with Host, Docker, and Kubernetes permissions.', 4000);
|
||||
} catch (err) {
|
||||
logger.error('Failed to generate agent token', err);
|
||||
notificationStore.error('Failed to generate agent token. Confirm you are signed in as an administrator.', 6000);
|
||||
|
|
@ -237,6 +238,7 @@ export const UnifiedAgents: Component = () => {
|
|||
};
|
||||
|
||||
const getDockerFlag = () => enableDocker() ? ' --enable-docker' : '';
|
||||
const getKubernetesFlag = () => enableKubernetes() ? ' --enable-kubernetes' : '';
|
||||
const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : '';
|
||||
const getInsecureFlag = () => insecureMode() ? ' --insecure' : '';
|
||||
const getCurlInsecureFlag = () => insecureMode() ? '-k' : '';
|
||||
|
|
@ -344,19 +346,32 @@ export const UnifiedAgents: Component = () => {
|
|||
});
|
||||
const hasRemovedDockerHosts = createMemo(() => removedDockerHosts().length > 0);
|
||||
|
||||
const kubernetesClusters = createMemo(() => {
|
||||
const clusters = state.kubernetesClusters || [];
|
||||
return clusters.slice().sort((a, b) => (a.displayName || a.name || a.id).localeCompare(b.displayName || b.name || b.id));
|
||||
});
|
||||
|
||||
const removedKubernetesClusters = createMemo(() => {
|
||||
const removed = state.removedKubernetesClusters || [];
|
||||
return removed.sort((a, b) => b.removedAt - a.removedAt);
|
||||
});
|
||||
const hasRemovedKubernetesClusters = createMemo(() => removedKubernetesClusters().length > 0);
|
||||
|
||||
const getUpgradeCommand = (_hostname: string) => {
|
||||
const token = resolvedToken();
|
||||
return `curl ${getCurlInsecureFlag()}-fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${token}${getInsecureFlag()}`;
|
||||
};
|
||||
|
||||
const handleRemoveAgent = async (id: string, type: 'host' | 'docker') => {
|
||||
const handleRemoveAgent = async (id: string, type: 'host' | 'docker' | 'kubernetes') => {
|
||||
if (!confirm('Are you sure you want to remove this agent? This will stop monitoring but will not uninstall the agent from the remote machine.')) return;
|
||||
|
||||
try {
|
||||
if (type === 'host') {
|
||||
await MonitoringAPI.deleteHostAgent(id);
|
||||
} else {
|
||||
} else if (type === 'docker') {
|
||||
await MonitoringAPI.deleteDockerHost(id);
|
||||
} else {
|
||||
await MonitoringAPI.deleteKubernetesCluster(id);
|
||||
}
|
||||
notificationStore.success('Agent removed from Pulse');
|
||||
} catch (err) {
|
||||
|
|
@ -375,6 +390,16 @@ export const UnifiedAgents: Component = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAllowKubernetesReenroll = async (clusterId: string, name?: string) => {
|
||||
try {
|
||||
await MonitoringAPI.allowKubernetesClusterReenroll(clusterId);
|
||||
notificationStore.success(`Re-enrollment allowed for ${name || clusterId}. Restart the agent to reconnect.`);
|
||||
} catch (err) {
|
||||
logger.error('Failed to allow kubernetes re-enrollment', err);
|
||||
notificationStore.error('Failed to allow kubernetes re-enrollment');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<Card padding="lg" class="space-y-5">
|
||||
|
|
@ -391,7 +416,7 @@ export const UnifiedAgents: Component = () => {
|
|||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a fresh token scoped for both Host and Docker monitoring.
|
||||
Create a fresh token scoped for Host, Docker, and Kubernetes monitoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -466,6 +491,15 @@ export const UnifiedAgents: Component = () => {
|
|||
/>
|
||||
Docker monitoring
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableKubernetes()}
|
||||
onChange={(e) => setEnableKubernetes(e.currentTarget.checked)}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
Kubernetes monitoring
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="For Proxmox VE/PBS nodes - auto-creates API token and registers the node">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -524,6 +558,14 @@ export const UnifiedAgents: Component = () => {
|
|||
cmd += getDockerFlag();
|
||||
}
|
||||
}
|
||||
// Append kubernetes flag if enabled
|
||||
if (enableKubernetes()) {
|
||||
if (cmd.includes('$env:PULSE_URL')) {
|
||||
cmd = `$env:PULSE_ENABLE_KUBERNETES="true"; ` + cmd;
|
||||
} else if (isBashScript) {
|
||||
cmd += getKubernetesFlag();
|
||||
}
|
||||
}
|
||||
// Append proxmox flag if enabled (Linux only - Proxmox doesn't run on Windows/macOS)
|
||||
if (enableProxmox() && isBashScript) {
|
||||
cmd += getProxmoxFlag();
|
||||
|
|
@ -800,6 +842,68 @@ export const UnifiedAgents: Component = () => {
|
|||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Kubernetes Clusters</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Kubernetes clusters currently reporting to Pulse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Version</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Last Seen</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<For each={kubernetesClusters()} fallback={
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No Kubernetes clusters reporting yet.
|
||||
</td>
|
||||
</tr>
|
||||
}>
|
||||
{(cluster) => (
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${connectedFromStatus(cluster.status)
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{cluster.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{cluster.version || cluster.agentVersion || '—'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(cluster.id, 'kubernetes')}
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Show when={hasRemovedDockerHosts()}>
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
|
|
@ -848,6 +952,55 @@ export const UnifiedAgents: Component = () => {
|
|||
</Card>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={hasRemovedKubernetesClusters()}>
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Removed Kubernetes Clusters</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Kubernetes clusters that were removed and are blocked from re-enrolling. Allow re-enrollment to let them report again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster ID</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Removed</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<For each={removedKubernetesClusters()}>
|
||||
{(cluster) => (
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{cluster.displayName || cluster.name || 'Unknown'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-mono text-xs">
|
||||
{cluster.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatRelativeTime(cluster.removedAt)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleAllowKubernetesReenroll(cluster.id, cluster.displayName || cluster.name)}
|
||||
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Allow re-enroll
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</Card>
|
||||
</Show>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ describe('UnifiedAgents token generation', () => {
|
|||
{ interval: 0 },
|
||||
);
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Token generated with Host and Docker permissions.',
|
||||
'Token generated with Host, Docker, and Kubernetes permissions.',
|
||||
4000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -187,16 +187,10 @@ export const Sparkline: Component<SparklineProps> = (props) => {
|
|||
|
||||
// Redraw when data or dimensions change
|
||||
createEffect(() => {
|
||||
// Read reactive values to track dependencies
|
||||
// This ensures the effect re-runs when data, metric, or dimensions change
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _data = props.data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _metric = props.metric;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _w = width();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _h = height();
|
||||
void props.data;
|
||||
void props.metric;
|
||||
void width();
|
||||
void height();
|
||||
|
||||
// Unregister previous draw callback if it exists
|
||||
if (unregister) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface APIScopeOption {
|
|||
export const HOST_AGENT_SCOPE = 'host-agent:report';
|
||||
export const DOCKER_REPORT_SCOPE = 'docker:report';
|
||||
export const DOCKER_MANAGE_SCOPE = 'docker:manage';
|
||||
export const KUBERNETES_REPORT_SCOPE = 'kubernetes:report';
|
||||
export const KUBERNETES_MANAGE_SCOPE = 'kubernetes:manage';
|
||||
export const MONITORING_READ_SCOPE = 'monitoring:read';
|
||||
export const MONITORING_WRITE_SCOPE = 'monitoring:write';
|
||||
export const SETTINGS_READ_SCOPE = 'settings:read';
|
||||
|
|
@ -38,6 +40,18 @@ export const API_SCOPE_OPTIONS: APIScopeOption[] = [
|
|||
description: 'Enable agent-triggered runtime commands and host actions.',
|
||||
group: 'Agents',
|
||||
},
|
||||
{
|
||||
value: KUBERNETES_REPORT_SCOPE,
|
||||
label: 'Kubernetes agent reporting',
|
||||
description: 'Allow the Kubernetes agent to submit cluster, node, and workload telemetry.',
|
||||
group: 'Agents',
|
||||
},
|
||||
{
|
||||
value: KUBERNETES_MANAGE_SCOPE,
|
||||
label: 'Kubernetes cluster management',
|
||||
description: 'Allow administrative actions for Kubernetes cluster agents.',
|
||||
group: 'Agents',
|
||||
},
|
||||
{
|
||||
value: HOST_AGENT_SCOPE,
|
||||
label: 'Host agent reporting',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* These tests focus on the ring buffer operations and metric recording logic.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// We can't directly test the module due to side effects (localStorage, window),
|
||||
// so we test the core logic patterns used in the implementation.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface State {
|
|||
containers: Container[];
|
||||
dockerHosts: DockerHost[];
|
||||
removedDockerHosts?: RemovedDockerHost[];
|
||||
kubernetesClusters?: KubernetesCluster[];
|
||||
removedKubernetesClusters?: RemovedKubernetesCluster[];
|
||||
hosts: Host[];
|
||||
replicationJobs: ReplicationJob[];
|
||||
storage: Storage[];
|
||||
|
|
@ -38,6 +40,34 @@ export interface RemovedDockerHost {
|
|||
removedAt: number;
|
||||
}
|
||||
|
||||
export interface KubernetesCluster {
|
||||
id: string;
|
||||
agentId: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
customDisplayName?: string;
|
||||
server?: string;
|
||||
context?: string;
|
||||
version?: string;
|
||||
status: string;
|
||||
lastSeen: number;
|
||||
intervalSeconds: number;
|
||||
agentVersion?: string;
|
||||
tokenId?: string;
|
||||
tokenName?: string;
|
||||
tokenHint?: string;
|
||||
tokenLastUsedAt?: number;
|
||||
hidden?: boolean;
|
||||
pendingUninstall?: boolean;
|
||||
}
|
||||
|
||||
export interface RemovedKubernetesCluster {
|
||||
id: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
removedAt: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type ResourceType =
|
|||
| 'node' // Proxmox VE node
|
||||
| 'host' // Standalone host (via host-agent)
|
||||
| 'docker-host' // Docker/Podman host
|
||||
| 'k8s-cluster' // Kubernetes cluster
|
||||
| 'k8s-node' // Kubernetes node
|
||||
| 'truenas' // TrueNAS system
|
||||
| 'vm' // Proxmox VM
|
||||
|
|
@ -134,7 +135,7 @@ export interface Resource {
|
|||
* Helper type guards
|
||||
*/
|
||||
export function isInfrastructure(r: Resource): boolean {
|
||||
return ['node', 'host', 'docker-host', 'k8s-node', 'truenas'].includes(r.type);
|
||||
return ['node', 'host', 'docker-host', 'k8s-cluster', 'k8s-node', 'truenas'].includes(r.type);
|
||||
}
|
||||
|
||||
export function isWorkload(r: Resource): boolean {
|
||||
|
|
|
|||
35
go.mod
35
go.mod
|
|
@ -19,7 +19,6 @@ require (
|
|||
github.com/rs/zerolog v1.34.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.11
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
golang.org/x/sync v0.18.0
|
||||
|
|
@ -27,6 +26,10 @@ require (
|
|||
golang.org/x/term v0.37.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.32.0
|
||||
k8s.io/apimachinery v0.32.0
|
||||
k8s.io/client-go v0.32.0
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -36,34 +39,48 @@ require (
|
|||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
|
|
@ -71,6 +88,7 @@ require (
|
|||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
|
|
@ -80,11 +98,20 @@ require (
|
|||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
|
||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
|
|
|||
BIN
go.sum
BIN
go.sum
Binary file not shown.
286
internal/api/kubernetes_agents.go
Normal file
286
internal/api/kubernetes_agents.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ type Router struct {
|
|||
notificationHandlers *NotificationHandlers
|
||||
notificationQueueHandlers *NotificationQueueHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
kubernetesAgentHandlers *KubernetesAgentHandlers
|
||||
hostAgentHandlers *HostAgentHandlers
|
||||
temperatureProxyHandlers *TemperatureProxyHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
|
|
@ -188,6 +189,7 @@ func (r *Router) setupRoutes() {
|
|||
r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings)
|
||||
updateHandlers := NewUpdateHandlers(r.updateManager, r.updateHistory)
|
||||
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
|
||||
r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.monitor, r.wsHub)
|
||||
r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub)
|
||||
r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.config, r.persistence, r.reloadFunc)
|
||||
r.resourceHandlers = NewResourceHandlers()
|
||||
|
|
@ -197,6 +199,7 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/monitoring/scheduler/health", RequireAuth(r.config, r.handleSchedulerHealth))
|
||||
r.mux.HandleFunc("/api/state", r.handleState)
|
||||
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport)))
|
||||
r.mux.HandleFunc("/api/agents/kubernetes/report", RequireAuth(r.config, RequireScope(config.ScopeKubernetesReport, r.kubernetesAgentHandlers.HandleReport)))
|
||||
r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport)))
|
||||
r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup)))
|
||||
r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost)))
|
||||
|
|
@ -207,6 +210,7 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/temperature-proxy/host-status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleHostProxyStatus)))
|
||||
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck)))
|
||||
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
|
||||
r.mux.HandleFunc("/api/agents/kubernetes/clusters/", RequireAdmin(r.config, RequireScope(config.ScopeKubernetesManage, r.kubernetesAgentHandlers.HandleClusterActions)))
|
||||
r.mux.HandleFunc("/api/version", r.handleVersion)
|
||||
r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage)))
|
||||
r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts)))
|
||||
|
|
@ -1130,7 +1134,7 @@ func (r *Router) setupRoutes() {
|
|||
}))
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule))
|
||||
r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings))
|
||||
|
||||
|
||||
// AI Intelligence endpoints - expose learned patterns, correlations, and predictions
|
||||
r.mux.HandleFunc("/api/ai/intelligence/patterns", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatterns))
|
||||
r.mux.HandleFunc("/api/ai/intelligence/predictions", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPredictions))
|
||||
|
|
@ -1444,13 +1448,13 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Initialize operational memory (change detection and remediation logging)
|
||||
dataDir := ""
|
||||
if r.persistence != nil {
|
||||
dataDir = r.persistence.DataDir()
|
||||
}
|
||||
|
||||
|
||||
changeDetector := ai.NewChangeDetector(ai.ChangeDetectorConfig{
|
||||
MaxChanges: 1000,
|
||||
DataDir: dataDir,
|
||||
|
|
@ -1458,7 +1462,7 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
|||
if changeDetector != nil {
|
||||
r.aiSettingsHandler.SetChangeDetector(changeDetector)
|
||||
}
|
||||
|
||||
|
||||
remediationLog := ai.NewRemediationLog(ai.RemediationLogConfig{
|
||||
MaxRecords: 500,
|
||||
DataDir: dataDir,
|
||||
|
|
@ -1466,7 +1470,7 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
|||
if remediationLog != nil {
|
||||
r.aiSettingsHandler.SetRemediationLog(remediationLog)
|
||||
}
|
||||
|
||||
|
||||
// Initialize pattern detector for failure prediction
|
||||
patternDetector := ai.NewPatternDetector(ai.PatternDetectorConfig{
|
||||
MaxEvents: 5000,
|
||||
|
|
@ -1477,7 +1481,7 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
|||
})
|
||||
if patternDetector != nil {
|
||||
r.aiSettingsHandler.SetPatternDetector(patternDetector)
|
||||
|
||||
|
||||
// Wire alert history to pattern detector for event tracking
|
||||
if alertManager := r.monitor.GetAlertManager(); alertManager != nil {
|
||||
alertManager.OnAlertHistory(func(alert alerts.Alert) {
|
||||
|
|
@ -1487,7 +1491,7 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
|||
log.Info().Msg("AI Pattern Detector: Wired to alert history for failure prediction")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Initialize correlation detector for multi-resource relationships
|
||||
correlationDetector := ai.NewCorrelationDetector(ai.CorrelationConfig{
|
||||
MaxEvents: 10000,
|
||||
|
|
@ -1498,7 +1502,7 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
|||
})
|
||||
if correlationDetector != nil {
|
||||
r.aiSettingsHandler.SetCorrelationDetector(correlationDetector)
|
||||
|
||||
|
||||
// Wire alert history to correlation detector
|
||||
if alertManager := r.monitor.GetAlertManager(); alertManager != nil {
|
||||
alertManager.OnAlertHistory(func(alert alerts.Alert) {
|
||||
|
|
|
|||
|
|
@ -11,15 +11,17 @@ import (
|
|||
|
||||
// Canonical API token scope strings.
|
||||
const (
|
||||
ScopeWildcard = "*"
|
||||
ScopeMonitoringRead = "monitoring:read"
|
||||
ScopeMonitoringWrite = "monitoring:write"
|
||||
ScopeDockerReport = "docker:report"
|
||||
ScopeDockerManage = "docker:manage"
|
||||
ScopeHostReport = "host-agent:report"
|
||||
ScopeHostManage = "host-agent:manage"
|
||||
ScopeSettingsRead = "settings:read"
|
||||
ScopeSettingsWrite = "settings:write"
|
||||
ScopeWildcard = "*"
|
||||
ScopeMonitoringRead = "monitoring:read"
|
||||
ScopeMonitoringWrite = "monitoring:write"
|
||||
ScopeDockerReport = "docker:report"
|
||||
ScopeDockerManage = "docker:manage"
|
||||
ScopeKubernetesReport = "kubernetes:report"
|
||||
ScopeKubernetesManage = "kubernetes:manage"
|
||||
ScopeHostReport = "host-agent:report"
|
||||
ScopeHostManage = "host-agent:manage"
|
||||
ScopeSettingsRead = "settings:read"
|
||||
ScopeSettingsWrite = "settings:write"
|
||||
)
|
||||
|
||||
// AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel).
|
||||
|
|
@ -28,6 +30,8 @@ var AllKnownScopes = []string{
|
|||
ScopeMonitoringWrite,
|
||||
ScopeDockerReport,
|
||||
ScopeDockerManage,
|
||||
ScopeKubernetesReport,
|
||||
ScopeKubernetesManage,
|
||||
ScopeHostReport,
|
||||
ScopeHostManage,
|
||||
ScopeSettingsRead,
|
||||
|
|
|
|||
|
|
@ -167,6 +167,8 @@ func TestIsKnownScope(t *testing.T) {
|
|||
{name: "monitoring:write", scope: ScopeMonitoringWrite, expected: true},
|
||||
{name: "docker:report", scope: ScopeDockerReport, expected: true},
|
||||
{name: "docker:manage", scope: ScopeDockerManage, expected: true},
|
||||
{name: "kubernetes:report", scope: ScopeKubernetesReport, expected: true},
|
||||
{name: "kubernetes:manage", scope: ScopeKubernetesManage, expected: true},
|
||||
{name: "host-agent:report", scope: ScopeHostReport, expected: true},
|
||||
{name: "host-agent:manage", scope: ScopeHostManage, expected: true},
|
||||
{name: "settings:read", scope: ScopeSettingsRead, expected: true},
|
||||
|
|
|
|||
683
internal/kubernetesagent/agent.go
Normal file
683
internal/kubernetesagent/agent.go
Normal 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
|
||||
}
|
||||
4
internal/kubernetesagent/version.go
Normal file
4
internal/kubernetesagent/version.go
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
package kubernetesagent
|
||||
|
||||
// Version is overridden at build time.
|
||||
var Version = "dev"
|
||||
|
|
@ -311,6 +311,151 @@ func (r RemovedDockerHost) ToFrontend() RemovedDockerHostFrontend {
|
|||
}
|
||||
}
|
||||
|
||||
// ToFrontend converts a KubernetesCluster to its frontend representation.
|
||||
func (c KubernetesCluster) ToFrontend() KubernetesClusterFrontend {
|
||||
cluster := KubernetesClusterFrontend{
|
||||
ID: c.ID,
|
||||
AgentID: c.AgentID,
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
CustomDisplayName: c.CustomDisplayName,
|
||||
Server: c.Server,
|
||||
Context: c.Context,
|
||||
Version: c.Version,
|
||||
Status: c.Status,
|
||||
LastSeen: c.LastSeen.Unix() * 1000,
|
||||
IntervalSeconds: c.IntervalSeconds,
|
||||
AgentVersion: c.AgentVersion,
|
||||
TokenID: c.TokenID,
|
||||
TokenName: c.TokenName,
|
||||
TokenHint: c.TokenHint,
|
||||
Hidden: c.Hidden,
|
||||
PendingUninstall: c.PendingUninstall,
|
||||
}
|
||||
|
||||
if c.TokenLastUsedAt != nil {
|
||||
ts := c.TokenLastUsedAt.Unix() * 1000
|
||||
cluster.TokenLastUsedAt = &ts
|
||||
}
|
||||
|
||||
if len(c.Nodes) > 0 {
|
||||
cluster.Nodes = make([]KubernetesNodeFrontend, len(c.Nodes))
|
||||
for i, n := range c.Nodes {
|
||||
cluster.Nodes[i] = KubernetesNodeFrontend{
|
||||
UID: n.UID,
|
||||
Name: n.Name,
|
||||
Ready: n.Ready,
|
||||
Unschedulable: n.Unschedulable,
|
||||
KubeletVersion: n.KubeletVersion,
|
||||
ContainerRuntimeVersion: n.ContainerRuntimeVersion,
|
||||
OSImage: n.OSImage,
|
||||
KernelVersion: n.KernelVersion,
|
||||
Architecture: n.Architecture,
|
||||
CapacityCPU: n.CapacityCPU,
|
||||
CapacityMemoryBytes: n.CapacityMemoryBytes,
|
||||
CapacityPods: n.CapacityPods,
|
||||
AllocCPU: n.AllocCPU,
|
||||
AllocMemoryBytes: n.AllocMemoryBytes,
|
||||
AllocPods: n.AllocPods,
|
||||
Roles: append([]string(nil), n.Roles...),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Pods) > 0 {
|
||||
cluster.Pods = make([]KubernetesPodFrontend, len(c.Pods))
|
||||
for i, p := range c.Pods {
|
||||
labels := make(map[string]string, len(p.Labels))
|
||||
for k, v := range p.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
containers := make([]KubernetesPodContainerFrontend, 0, len(p.Containers))
|
||||
for _, cn := range p.Containers {
|
||||
containers = append(containers, KubernetesPodContainerFrontend{
|
||||
Name: cn.Name,
|
||||
Image: cn.Image,
|
||||
Ready: cn.Ready,
|
||||
RestartCount: cn.RestartCount,
|
||||
State: cn.State,
|
||||
Reason: cn.Reason,
|
||||
Message: cn.Message,
|
||||
})
|
||||
}
|
||||
|
||||
var startTime *int64
|
||||
if p.StartTime != nil {
|
||||
ts := p.StartTime.Unix() * 1000
|
||||
startTime = &ts
|
||||
}
|
||||
|
||||
cluster.Pods[i] = KubernetesPodFrontend{
|
||||
UID: p.UID,
|
||||
Name: p.Name,
|
||||
Namespace: p.Namespace,
|
||||
NodeName: p.NodeName,
|
||||
Phase: p.Phase,
|
||||
Reason: p.Reason,
|
||||
Message: p.Message,
|
||||
QoSClass: p.QoSClass,
|
||||
CreatedAt: timeToUnixMillis(p.CreatedAt),
|
||||
StartTime: startTime,
|
||||
Restarts: p.Restarts,
|
||||
Labels: labels,
|
||||
OwnerKind: p.OwnerKind,
|
||||
OwnerName: p.OwnerName,
|
||||
Containers: containers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Deployments) > 0 {
|
||||
cluster.Deployments = make([]KubernetesDeploymentFrontend, len(c.Deployments))
|
||||
for i, d := range c.Deployments {
|
||||
labels := make(map[string]string, len(d.Labels))
|
||||
for k, v := range d.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
cluster.Deployments[i] = KubernetesDeploymentFrontend{
|
||||
UID: d.UID,
|
||||
Name: d.Name,
|
||||
Namespace: d.Namespace,
|
||||
DesiredReplicas: d.DesiredReplicas,
|
||||
UpdatedReplicas: d.UpdatedReplicas,
|
||||
ReadyReplicas: d.ReadyReplicas,
|
||||
AvailableReplicas: d.AvailableReplicas,
|
||||
Labels: labels,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cluster.DisplayName == "" && cluster.Name != "" {
|
||||
cluster.DisplayName = cluster.Name
|
||||
}
|
||||
|
||||
if cluster.DisplayName == "" {
|
||||
cluster.DisplayName = cluster.ID
|
||||
}
|
||||
|
||||
return cluster
|
||||
}
|
||||
|
||||
func timeToUnixMillis(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix() * 1000
|
||||
}
|
||||
|
||||
// ToFrontend converts a RemovedKubernetesCluster to its frontend representation.
|
||||
func (r RemovedKubernetesCluster) ToFrontend() RemovedKubernetesClusterFrontend {
|
||||
return RemovedKubernetesClusterFrontend{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
DisplayName: r.DisplayName,
|
||||
RemovedAt: r.RemovedAt.Unix() * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// ToFrontend converts a Host to HostFrontend.
|
||||
func (h Host) ToFrontend() HostFrontend {
|
||||
host := HostFrontend{
|
||||
|
|
|
|||
|
|
@ -11,30 +11,32 @@ import (
|
|||
// State represents the current state of all monitored resources
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
Nodes []Node `json:"nodes"`
|
||||
VMs []VM `json:"vms"`
|
||||
Containers []Container `json:"containers"`
|
||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
Storage []Storage `json:"storage"`
|
||||
CephClusters []CephCluster `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
PBSInstances []PBSInstance `json:"pbs"`
|
||||
PMGInstances []PMGInstance `json:"pmg"`
|
||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||
Backups Backups `json:"backups"`
|
||||
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
||||
Metrics []Metric `json:"metrics"`
|
||||
PVEBackups PVEBackups `json:"pveBackups"`
|
||||
Performance Performance `json:"performance"`
|
||||
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
||||
Stats Stats `json:"stats"`
|
||||
ActiveAlerts []Alert `json:"activeAlerts"`
|
||||
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
VMs []VM `json:"vms"`
|
||||
Containers []Container `json:"containers"`
|
||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
||||
KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"`
|
||||
RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
Storage []Storage `json:"storage"`
|
||||
CephClusters []CephCluster `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
PBSInstances []PBSInstance `json:"pbs"`
|
||||
PMGInstances []PMGInstance `json:"pmg"`
|
||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||
Backups Backups `json:"backups"`
|
||||
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
||||
Metrics []Metric `json:"metrics"`
|
||||
PVEBackups PVEBackups `json:"pveBackups"`
|
||||
Performance Performance `json:"performance"`
|
||||
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
||||
Stats Stats `json:"stats"`
|
||||
ActiveAlerts []Alert `json:"activeAlerts"`
|
||||
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
||||
}
|
||||
|
||||
// Alert represents an active alert (simplified for State)
|
||||
|
|
@ -228,22 +230,22 @@ type HostRAIDDevice struct {
|
|||
// HostCephCluster represents Ceph cluster status collected directly by the host agent.
|
||||
// This is separate from CephCluster which comes from the Proxmox API.
|
||||
type HostCephCluster struct {
|
||||
FSID string `json:"fsid"`
|
||||
Health HostCephHealth `json:"health"`
|
||||
MonMap HostCephMonitorMap `json:"monMap,omitempty"`
|
||||
MgrMap HostCephManagerMap `json:"mgrMap,omitempty"`
|
||||
OSDMap HostCephOSDMap `json:"osdMap"`
|
||||
PGMap HostCephPGMap `json:"pgMap"`
|
||||
Pools []HostCephPool `json:"pools,omitempty"`
|
||||
Services []HostCephService `json:"services,omitempty"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
FSID string `json:"fsid"`
|
||||
Health HostCephHealth `json:"health"`
|
||||
MonMap HostCephMonitorMap `json:"monMap,omitempty"`
|
||||
MgrMap HostCephManagerMap `json:"mgrMap,omitempty"`
|
||||
OSDMap HostCephOSDMap `json:"osdMap"`
|
||||
PGMap HostCephPGMap `json:"pgMap"`
|
||||
Pools []HostCephPool `json:"pools,omitempty"`
|
||||
Services []HostCephService `json:"services,omitempty"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
// HostCephHealth represents Ceph cluster health status.
|
||||
type HostCephHealth struct {
|
||||
Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR
|
||||
Checks map[string]HostCephCheck `json:"checks,omitempty"`
|
||||
Summary []HostCephHealthSummary `json:"summary,omitempty"`
|
||||
Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR
|
||||
Checks map[string]HostCephCheck `json:"checks,omitempty"`
|
||||
Summary []HostCephHealthSummary `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// HostCephCheck represents a health check detail.
|
||||
|
|
@ -464,6 +466,101 @@ type DockerContainerMount struct {
|
|||
Driver string `json:"driver,omitempty"`
|
||||
}
|
||||
|
||||
// KubernetesCluster represents a Kubernetes cluster reporting telemetry via the agent.
|
||||
type KubernetesCluster struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agentId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
CustomDisplayName string `json:"customDisplayName,omitempty"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
|
||||
Nodes []KubernetesNode `json:"nodes,omitempty"`
|
||||
Pods []KubernetesPod `json:"pods,omitempty"`
|
||||
Deployments []KubernetesDeployment `json:"deployments,omitempty"`
|
||||
|
||||
// Token information
|
||||
TokenID string `json:"tokenId,omitempty"`
|
||||
TokenName string `json:"tokenName,omitempty"`
|
||||
TokenHint string `json:"tokenHint,omitempty"`
|
||||
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
||||
|
||||
Hidden bool `json:"hidden"`
|
||||
PendingUninstall bool `json:"pendingUninstall"`
|
||||
}
|
||||
|
||||
// RemovedKubernetesCluster tracks a Kubernetes cluster that was deliberately removed and blocked from reporting.
|
||||
type RemovedKubernetesCluster struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
RemovedAt time.Time `json:"removedAt"`
|
||||
}
|
||||
|
||||
type KubernetesNode struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"`
|
||||
Unschedulable bool `json:"unschedulable,omitempty"`
|
||||
KubeletVersion string `json:"kubeletVersion,omitempty"`
|
||||
ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"`
|
||||
OSImage string `json:"osImage,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
CapacityCPU int64 `json:"capacityCpuCores,omitempty"`
|
||||
CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"`
|
||||
CapacityPods int64 `json:"capacityPods,omitempty"`
|
||||
AllocCPU int64 `json:"allocatableCpuCores,omitempty"`
|
||||
AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"`
|
||||
AllocPods int64 `json:"allocatablePods,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesPod struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
NodeName string `json:"nodeName,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
QoSClass string `json:"qosClass,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
StartTime *time.Time `json:"startTime,omitempty"`
|
||||
Restarts int `json:"restarts,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
OwnerKind string `json:"ownerKind,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Containers []KubernetesPodContainer `json:"containers,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesPodContainer struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
RestartCount int32 `json:"restartCount,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesDeployment struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
||||
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
|
||||
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
||||
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// DockerService summarises a Docker Swarm service.
|
||||
type DockerService struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -1577,6 +1674,185 @@ func (s *State) GetRemovedDockerHosts() []RemovedDockerHost {
|
|||
return entries
|
||||
}
|
||||
|
||||
// UpsertKubernetesCluster inserts or updates a Kubernetes cluster in state.
|
||||
func (s *State) UpsertKubernetesCluster(cluster KubernetesCluster) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
updated := false
|
||||
for i, existing := range s.KubernetesClusters {
|
||||
if existing.ID == cluster.ID {
|
||||
if existing.CustomDisplayName != "" {
|
||||
cluster.CustomDisplayName = existing.CustomDisplayName
|
||||
}
|
||||
cluster.Hidden = existing.Hidden
|
||||
cluster.PendingUninstall = existing.PendingUninstall
|
||||
s.KubernetesClusters[i] = cluster
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
s.KubernetesClusters = append(s.KubernetesClusters, cluster)
|
||||
}
|
||||
|
||||
sort.Slice(s.KubernetesClusters, func(i, j int) bool {
|
||||
left := s.KubernetesClusters[i].Name
|
||||
right := s.KubernetesClusters[j].Name
|
||||
if left == "" {
|
||||
left = s.KubernetesClusters[i].ID
|
||||
}
|
||||
if right == "" {
|
||||
right = s.KubernetesClusters[j].ID
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
|
||||
s.LastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// RemoveKubernetesCluster removes a Kubernetes cluster by ID and returns the removed cluster.
|
||||
func (s *State) RemoveKubernetesCluster(clusterID string) (KubernetesCluster, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, cluster := range s.KubernetesClusters {
|
||||
if cluster.ID == clusterID {
|
||||
s.KubernetesClusters = append(s.KubernetesClusters[:i], s.KubernetesClusters[i+1:]...)
|
||||
s.LastUpdate = time.Now()
|
||||
return cluster, true
|
||||
}
|
||||
}
|
||||
|
||||
return KubernetesCluster{}, false
|
||||
}
|
||||
|
||||
// SetKubernetesClusterStatus updates the status of a kubernetes cluster if present.
|
||||
func (s *State) SetKubernetesClusterStatus(clusterID, status string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
changed := false
|
||||
for i, cluster := range s.KubernetesClusters {
|
||||
if cluster.ID == clusterID {
|
||||
if cluster.Status != status {
|
||||
cluster.Status = status
|
||||
s.KubernetesClusters[i] = cluster
|
||||
s.LastUpdate = time.Now()
|
||||
}
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// SetKubernetesClusterHidden updates the hidden status of a kubernetes cluster and returns the updated cluster.
|
||||
func (s *State) SetKubernetesClusterHidden(clusterID string, hidden bool) (KubernetesCluster, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, cluster := range s.KubernetesClusters {
|
||||
if cluster.ID == clusterID {
|
||||
cluster.Hidden = hidden
|
||||
s.KubernetesClusters[i] = cluster
|
||||
s.LastUpdate = time.Now()
|
||||
return cluster, true
|
||||
}
|
||||
}
|
||||
return KubernetesCluster{}, false
|
||||
}
|
||||
|
||||
// SetKubernetesClusterPendingUninstall updates the pending uninstall flag and returns the updated cluster.
|
||||
func (s *State) SetKubernetesClusterPendingUninstall(clusterID string, pending bool) (KubernetesCluster, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, cluster := range s.KubernetesClusters {
|
||||
if cluster.ID == clusterID {
|
||||
cluster.PendingUninstall = pending
|
||||
s.KubernetesClusters[i] = cluster
|
||||
s.LastUpdate = time.Now()
|
||||
return cluster, true
|
||||
}
|
||||
}
|
||||
return KubernetesCluster{}, false
|
||||
}
|
||||
|
||||
// SetKubernetesClusterCustomDisplayName updates the custom display name for a kubernetes cluster.
|
||||
func (s *State) SetKubernetesClusterCustomDisplayName(clusterID string, customName string) (KubernetesCluster, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, cluster := range s.KubernetesClusters {
|
||||
if cluster.ID == clusterID {
|
||||
cluster.CustomDisplayName = customName
|
||||
s.KubernetesClusters[i] = cluster
|
||||
s.LastUpdate = time.Now()
|
||||
return cluster, true
|
||||
}
|
||||
}
|
||||
|
||||
return KubernetesCluster{}, false
|
||||
}
|
||||
|
||||
// GetKubernetesClusters returns a copy of kubernetes clusters.
|
||||
func (s *State) GetKubernetesClusters() []KubernetesCluster {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
clusters := make([]KubernetesCluster, len(s.KubernetesClusters))
|
||||
copy(clusters, s.KubernetesClusters)
|
||||
return clusters
|
||||
}
|
||||
|
||||
// AddRemovedKubernetesCluster records a removed kubernetes cluster entry.
|
||||
func (s *State) AddRemovedKubernetesCluster(entry RemovedKubernetesCluster) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
replaced := false
|
||||
for i, existing := range s.RemovedKubernetesClusters {
|
||||
if existing.ID == entry.ID {
|
||||
s.RemovedKubernetesClusters[i] = entry
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
s.RemovedKubernetesClusters = append(s.RemovedKubernetesClusters, entry)
|
||||
}
|
||||
sort.Slice(s.RemovedKubernetesClusters, func(i, j int) bool {
|
||||
return s.RemovedKubernetesClusters[i].RemovedAt.After(s.RemovedKubernetesClusters[j].RemovedAt)
|
||||
})
|
||||
s.LastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// RemoveRemovedKubernetesCluster deletes a removed kubernetes cluster entry by ID.
|
||||
func (s *State) RemoveRemovedKubernetesCluster(clusterID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, entry := range s.RemovedKubernetesClusters {
|
||||
if entry.ID == clusterID {
|
||||
s.RemovedKubernetesClusters = append(s.RemovedKubernetesClusters[:i], s.RemovedKubernetesClusters[i+1:]...)
|
||||
s.LastUpdate = time.Now()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemovedKubernetesClusters returns a copy of removed kubernetes cluster entries.
|
||||
func (s *State) GetRemovedKubernetesClusters() []RemovedKubernetesCluster {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entries := make([]RemovedKubernetesCluster, len(s.RemovedKubernetesClusters))
|
||||
copy(entries, s.RemovedKubernetesClusters)
|
||||
return entries
|
||||
}
|
||||
|
||||
// UpsertHost inserts or updates a generic host in state.
|
||||
func (s *State) UpsertHost(host Host) {
|
||||
s.mu.Lock()
|
||||
|
|
@ -1654,7 +1930,6 @@ func (s *State) UpsertCephCluster(cluster CephCluster) {
|
|||
s.LastUpdate = time.Now()
|
||||
}
|
||||
|
||||
|
||||
// SetHostStatus updates the status of a host if present.
|
||||
func (s *State) SetHostStatus(hostID, status string) bool {
|
||||
s.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -68,16 +68,16 @@ type VMFrontend struct {
|
|||
|
||||
// ContainerFrontend represents a Container with frontend-friendly field names
|
||||
type ContainerFrontend struct {
|
||||
ID string `json:"id"`
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
Instance string `json:"instance"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
Instance string `json:"instance"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
// OCI container support (Proxmox VE 9.1+)
|
||||
IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container
|
||||
OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest")
|
||||
IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container
|
||||
OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest")
|
||||
CPU float64 `json:"cpu"`
|
||||
CPUs int `json:"cpus"`
|
||||
Memory *Memory `json:"memory,omitempty"` // Full memory object
|
||||
|
|
@ -146,6 +146,98 @@ type RemovedDockerHostFrontend struct {
|
|||
RemovedAt int64 `json:"removedAt"`
|
||||
}
|
||||
|
||||
// KubernetesClusterFrontend represents a Kubernetes cluster for the frontend.
|
||||
type KubernetesClusterFrontend struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agentId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
CustomDisplayName string `json:"customDisplayName,omitempty"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
TokenID string `json:"tokenId,omitempty"`
|
||||
TokenName string `json:"tokenName,omitempty"`
|
||||
TokenHint string `json:"tokenHint,omitempty"`
|
||||
TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"`
|
||||
Hidden bool `json:"hidden"`
|
||||
PendingUninstall bool `json:"pendingUninstall"`
|
||||
|
||||
Nodes []KubernetesNodeFrontend `json:"nodes,omitempty"`
|
||||
Pods []KubernetesPodFrontend `json:"pods,omitempty"`
|
||||
Deployments []KubernetesDeploymentFrontend `json:"deployments,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesNodeFrontend struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"`
|
||||
Unschedulable bool `json:"unschedulable,omitempty"`
|
||||
KubeletVersion string `json:"kubeletVersion,omitempty"`
|
||||
ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"`
|
||||
OSImage string `json:"osImage,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
CapacityCPU int64 `json:"capacityCpuCores,omitempty"`
|
||||
CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"`
|
||||
CapacityPods int64 `json:"capacityPods,omitempty"`
|
||||
AllocCPU int64 `json:"allocatableCpuCores,omitempty"`
|
||||
AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"`
|
||||
AllocPods int64 `json:"allocatablePods,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesPodFrontend struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
NodeName string `json:"nodeName,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
QoSClass string `json:"qosClass,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
StartTime *int64 `json:"startTime,omitempty"`
|
||||
Restarts int `json:"restarts,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
OwnerKind string `json:"ownerKind,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Containers []KubernetesPodContainerFrontend `json:"containers,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesPodContainerFrontend struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
RestartCount int32 `json:"restartCount,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type KubernetesDeploymentFrontend struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
||||
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
|
||||
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
||||
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// RemovedKubernetesClusterFrontend represents a blocked kubernetes cluster entry for the frontend.
|
||||
type RemovedKubernetesClusterFrontend struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
RemovedAt int64 `json:"removedAt"`
|
||||
}
|
||||
|
||||
// DockerContainerFrontend represents a Docker container for the frontend
|
||||
type DockerContainerFrontend struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -426,29 +518,31 @@ type ReplicationJobFrontend struct {
|
|||
|
||||
// StateFrontend represents the state with frontend-friendly field names
|
||||
type StateFrontend struct {
|
||||
Nodes []NodeFrontend `json:"nodes"`
|
||||
VMs []VMFrontend `json:"vms"`
|
||||
Containers []ContainerFrontend `json:"containers"`
|
||||
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"`
|
||||
Hosts []HostFrontend `json:"hosts"`
|
||||
Storage []StorageFrontend `json:"storage"`
|
||||
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
PBS []PBSInstance `json:"pbs"` // Keep as is
|
||||
PMG []PMGInstance `json:"pmg"`
|
||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||
Backups Backups `json:"backups"`
|
||||
ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"`
|
||||
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
|
||||
Metrics map[string]any `json:"metrics"` // Empty object for now
|
||||
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is
|
||||
Performance map[string]any `json:"performance"` // Empty object for now
|
||||
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
|
||||
Stats map[string]any `json:"stats"` // Empty object for now
|
||||
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
|
||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting
|
||||
Nodes []NodeFrontend `json:"nodes"`
|
||||
VMs []VMFrontend `json:"vms"`
|
||||
Containers []ContainerFrontend `json:"containers"`
|
||||
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"`
|
||||
KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters,omitempty"`
|
||||
RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters,omitempty"`
|
||||
Hosts []HostFrontend `json:"hosts"`
|
||||
Storage []StorageFrontend `json:"storage"`
|
||||
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
PBS []PBSInstance `json:"pbs"` // Keep as is
|
||||
PMG []PMGInstance `json:"pmg"`
|
||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||
Backups Backups `json:"backups"`
|
||||
ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"`
|
||||
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
|
||||
Metrics map[string]any `json:"metrics"` // Empty object for now
|
||||
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is
|
||||
Performance map[string]any `json:"performance"` // Empty object for now
|
||||
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
|
||||
Stats map[string]any `json:"stats"` // Empty object for now
|
||||
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
|
||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting
|
||||
// Unified resources - the new way to access all monitored entities
|
||||
Resources []ResourceFrontend `json:"resources,omitempty"`
|
||||
}
|
||||
|
|
@ -481,9 +575,9 @@ type ResourceFrontend struct {
|
|||
Uptime *int64 `json:"uptime,omitempty"`
|
||||
|
||||
// Metadata
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
|
||||
Alerts []ResourceAlertFrontend `json:"alerts,omitempty"`
|
||||
|
||||
// Identity for deduplication
|
||||
|
|
|
|||
|
|
@ -4,30 +4,32 @@ import "time"
|
|||
|
||||
// StateSnapshot represents a snapshot of the state without mutex
|
||||
type StateSnapshot struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
VMs []VM `json:"vms"`
|
||||
Containers []Container `json:"containers"`
|
||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
Storage []Storage `json:"storage"`
|
||||
CephClusters []CephCluster `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
PBSInstances []PBSInstance `json:"pbs"`
|
||||
PMGInstances []PMGInstance `json:"pmg"`
|
||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||
Backups Backups `json:"backups"`
|
||||
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
||||
Metrics []Metric `json:"metrics"`
|
||||
PVEBackups PVEBackups `json:"pveBackups"`
|
||||
Performance Performance `json:"performance"`
|
||||
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
||||
Stats Stats `json:"stats"`
|
||||
ActiveAlerts []Alert `json:"activeAlerts"`
|
||||
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
VMs []VM `json:"vms"`
|
||||
Containers []Container `json:"containers"`
|
||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
||||
KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"`
|
||||
RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
Storage []Storage `json:"storage"`
|
||||
CephClusters []CephCluster `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
PBSInstances []PBSInstance `json:"pbs"`
|
||||
PMGInstances []PMGInstance `json:"pmg"`
|
||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||
Backups Backups `json:"backups"`
|
||||
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
||||
Metrics []Metric `json:"metrics"`
|
||||
PVEBackups PVEBackups `json:"pveBackups"`
|
||||
Performance Performance `json:"performance"`
|
||||
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
||||
Stats Stats `json:"stats"`
|
||||
ActiveAlerts []Alert `json:"activeAlerts"`
|
||||
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
||||
}
|
||||
|
||||
// GetSnapshot returns a snapshot of the current state without mutex
|
||||
|
|
@ -45,19 +47,21 @@ func (s *State) GetSnapshot() StateSnapshot {
|
|||
|
||||
// Create a snapshot without mutex
|
||||
snapshot := StateSnapshot{
|
||||
Nodes: append([]Node{}, s.Nodes...),
|
||||
VMs: append([]VM{}, s.VMs...),
|
||||
Containers: append([]Container{}, s.Containers...),
|
||||
DockerHosts: append([]DockerHost{}, s.DockerHosts...),
|
||||
RemovedDockerHosts: append([]RemovedDockerHost{}, s.RemovedDockerHosts...),
|
||||
Hosts: append([]Host{}, s.Hosts...),
|
||||
Storage: append([]Storage{}, s.Storage...),
|
||||
CephClusters: append([]CephCluster{}, s.CephClusters...),
|
||||
PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...),
|
||||
PBSInstances: append([]PBSInstance{}, s.PBSInstances...),
|
||||
PMGInstances: append([]PMGInstance{}, s.PMGInstances...),
|
||||
PBSBackups: pbsBackups,
|
||||
PMGBackups: pmgBackups,
|
||||
Nodes: append([]Node{}, s.Nodes...),
|
||||
VMs: append([]VM{}, s.VMs...),
|
||||
Containers: append([]Container{}, s.Containers...),
|
||||
DockerHosts: append([]DockerHost{}, s.DockerHosts...),
|
||||
RemovedDockerHosts: append([]RemovedDockerHost{}, s.RemovedDockerHosts...),
|
||||
KubernetesClusters: append([]KubernetesCluster{}, s.KubernetesClusters...),
|
||||
RemovedKubernetesClusters: append([]RemovedKubernetesCluster{}, s.RemovedKubernetesClusters...),
|
||||
Hosts: append([]Host{}, s.Hosts...),
|
||||
Storage: append([]Storage{}, s.Storage...),
|
||||
CephClusters: append([]CephCluster{}, s.CephClusters...),
|
||||
PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...),
|
||||
PBSInstances: append([]PBSInstance{}, s.PBSInstances...),
|
||||
PMGInstances: append([]PMGInstance{}, s.PMGInstances...),
|
||||
PBSBackups: pbsBackups,
|
||||
PMGBackups: pmgBackups,
|
||||
Backups: Backups{
|
||||
PVE: pveBackups,
|
||||
PBS: pbsBackups,
|
||||
|
|
@ -113,6 +117,16 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
|
|||
removedDockerHosts[i] = entry.ToFrontend()
|
||||
}
|
||||
|
||||
kubernetesClusters := make([]KubernetesClusterFrontend, len(s.KubernetesClusters))
|
||||
for i, cluster := range s.KubernetesClusters {
|
||||
kubernetesClusters[i] = cluster.ToFrontend()
|
||||
}
|
||||
|
||||
removedKubernetesClusters := make([]RemovedKubernetesClusterFrontend, len(s.RemovedKubernetesClusters))
|
||||
for i, entry := range s.RemovedKubernetesClusters {
|
||||
removedKubernetesClusters[i] = entry.ToFrontend()
|
||||
}
|
||||
|
||||
hosts := make([]HostFrontend, len(s.Hosts))
|
||||
for i, host := range s.Hosts {
|
||||
hosts[i] = host.ToFrontend()
|
||||
|
|
@ -141,6 +155,8 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
|
|||
Containers: containers,
|
||||
DockerHosts: dockerHosts,
|
||||
RemovedDockerHosts: removedDockerHosts,
|
||||
KubernetesClusters: kubernetesClusters,
|
||||
RemovedKubernetesClusters: removedKubernetesClusters,
|
||||
Hosts: hosts,
|
||||
Storage: storage,
|
||||
CephClusters: cephClusters,
|
||||
|
|
|
|||
390
internal/monitoring/kubernetes_agents.go
Normal file
390
internal/monitoring/kubernetes_agents.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -602,7 +602,7 @@ type Monitor struct {
|
|||
startTime time.Time
|
||||
rateTracker *RateTracker
|
||||
metricsHistory *MetricsHistory
|
||||
metricsStore *metrics.Store // Persistent SQLite metrics storage
|
||||
metricsStore *metrics.Store // Persistent SQLite metrics storage
|
||||
alertManager *alerts.Manager
|
||||
notificationMgr *notifications.NotificationManager
|
||||
configPersist *config.ConfigPersistence
|
||||
|
|
@ -626,6 +626,8 @@ type Monitor struct {
|
|||
nodeRRDMemCache map[string]rrdMemCacheEntry
|
||||
removedDockerHosts map[string]time.Time // Track deliberately removed Docker hosts (ID -> removal time)
|
||||
dockerTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness
|
||||
removedKubernetesClusters map[string]time.Time // Track deliberately removed Kubernetes clusters (ID -> removal time)
|
||||
kubernetesTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness
|
||||
hostTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness
|
||||
dockerCommands map[string]*dockerHostCommand
|
||||
dockerCommandIndex map[string]string
|
||||
|
|
@ -651,7 +653,7 @@ type Monitor struct {
|
|||
instanceInfoCache map[string]*instanceInfo
|
||||
pollStatusMap map[string]*pollStatus
|
||||
dlqInsightMap map[string]*dlqInsight
|
||||
nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period)
|
||||
nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period)
|
||||
resourceStore ResourceStoreInterface // Optional unified resource store for polling optimization
|
||||
}
|
||||
|
||||
|
|
@ -917,17 +919,21 @@ func (m *Monitor) shouldRunBackupPoll(last time.Time, now time.Time) (bool, stri
|
|||
}
|
||||
|
||||
const (
|
||||
dockerConnectionPrefix = "docker-"
|
||||
hostConnectionPrefix = "host-"
|
||||
dockerOfflineGraceMultiplier = 4
|
||||
dockerMinimumHealthWindow = 30 * time.Second
|
||||
dockerMaximumHealthWindow = 10 * time.Minute
|
||||
hostOfflineGraceMultiplier = 6
|
||||
hostMinimumHealthWindow = 60 * time.Second
|
||||
hostMaximumHealthWindow = 10 * time.Minute
|
||||
nodeOfflineGracePeriod = 60 * time.Second // Grace period before marking Proxmox nodes offline
|
||||
nodeRRDCacheTTL = 30 * time.Second
|
||||
nodeRRDRequestTimeout = 2 * time.Second
|
||||
dockerConnectionPrefix = "docker-"
|
||||
kubernetesConnectionPrefix = "kubernetes-"
|
||||
hostConnectionPrefix = "host-"
|
||||
dockerOfflineGraceMultiplier = 4
|
||||
dockerMinimumHealthWindow = 30 * time.Second
|
||||
dockerMaximumHealthWindow = 10 * time.Minute
|
||||
kubernetesOfflineGraceMultiplier = 4
|
||||
kubernetesMinimumHealthWindow = 30 * time.Second
|
||||
kubernetesMaximumHealthWindow = 10 * time.Minute
|
||||
hostOfflineGraceMultiplier = 6
|
||||
hostMinimumHealthWindow = 60 * time.Second
|
||||
hostMaximumHealthWindow = 10 * time.Minute
|
||||
nodeOfflineGracePeriod = 60 * time.Second // Grace period before marking Proxmox nodes offline
|
||||
nodeRRDCacheTTL = 30 * time.Second
|
||||
nodeRRDRequestTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type taskOutcome struct {
|
||||
|
|
@ -1766,20 +1772,20 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
|||
// Record Docker HOST metrics for sparkline charts
|
||||
now := time.Now()
|
||||
hostMetricKey := fmt.Sprintf("dockerHost:%s", host.ID)
|
||||
|
||||
|
||||
// Record host CPU usage
|
||||
m.metricsHistory.AddGuestMetric(hostMetricKey, "cpu", host.CPUUsage, now)
|
||||
|
||||
|
||||
// Record host Memory usage
|
||||
m.metricsHistory.AddGuestMetric(hostMetricKey, "memory", host.Memory.Usage, now)
|
||||
|
||||
|
||||
// Record host Disk usage (use first disk or calculate total)
|
||||
var hostDiskPercent float64
|
||||
if len(host.Disks) > 0 {
|
||||
hostDiskPercent = host.Disks[0].Usage
|
||||
}
|
||||
m.metricsHistory.AddGuestMetric(hostMetricKey, "disk", hostDiskPercent, now)
|
||||
|
||||
|
||||
// Also write to persistent SQLite store
|
||||
if m.metricsStore != nil {
|
||||
m.metricsStore.Write("dockerHost", host.ID, "cpu", host.CPUUsage, now)
|
||||
|
|
@ -1795,13 +1801,13 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
|||
}
|
||||
// Build a unique metric key for Docker containers
|
||||
metricKey := fmt.Sprintf("docker:%s", container.ID)
|
||||
|
||||
|
||||
// Record CPU (already a percentage 0-100)
|
||||
m.metricsHistory.AddGuestMetric(metricKey, "cpu", container.CPUPercent, now)
|
||||
|
||||
|
||||
// Record Memory (already a percentage 0-100)
|
||||
m.metricsHistory.AddGuestMetric(metricKey, "memory", container.MemoryPercent, now)
|
||||
|
||||
|
||||
// Record Disk usage as percentage of writable layer vs root filesystem
|
||||
var diskPercent float64
|
||||
if container.RootFilesystemBytes > 0 && container.WritableLayerBytes > 0 {
|
||||
|
|
@ -2875,6 +2881,8 @@ func New(cfg *config.Config) (*Monitor, error) {
|
|||
nodeRRDMemCache: make(map[string]rrdMemCacheEntry),
|
||||
removedDockerHosts: make(map[string]time.Time),
|
||||
dockerTokenBindings: make(map[string]string),
|
||||
removedKubernetesClusters: make(map[string]time.Time),
|
||||
kubernetesTokenBindings: make(map[string]string),
|
||||
hostTokenBindings: make(map[string]string),
|
||||
dockerCommands: make(map[string]*dockerHostCommand),
|
||||
dockerCommandIndex: make(map[string]string),
|
||||
|
|
@ -3518,8 +3526,10 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
|
|||
case <-pollTicker.C:
|
||||
now := time.Now()
|
||||
m.evaluateDockerAgents(now)
|
||||
m.evaluateKubernetesAgents(now)
|
||||
m.evaluateHostAgents(now)
|
||||
m.cleanupRemovedDockerHosts(now)
|
||||
m.cleanupRemovedKubernetesClusters(now)
|
||||
m.cleanupGuestMetadataCache(now)
|
||||
m.cleanupDiagnosticSnapshots(now)
|
||||
m.cleanupRRDCache(now)
|
||||
|
|
@ -5098,12 +5108,12 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
|
|||
// Run physical disk polling in background to avoid blocking the main task
|
||||
go func(inst string, pveClient PVEClientInterface, nodeList []proxmox.Node, nodeStatus map[string]string, modelNodesCopy []models.Node) {
|
||||
defer recoverFromPanic(fmt.Sprintf("pollPhysicalDisks-%s", inst))
|
||||
|
||||
|
||||
// Use a generous timeout for disk polling
|
||||
diskTimeout := 60 * time.Second
|
||||
diskCtx, diskCancel := context.WithTimeout(context.Background(), diskTimeout)
|
||||
defer diskCancel()
|
||||
|
||||
|
||||
log.Debug().
|
||||
Int("nodeCount", len(nodeList)).
|
||||
Dur("interval", pollingInterval).
|
||||
|
|
@ -5131,7 +5141,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
|
|||
return
|
||||
default:
|
||||
}
|
||||
|
||||
|
||||
// Skip offline nodes but preserve their existing disk data
|
||||
if nodeStatus[node.Node] != "online" {
|
||||
log.Debug().Str("node", node.Node).Msg("Skipping disk poll for offline node - preserving existing data")
|
||||
|
|
@ -5418,12 +5428,12 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
|
|||
default:
|
||||
go func(inst string, pveClient PVEClientInterface, nodeList []proxmox.Node) {
|
||||
defer recoverFromPanic(fmt.Sprintf("pollStorageWithNodes-%s", inst))
|
||||
|
||||
|
||||
// Use a generous timeout for storage polling - it's not blocking the main task
|
||||
storageTimeout := 60 * time.Second
|
||||
storageCtx, storageCancel := context.WithTimeout(context.Background(), storageTimeout)
|
||||
defer storageCancel()
|
||||
|
||||
|
||||
m.pollStorageWithNodes(storageCtx, inst, pveClient, nodeList)
|
||||
}(instanceName, client, nodes)
|
||||
}
|
||||
|
|
@ -7455,11 +7465,11 @@ func (m *Monitor) shouldSkipNodeMetrics(nodeName string) bool {
|
|||
m.mu.RLock()
|
||||
store := m.resourceStore
|
||||
m.mu.RUnlock()
|
||||
|
||||
|
||||
if store == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
should := store.ShouldSkipAPIPolling(nodeName)
|
||||
if should {
|
||||
log.Debug().
|
||||
|
|
@ -7475,12 +7485,12 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) {
|
|||
m.mu.RLock()
|
||||
store := m.resourceStore
|
||||
m.mu.RUnlock()
|
||||
|
||||
|
||||
if store == nil {
|
||||
log.Debug().Msg("[Resources] No resource store configured, skipping population")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
log.Debug().
|
||||
Int("nodes", len(state.Nodes)).
|
||||
Int("vms", len(state.VMs)).
|
||||
|
|
@ -7488,7 +7498,7 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) {
|
|||
Int("hosts", len(state.Hosts)).
|
||||
Int("dockerHosts", len(state.DockerHosts)).
|
||||
Msg("[Resources] Populating resource store from state snapshot")
|
||||
|
||||
|
||||
store.PopulateFromSnapshot(state)
|
||||
}
|
||||
|
||||
|
|
@ -7498,18 +7508,18 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
m.mu.RLock()
|
||||
store := m.resourceStore
|
||||
m.mu.RUnlock()
|
||||
|
||||
|
||||
if store == nil {
|
||||
log.Debug().Msg("[Resources] No store for broadcast")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
allResources := store.GetAll()
|
||||
log.Debug().Int("count", len(allResources)).Msg("[Resources] Got resources for broadcast")
|
||||
if len(allResources) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
result := make([]models.ResourceFrontend, len(allResources))
|
||||
for i, r := range allResources {
|
||||
input := models.ResourceConvertInput{
|
||||
|
|
@ -7529,7 +7539,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
Labels: r.Labels,
|
||||
LastSeenUnix: r.LastSeen.UnixMilli(),
|
||||
}
|
||||
|
||||
|
||||
// Convert metrics
|
||||
if r.CPU != nil {
|
||||
input.CPU = &models.ResourceMetricInput{
|
||||
|
|
@ -7560,7 +7570,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
input.NetworkRX = r.Network.RXBytes
|
||||
input.NetworkTX = r.Network.TXBytes
|
||||
}
|
||||
|
||||
|
||||
// Convert alerts
|
||||
if len(r.Alerts) > 0 {
|
||||
input.Alerts = make([]models.ResourceAlertInput, len(r.Alerts))
|
||||
|
|
@ -7576,7 +7586,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Convert identity
|
||||
if r.Identity != nil {
|
||||
input.Identity = &models.ResourceIdentityInput{
|
||||
|
|
@ -7585,7 +7595,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
IPs: r.Identity.IPs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Convert platform data from json.RawMessage to map
|
||||
if len(r.PlatformData) > 0 {
|
||||
var platformMap map[string]any
|
||||
|
|
@ -7593,10 +7603,10 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
input.PlatformData = platformMap
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
result[i] = models.ConvertResourceToFrontend(input)
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,13 +169,13 @@ func FromVM(vm models.VM) Resource {
|
|||
CPU: &MetricValue{
|
||||
Current: vm.CPU * 100, // VM CPU is 0-1, convert to percentage
|
||||
},
|
||||
Memory: memory,
|
||||
Disk: disk,
|
||||
Network: network,
|
||||
Uptime: &vm.Uptime,
|
||||
Tags: vm.Tags,
|
||||
LastSeen: vm.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
Memory: memory,
|
||||
Disk: disk,
|
||||
Network: network,
|
||||
Uptime: &vm.Uptime,
|
||||
Tags: vm.Tags,
|
||||
LastSeen: vm.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
|
@ -255,13 +255,13 @@ func FromContainer(ct models.Container) Resource {
|
|||
CPU: &MetricValue{
|
||||
Current: ct.CPU * 100, // Container CPU is 0-1, convert to percentage
|
||||
},
|
||||
Memory: memory,
|
||||
Disk: disk,
|
||||
Network: network,
|
||||
Uptime: &ct.Uptime,
|
||||
Tags: ct.Tags,
|
||||
LastSeen: ct.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
Memory: memory,
|
||||
Disk: disk,
|
||||
Network: network,
|
||||
Uptime: &ct.Uptime,
|
||||
Tags: ct.Tags,
|
||||
LastSeen: ct.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
|
@ -347,14 +347,14 @@ func FromHost(h models.Host) Resource {
|
|||
diskIO := make([]DiskIOStats, len(h.DiskIO))
|
||||
for i, d := range h.DiskIO {
|
||||
diskIO[i] = DiskIOStats{
|
||||
Device: d.Device,
|
||||
ReadBytes: d.ReadBytes,
|
||||
WriteBytes: d.WriteBytes,
|
||||
ReadOps: d.ReadOps,
|
||||
WriteOps: d.WriteOps,
|
||||
ReadTimeMs: d.ReadTime,
|
||||
Device: d.Device,
|
||||
ReadBytes: d.ReadBytes,
|
||||
WriteBytes: d.WriteBytes,
|
||||
ReadOps: d.ReadOps,
|
||||
WriteOps: d.WriteOps,
|
||||
ReadTimeMs: d.ReadTime,
|
||||
WriteTimeMs: d.WriteTime,
|
||||
IOTimeMs: d.IOTime,
|
||||
IOTimeMs: d.IOTime,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -678,11 +678,217 @@ func FromDockerContainer(dc models.DockerContainer, hostID, hostName string) Res
|
|||
CPU: &MetricValue{
|
||||
Current: dc.CPUPercent,
|
||||
},
|
||||
Memory: memory,
|
||||
Uptime: &dc.UptimeSeconds,
|
||||
Labels: dc.Labels,
|
||||
LastSeen: time.Now(), // Containers don't have their own LastSeen
|
||||
PlatformData: platformDataJSON,
|
||||
Memory: memory,
|
||||
Uptime: &dc.UptimeSeconds,
|
||||
Labels: dc.Labels,
|
||||
LastSeen: time.Now(), // Containers don't have their own LastSeen
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// FromKubernetesCluster converts a KubernetesCluster to a unified Resource.
|
||||
func FromKubernetesCluster(cluster models.KubernetesCluster) Resource {
|
||||
platformData := KubernetesClusterPlatformData{
|
||||
AgentID: cluster.AgentID,
|
||||
Server: cluster.Server,
|
||||
Context: cluster.Context,
|
||||
Version: cluster.Version,
|
||||
CustomDisplayName: cluster.CustomDisplayName,
|
||||
Hidden: cluster.Hidden,
|
||||
PendingUninstall: cluster.PendingUninstall,
|
||||
NodeCount: len(cluster.Nodes),
|
||||
PodCount: len(cluster.Pods),
|
||||
DeploymentCount: len(cluster.Deployments),
|
||||
TokenID: cluster.TokenID,
|
||||
TokenName: cluster.TokenName,
|
||||
TokenHint: cluster.TokenHint,
|
||||
TokenLastUsedAt: cluster.TokenLastUsedAt,
|
||||
}
|
||||
platformDataJSON, _ := json.Marshal(platformData)
|
||||
|
||||
displayName := strings.TrimSpace(cluster.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(cluster.Name)
|
||||
}
|
||||
if cluster.CustomDisplayName != "" {
|
||||
displayName = cluster.CustomDisplayName
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = cluster.ID
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(cluster.Name)
|
||||
if name == "" {
|
||||
name = displayName
|
||||
}
|
||||
|
||||
return Resource{
|
||||
ID: cluster.ID,
|
||||
Type: ResourceTypeK8sCluster,
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
PlatformID: cluster.AgentID,
|
||||
PlatformType: PlatformKubernetes,
|
||||
SourceType: SourceAgent,
|
||||
Status: mapKubernetesClusterStatus(cluster.Status),
|
||||
LastSeen: cluster.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// FromKubernetesNode converts a KubernetesNode to a unified Resource.
|
||||
func FromKubernetesNode(node models.KubernetesNode, cluster models.KubernetesCluster) Resource {
|
||||
platformData := KubernetesNodePlatformData{
|
||||
ClusterID: cluster.ID,
|
||||
Ready: node.Ready,
|
||||
Unschedulable: node.Unschedulable,
|
||||
KubeletVersion: node.KubeletVersion,
|
||||
ContainerRuntimeVersion: node.ContainerRuntimeVersion,
|
||||
OSImage: node.OSImage,
|
||||
KernelVersion: node.KernelVersion,
|
||||
Architecture: node.Architecture,
|
||||
CapacityCPUCores: node.CapacityCPU,
|
||||
CapacityMemoryBytes: node.CapacityMemoryBytes,
|
||||
CapacityPods: node.CapacityPods,
|
||||
AllocatableCPUCores: node.AllocCPU,
|
||||
AllocatableMemoryBytes: node.AllocMemoryBytes,
|
||||
AllocatablePods: node.AllocPods,
|
||||
Roles: append([]string(nil), node.Roles...),
|
||||
}
|
||||
platformDataJSON, _ := json.Marshal(platformData)
|
||||
|
||||
nodeID := strings.TrimSpace(node.UID)
|
||||
if nodeID == "" {
|
||||
nodeID = node.Name
|
||||
}
|
||||
resourceID := fmt.Sprintf("%s/node/%s", cluster.ID, nodeID)
|
||||
|
||||
status := StatusUnknown
|
||||
if node.Ready {
|
||||
status = StatusOnline
|
||||
} else {
|
||||
status = StatusOffline
|
||||
}
|
||||
if node.Unschedulable && status == StatusOnline {
|
||||
status = StatusDegraded
|
||||
}
|
||||
|
||||
return Resource{
|
||||
ID: resourceID,
|
||||
Type: ResourceTypeK8sNode,
|
||||
Name: node.Name,
|
||||
PlatformID: cluster.ID,
|
||||
PlatformType: PlatformKubernetes,
|
||||
SourceType: SourceAgent,
|
||||
ParentID: cluster.ID,
|
||||
Status: status,
|
||||
LastSeen: cluster.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// FromKubernetesPod converts a KubernetesPod to a unified Resource.
|
||||
func FromKubernetesPod(pod models.KubernetesPod, cluster models.KubernetesCluster) Resource {
|
||||
containers := make([]KubernetesPodContainerInfo, 0, len(pod.Containers))
|
||||
for _, c := range pod.Containers {
|
||||
containers = append(containers, KubernetesPodContainerInfo{
|
||||
Name: c.Name,
|
||||
Image: c.Image,
|
||||
Ready: c.Ready,
|
||||
RestartCount: c.RestartCount,
|
||||
State: c.State,
|
||||
Reason: c.Reason,
|
||||
Message: c.Message,
|
||||
})
|
||||
}
|
||||
|
||||
platformData := KubernetesPodPlatformData{
|
||||
ClusterID: cluster.ID,
|
||||
Namespace: pod.Namespace,
|
||||
NodeName: pod.NodeName,
|
||||
Phase: pod.Phase,
|
||||
Reason: pod.Reason,
|
||||
Message: pod.Message,
|
||||
QoSClass: pod.QoSClass,
|
||||
Restarts: pod.Restarts,
|
||||
OwnerKind: pod.OwnerKind,
|
||||
OwnerName: pod.OwnerName,
|
||||
Containers: containers,
|
||||
}
|
||||
platformDataJSON, _ := json.Marshal(platformData)
|
||||
|
||||
podID := strings.TrimSpace(pod.UID)
|
||||
if podID == "" {
|
||||
podID = fmt.Sprintf("%s/%s", pod.Namespace, pod.Name)
|
||||
}
|
||||
resourceID := fmt.Sprintf("%s/pod/%s", cluster.ID, podID)
|
||||
|
||||
parentID := cluster.ID
|
||||
if strings.TrimSpace(pod.NodeName) != "" {
|
||||
parentID = fmt.Sprintf("%s/node/%s", cluster.ID, pod.NodeName)
|
||||
}
|
||||
|
||||
lastSeen := cluster.LastSeen
|
||||
|
||||
return Resource{
|
||||
ID: resourceID,
|
||||
Type: ResourceTypePod,
|
||||
Name: pod.Name,
|
||||
PlatformID: cluster.ID,
|
||||
PlatformType: PlatformKubernetes,
|
||||
SourceType: SourceAgent,
|
||||
ParentID: parentID,
|
||||
Status: mapKubernetesPodStatus(pod.Phase),
|
||||
Labels: pod.Labels,
|
||||
LastSeen: lastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// FromKubernetesDeployment converts a KubernetesDeployment to a unified Resource.
|
||||
func FromKubernetesDeployment(dep models.KubernetesDeployment, cluster models.KubernetesCluster) Resource {
|
||||
platformData := KubernetesDeploymentPlatformData{
|
||||
ClusterID: cluster.ID,
|
||||
Namespace: dep.Namespace,
|
||||
DesiredReplicas: dep.DesiredReplicas,
|
||||
UpdatedReplicas: dep.UpdatedReplicas,
|
||||
ReadyReplicas: dep.ReadyReplicas,
|
||||
AvailableReplicas: dep.AvailableReplicas,
|
||||
}
|
||||
platformDataJSON, _ := json.Marshal(platformData)
|
||||
|
||||
depID := strings.TrimSpace(dep.UID)
|
||||
if depID == "" {
|
||||
depID = fmt.Sprintf("%s/%s", dep.Namespace, dep.Name)
|
||||
}
|
||||
resourceID := fmt.Sprintf("%s/deployment/%s", cluster.ID, depID)
|
||||
|
||||
status := StatusUnknown
|
||||
switch {
|
||||
case dep.DesiredReplicas == 0:
|
||||
status = StatusStopped
|
||||
case dep.AvailableReplicas >= dep.DesiredReplicas:
|
||||
status = StatusRunning
|
||||
case dep.AvailableReplicas > 0:
|
||||
status = StatusDegraded
|
||||
}
|
||||
|
||||
return Resource{
|
||||
ID: resourceID,
|
||||
Type: ResourceTypeK8sDeployment,
|
||||
Name: dep.Name,
|
||||
PlatformID: cluster.ID,
|
||||
PlatformType: PlatformKubernetes,
|
||||
SourceType: SourceAgent,
|
||||
ParentID: cluster.ID,
|
||||
Status: status,
|
||||
Labels: dep.Labels,
|
||||
LastSeen: cluster.LastSeen,
|
||||
PlatformData: platformDataJSON,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
}
|
||||
}
|
||||
|
|
@ -840,6 +1046,30 @@ func mapDockerContainerStatus(state string) ResourceStatus {
|
|||
}
|
||||
}
|
||||
|
||||
func mapKubernetesClusterStatus(status string) ResourceStatus {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "online":
|
||||
return StatusOnline
|
||||
case "offline":
|
||||
return StatusOffline
|
||||
default:
|
||||
return StatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func mapKubernetesPodStatus(phase string) ResourceStatus {
|
||||
switch strings.ToLower(strings.TrimSpace(phase)) {
|
||||
case "running":
|
||||
return StatusRunning
|
||||
case "succeeded", "failed":
|
||||
return StatusStopped
|
||||
case "pending":
|
||||
return StatusUnknown
|
||||
default:
|
||||
return StatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func mapPBSStatus(status, connectionHealth string) ResourceStatus {
|
||||
if connectionHealth != "healthy" {
|
||||
return StatusDegraded
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ type VMPlatformData struct {
|
|||
// Stored in Resource.PlatformData when Type is ResourceTypeContainer or ResourceTypeOCIContainer.
|
||||
type ContainerPlatformData struct {
|
||||
VMID int `json:"vmid"`
|
||||
Node string `json:"node"` // Proxmox node hosting this container
|
||||
Instance string `json:"instance"` // Proxmox instance URL
|
||||
Type string `json:"type,omitempty"` // lxc or oci
|
||||
CPUs int `json:"cpus"` // Number of vCPUs
|
||||
Node string `json:"node"` // Proxmox node hosting this container
|
||||
Instance string `json:"instance"` // Proxmox instance URL
|
||||
Type string `json:"type,omitempty"` // lxc or oci
|
||||
CPUs int `json:"cpus"` // Number of vCPUs
|
||||
Template bool `json:"template"`
|
||||
Lock string `json:"lock,omitempty"`
|
||||
OSName string `json:"osName,omitempty"`
|
||||
|
|
@ -81,20 +81,20 @@ type ContainerPlatformData struct {
|
|||
// HostPlatformData contains host-agent specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeHost.
|
||||
type HostPlatformData struct {
|
||||
Platform string `json:"platform,omitempty"` // linux, windows, darwin
|
||||
OSName string `json:"osName,omitempty"` // e.g., "Ubuntu 22.04"
|
||||
OSVersion string `json:"osVersion,omitempty"` // OS version string
|
||||
KernelVersion string `json:"kernelVersion,omitempty"` // Kernel version
|
||||
Architecture string `json:"architecture,omitempty"` // amd64, arm64, etc.
|
||||
CPUCount int `json:"cpuCount,omitempty"` // Number of CPUs
|
||||
LoadAverage []float64 `json:"loadAverage,omitempty"` // 1, 5, 15 minute loads
|
||||
AgentVersion string `json:"agentVersion,omitempty"` // Pulse agent version
|
||||
IsLegacy bool `json:"isLegacy,omitempty"` // Legacy agent indicator
|
||||
Sensors HostSensorSummary `json:"sensors,omitempty"` // Temperature/fan sensors
|
||||
RAID []HostRAIDArray `json:"raid,omitempty"` // RAID arrays
|
||||
DiskIO []DiskIOStats `json:"diskIO,omitempty"` // Per-disk I/O stats
|
||||
Disks []DiskInfo `json:"disks,omitempty"` // Disk usage info
|
||||
Interfaces []NetworkInterface `json:"interfaces,omitempty"` // Network interfaces
|
||||
Platform string `json:"platform,omitempty"` // linux, windows, darwin
|
||||
OSName string `json:"osName,omitempty"` // e.g., "Ubuntu 22.04"
|
||||
OSVersion string `json:"osVersion,omitempty"` // OS version string
|
||||
KernelVersion string `json:"kernelVersion,omitempty"` // Kernel version
|
||||
Architecture string `json:"architecture,omitempty"` // amd64, arm64, etc.
|
||||
CPUCount int `json:"cpuCount,omitempty"` // Number of CPUs
|
||||
LoadAverage []float64 `json:"loadAverage,omitempty"` // 1, 5, 15 minute loads
|
||||
AgentVersion string `json:"agentVersion,omitempty"` // Pulse agent version
|
||||
IsLegacy bool `json:"isLegacy,omitempty"` // Legacy agent indicator
|
||||
Sensors HostSensorSummary `json:"sensors,omitempty"` // Temperature/fan sensors
|
||||
RAID []HostRAIDArray `json:"raid,omitempty"` // RAID arrays
|
||||
DiskIO []DiskIOStats `json:"diskIO,omitempty"` // Per-disk I/O stats
|
||||
Disks []DiskInfo `json:"disks,omitempty"` // Disk usage info
|
||||
Interfaces []NetworkInterface `json:"interfaces,omitempty"` // Network interfaces
|
||||
|
||||
// Token information
|
||||
TokenID string `json:"tokenId,omitempty"`
|
||||
|
|
@ -136,14 +136,14 @@ type HostRAIDDevice struct {
|
|||
|
||||
// DiskIOStats captures I/O statistics for a disk device.
|
||||
type DiskIOStats struct {
|
||||
Device string `json:"device"`
|
||||
ReadBytes uint64 `json:"readBytes,omitempty"`
|
||||
WriteBytes uint64 `json:"writeBytes,omitempty"`
|
||||
ReadOps uint64 `json:"readOps,omitempty"`
|
||||
WriteOps uint64 `json:"writeOps,omitempty"`
|
||||
ReadTimeMs uint64 `json:"readTimeMs,omitempty"`
|
||||
Device string `json:"device"`
|
||||
ReadBytes uint64 `json:"readBytes,omitempty"`
|
||||
WriteBytes uint64 `json:"writeBytes,omitempty"`
|
||||
ReadOps uint64 `json:"readOps,omitempty"`
|
||||
WriteOps uint64 `json:"writeOps,omitempty"`
|
||||
ReadTimeMs uint64 `json:"readTimeMs,omitempty"`
|
||||
WriteTimeMs uint64 `json:"writeTimeMs,omitempty"`
|
||||
IOTimeMs uint64 `json:"ioTimeMs,omitempty"`
|
||||
IOTimeMs uint64 `json:"ioTimeMs,omitempty"`
|
||||
}
|
||||
|
||||
// DiskInfo represents disk/partition usage.
|
||||
|
|
@ -170,23 +170,23 @@ type NetworkInterface struct {
|
|||
// DockerHostPlatformData contains Docker host-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeDockerHost.
|
||||
type DockerHostPlatformData struct {
|
||||
AgentID string `json:"agentId"`
|
||||
MachineID string `json:"machineId,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Runtime string `json:"runtime,omitempty"` // docker, podman
|
||||
RuntimeVersion string `json:"runtimeVersion,omitempty"`
|
||||
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
CPUs int `json:"cpus"`
|
||||
IsLegacy bool `json:"isLegacy,omitempty"`
|
||||
Disks []DiskInfo `json:"disks,omitempty"`
|
||||
Interfaces []NetworkInterface `json:"interfaces,omitempty"`
|
||||
CustomDisplayName string `json:"customDisplayName,omitempty"`
|
||||
Hidden bool `json:"hidden"`
|
||||
PendingUninstall bool `json:"pendingUninstall"`
|
||||
AgentID string `json:"agentId"`
|
||||
MachineID string `json:"machineId,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Runtime string `json:"runtime,omitempty"` // docker, podman
|
||||
RuntimeVersion string `json:"runtimeVersion,omitempty"`
|
||||
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
CPUs int `json:"cpus"`
|
||||
IsLegacy bool `json:"isLegacy,omitempty"`
|
||||
Disks []DiskInfo `json:"disks,omitempty"`
|
||||
Interfaces []NetworkInterface `json:"interfaces,omitempty"`
|
||||
CustomDisplayName string `json:"customDisplayName,omitempty"`
|
||||
Hidden bool `json:"hidden"`
|
||||
PendingUninstall bool `json:"pendingUninstall"`
|
||||
|
||||
// Swarm information
|
||||
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
|
||||
|
|
@ -213,25 +213,107 @@ type DockerSwarmInfo struct {
|
|||
// DockerContainerPlatformData contains Docker container-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeDockerContainer.
|
||||
type DockerContainerPlatformData struct {
|
||||
HostID string `json:"hostId"` // Parent Docker host ID
|
||||
HostName string `json:"hostName"` // Parent Docker host name
|
||||
Image string `json:"image"` // Container image
|
||||
State string `json:"state"` // created, running, paused, restarting, exited, dead
|
||||
Status string `json:"status"` // Human-readable status
|
||||
Health string `json:"health"` // healthy, unhealthy, starting, none
|
||||
RestartCount int `json:"restartCount"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Ports []ContainerPort `json:"ports,omitempty"`
|
||||
Networks []ContainerNetwork `json:"networks,omitempty"`
|
||||
HostID string `json:"hostId"` // Parent Docker host ID
|
||||
HostName string `json:"hostName"` // Parent Docker host name
|
||||
Image string `json:"image"` // Container image
|
||||
State string `json:"state"` // created, running, paused, restarting, exited, dead
|
||||
Status string `json:"status"` // Human-readable status
|
||||
Health string `json:"health"` // healthy, unhealthy, starting, none
|
||||
RestartCount int `json:"restartCount"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Ports []ContainerPort `json:"ports,omitempty"`
|
||||
Networks []ContainerNetwork `json:"networks,omitempty"`
|
||||
|
||||
// Podman-specific
|
||||
Podman *PodmanContainerInfo `json:"podman,omitempty"`
|
||||
}
|
||||
|
||||
// KubernetesClusterPlatformData contains Kubernetes cluster-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeK8sCluster.
|
||||
type KubernetesClusterPlatformData struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
CustomDisplayName string `json:"customDisplayName,omitempty"`
|
||||
Hidden bool `json:"hidden"`
|
||||
PendingUninstall bool `json:"pendingUninstall"`
|
||||
|
||||
NodeCount int `json:"nodeCount,omitempty"`
|
||||
PodCount int `json:"podCount,omitempty"`
|
||||
DeploymentCount int `json:"deploymentCount,omitempty"`
|
||||
|
||||
// Token information
|
||||
TokenID string `json:"tokenId,omitempty"`
|
||||
TokenName string `json:"tokenName,omitempty"`
|
||||
TokenHint string `json:"tokenHint,omitempty"`
|
||||
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
// KubernetesNodePlatformData contains Kubernetes node-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeK8sNode.
|
||||
type KubernetesNodePlatformData struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
Ready bool `json:"ready"`
|
||||
Unschedulable bool `json:"unschedulable,omitempty"`
|
||||
KubeletVersion string `json:"kubeletVersion,omitempty"`
|
||||
ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"`
|
||||
OSImage string `json:"osImage,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
CapacityCPUCores int64 `json:"capacityCpuCores,omitempty"`
|
||||
CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"`
|
||||
CapacityPods int64 `json:"capacityPods,omitempty"`
|
||||
AllocatableCPUCores int64 `json:"allocatableCpuCores,omitempty"`
|
||||
AllocatableMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"`
|
||||
AllocatablePods int64 `json:"allocatablePods,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
// KubernetesPodPlatformData contains Kubernetes pod-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypePod.
|
||||
type KubernetesPodPlatformData struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
Namespace string `json:"namespace"`
|
||||
NodeName string `json:"nodeName,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
QoSClass string `json:"qosClass,omitempty"`
|
||||
Restarts int `json:"restarts,omitempty"`
|
||||
|
||||
OwnerKind string `json:"ownerKind,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
|
||||
Containers []KubernetesPodContainerInfo `json:"containers,omitempty"`
|
||||
}
|
||||
|
||||
// KubernetesPodContainerInfo captures per-container pod status.
|
||||
type KubernetesPodContainerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
RestartCount int32 `json:"restartCount,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// KubernetesDeploymentPlatformData contains Kubernetes deployment-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeK8sDeployment.
|
||||
type KubernetesDeploymentPlatformData struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
Namespace string `json:"namespace"`
|
||||
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
||||
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
|
||||
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
||||
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
|
||||
}
|
||||
|
||||
// ContainerPort describes a port mapping.
|
||||
type ContainerPort struct {
|
||||
PrivatePort int `json:"privatePort"`
|
||||
|
|
@ -280,12 +362,12 @@ type DatastorePlatformData struct {
|
|||
// StoragePlatformData contains Proxmox storage-specific fields.
|
||||
// Stored in Resource.PlatformData when Type is ResourceTypeStorage.
|
||||
type StoragePlatformData struct {
|
||||
Instance string `json:"instance"`
|
||||
Node string `json:"node"` // Primary node
|
||||
Nodes []string `json:"nodes,omitempty"` // All nodes (for shared storage)
|
||||
Type string `json:"type"` // zfspool, lvmthin, cephfs, etc.
|
||||
Content string `json:"content"`
|
||||
Shared bool `json:"shared"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
Instance string `json:"instance"`
|
||||
Node string `json:"node"` // Primary node
|
||||
Nodes []string `json:"nodes,omitempty"` // All nodes (for shared storage)
|
||||
Type string `json:"type"` // zfspool, lvmthin, cephfs, etc.
|
||||
Content string `json:"content"`
|
||||
Shared bool `json:"shared"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ type Resource struct {
|
|||
ClusterID string `json:"clusterId,omitempty"` // Cluster membership
|
||||
|
||||
// Universal Metrics (nullable - not all resources have all metrics)
|
||||
Status ResourceStatus `json:"status"` // online, offline, running, stopped, degraded
|
||||
CPU *MetricValue `json:"cpu,omitempty"` // CPU usage percentage
|
||||
Memory *MetricValue `json:"memory,omitempty"` // Memory usage
|
||||
Disk *MetricValue `json:"disk,omitempty"` // Primary disk usage
|
||||
Network *NetworkMetric `json:"network,omitempty"` // Network I/O
|
||||
Status ResourceStatus `json:"status"` // online, offline, running, stopped, degraded
|
||||
CPU *MetricValue `json:"cpu,omitempty"` // CPU usage percentage
|
||||
Memory *MetricValue `json:"memory,omitempty"` // Memory usage
|
||||
Disk *MetricValue `json:"disk,omitempty"` // Primary disk usage
|
||||
Network *NetworkMetric `json:"network,omitempty"` // Network I/O
|
||||
Temperature *float64 `json:"temperature,omitempty"` // Temperature in Celsius
|
||||
Uptime *int64 `json:"uptime,omitempty"` // Uptime in seconds
|
||||
Uptime *int64 `json:"uptime,omitempty"` // Uptime in seconds
|
||||
|
||||
// Universal Metadata
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
|
|
@ -69,6 +69,7 @@ const (
|
|||
ResourceTypeNode ResourceType = "node" // Proxmox VE node
|
||||
ResourceTypeHost ResourceType = "host" // Standalone host (via host-agent)
|
||||
ResourceTypeDockerHost ResourceType = "docker-host" // Docker/Podman host
|
||||
ResourceTypeK8sCluster ResourceType = "k8s-cluster" // Kubernetes cluster
|
||||
ResourceTypeK8sNode ResourceType = "k8s-node" // Kubernetes node
|
||||
ResourceTypeTrueNAS ResourceType = "truenas" // TrueNAS system
|
||||
|
||||
|
|
@ -148,8 +149,8 @@ type NetworkMetric struct {
|
|||
// ResourceAlert represents an alert associated with a resource.
|
||||
type ResourceAlert struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // cpu, memory, disk, temperature, etc.
|
||||
Level string `json:"level"` // warning, critical
|
||||
Type string `json:"type"` // cpu, memory, disk, temperature, etc.
|
||||
Level string `json:"level"` // warning, critical
|
||||
Message string `json:"message"`
|
||||
Value float64 `json:"value"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
|
|
@ -191,7 +192,7 @@ func (r *Resource) SetPlatformData(v interface{}) error {
|
|||
// (node, host, docker-host) rather than a workload (vm, container).
|
||||
func (r *Resource) IsInfrastructure() bool {
|
||||
switch r.Type {
|
||||
case ResourceTypeNode, ResourceTypeHost, ResourceTypeDockerHost, ResourceTypeK8sNode, ResourceTypeTrueNAS:
|
||||
case ResourceTypeNode, ResourceTypeHost, ResourceTypeDockerHost, ResourceTypeK8sCluster, ResourceTypeK8sNode, ResourceTypeTrueNAS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ import (
|
|||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
resources map[string]*Resource // Keyed by Resource.ID
|
||||
|
||||
|
||||
// Index by identity for deduplication
|
||||
byHostname map[string][]string // hostname (lower) -> resource IDs
|
||||
byMachineID map[string]string // machine-id -> resource ID
|
||||
byIP map[string][]string // IP -> resource IDs
|
||||
|
||||
|
||||
// Track merged resources (one source is preferred over another)
|
||||
mergedFrom map[string]string // suppressed ID -> preferred ID
|
||||
}
|
||||
|
|
@ -38,14 +38,14 @@ func NewStore() *Store {
|
|||
func (s *Store) Upsert(r Resource) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
|
||||
// Check for duplicates
|
||||
if r.Identity != nil {
|
||||
if existingID := s.findDuplicate(&r); existingID != "" {
|
||||
// Found a duplicate - determine which to prefer
|
||||
existing := s.resources[existingID]
|
||||
preferred := s.preferredResource(existing, &r)
|
||||
|
||||
|
||||
if preferred == &r {
|
||||
// New resource is preferred, replace the old one
|
||||
s.removeFromIndexes(existing)
|
||||
|
|
@ -58,11 +58,11 @@ func (s *Store) Upsert(r Resource) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add/update the resource
|
||||
s.resources[r.ID] = &r
|
||||
s.addToIndexes(&r)
|
||||
|
||||
|
||||
return r.ID
|
||||
}
|
||||
|
||||
|
|
@ -70,13 +70,13 @@ func (s *Store) Upsert(r Resource) string {
|
|||
func (s *Store) Get(id string) (*Resource, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
// Check if this ID was merged into another
|
||||
if preferredID, merged := s.mergedFrom[id]; merged {
|
||||
r, ok := s.resources[preferredID]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
|
||||
r, ok := s.resources[id]
|
||||
return r, ok
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ func (s *Store) Get(id string) (*Resource, bool) {
|
|||
func (s *Store) GetAll() []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
result := make([]Resource, 0, len(s.resources))
|
||||
for _, r := range s.resources {
|
||||
result = append(result, *r)
|
||||
|
|
@ -97,7 +97,7 @@ func (s *Store) GetAll() []Resource {
|
|||
func (s *Store) GetByType(t ResourceType) []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var result []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.Type == t {
|
||||
|
|
@ -111,7 +111,7 @@ func (s *Store) GetByType(t ResourceType) []Resource {
|
|||
func (s *Store) GetByPlatform(p PlatformType) []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var result []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.PlatformType == p {
|
||||
|
|
@ -125,7 +125,7 @@ func (s *Store) GetByPlatform(p PlatformType) []Resource {
|
|||
func (s *Store) GetInfrastructure() []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var result []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.IsInfrastructure() {
|
||||
|
|
@ -139,7 +139,7 @@ func (s *Store) GetInfrastructure() []Resource {
|
|||
func (s *Store) GetWorkloads() []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var result []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.IsWorkload() {
|
||||
|
|
@ -153,7 +153,7 @@ func (s *Store) GetWorkloads() []Resource {
|
|||
func (s *Store) GetChildren(parentID string) []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var result []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.ParentID == parentID {
|
||||
|
|
@ -170,13 +170,13 @@ func (s *Store) GetChildren(parentID string) []Resource {
|
|||
func (s *Store) FindContainerHost(containerNameOrID string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
if containerNameOrID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
containerNameLower := strings.ToLower(containerNameOrID)
|
||||
|
||||
|
||||
// Find the container
|
||||
var container *Resource
|
||||
for _, r := range s.resources {
|
||||
|
|
@ -185,24 +185,24 @@ func (s *Store) FindContainerHost(containerNameOrID string) string {
|
|||
}
|
||||
// Match by name or ID (case-insensitive)
|
||||
if strings.EqualFold(r.Name, containerNameOrID) ||
|
||||
strings.EqualFold(r.ID, containerNameOrID) ||
|
||||
strings.Contains(strings.ToLower(r.Name), containerNameLower) ||
|
||||
strings.Contains(strings.ToLower(r.ID), containerNameLower) {
|
||||
strings.EqualFold(r.ID, containerNameOrID) ||
|
||||
strings.Contains(strings.ToLower(r.Name), containerNameLower) ||
|
||||
strings.Contains(strings.ToLower(r.ID), containerNameLower) {
|
||||
container = r
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if container == nil || container.ParentID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// Find the parent DockerHost
|
||||
parent := s.resources[container.ParentID]
|
||||
if parent == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// Return the hostname from identity, or the name
|
||||
if parent.Identity != nil && parent.Identity.Hostname != "" {
|
||||
return parent.Identity.Hostname
|
||||
|
|
@ -210,17 +210,16 @@ func (s *Store) FindContainerHost(containerNameOrID string) string {
|
|||
return parent.Name
|
||||
}
|
||||
|
||||
|
||||
// Remove removes a resource from the store.
|
||||
func (s *Store) Remove(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
|
||||
if r, ok := s.resources[id]; ok {
|
||||
s.removeFromIndexes(r)
|
||||
delete(s.resources, id)
|
||||
}
|
||||
|
||||
|
||||
// Also clean up any merge references
|
||||
delete(s.mergedFrom, id)
|
||||
for k, v := range s.mergedFrom {
|
||||
|
|
@ -234,7 +233,7 @@ func (s *Store) Remove(id string) {
|
|||
func (s *Store) IsSuppressed(id string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
_, suppressed := s.mergedFrom[id]
|
||||
return suppressed
|
||||
}
|
||||
|
|
@ -243,7 +242,7 @@ func (s *Store) IsSuppressed(id string) bool {
|
|||
func (s *Store) GetPreferredID(id string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
if preferredID, ok := s.mergedFrom[id]; ok {
|
||||
return preferredID
|
||||
}
|
||||
|
|
@ -254,7 +253,7 @@ func (s *Store) GetPreferredID(id string) string {
|
|||
func (s *Store) GetStats() StoreStats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
stats := StoreStats{
|
||||
TotalResources: len(s.resources),
|
||||
SuppressedResources: len(s.mergedFrom),
|
||||
|
|
@ -264,7 +263,7 @@ func (s *Store) GetStats() StoreStats {
|
|||
WithAlerts: 0,
|
||||
LastUpdated: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
|
||||
for _, r := range s.resources {
|
||||
stats.ByType[r.Type]++
|
||||
stats.ByPlatform[r.PlatformType]++
|
||||
|
|
@ -273,19 +272,19 @@ func (s *Store) GetStats() StoreStats {
|
|||
stats.WithAlerts++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// StoreStats contains statistics about the resource store.
|
||||
type StoreStats struct {
|
||||
TotalResources int `json:"totalResources"`
|
||||
SuppressedResources int `json:"suppressedResources"`
|
||||
ByType map[ResourceType]int `json:"byType"`
|
||||
ByPlatform map[PlatformType]int `json:"byPlatform"`
|
||||
ByStatus map[ResourceStatus]int `json:"byStatus"`
|
||||
WithAlerts int `json:"withAlerts"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
TotalResources int `json:"totalResources"`
|
||||
SuppressedResources int `json:"suppressedResources"`
|
||||
ByType map[ResourceType]int `json:"byType"`
|
||||
ByPlatform map[PlatformType]int `json:"byPlatform"`
|
||||
ByStatus map[ResourceStatus]int `json:"byStatus"`
|
||||
WithAlerts int `json:"withAlerts"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// GetPreferredResourceFor returns the preferred resource for a given ID.
|
||||
|
|
@ -294,14 +293,14 @@ type StoreStats struct {
|
|||
func (s *Store) GetPreferredResourceFor(resourceID string) *Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
// Check if this ID was merged
|
||||
if preferredID, merged := s.mergedFrom[resourceID]; merged {
|
||||
if r, ok := s.resources[preferredID]; ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Return the resource itself if it exists
|
||||
if r, ok := s.resources[resourceID]; ok {
|
||||
return r
|
||||
|
|
@ -314,23 +313,23 @@ func (s *Store) GetPreferredResourceFor(resourceID string) *Resource {
|
|||
func (s *Store) IsSamePhysicalMachine(id1, id2 string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
// Check if they're literally the same
|
||||
if id1 == id2 {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Check if both map to the same preferred resource
|
||||
preferred1 := id1
|
||||
if pid, merged := s.mergedFrom[id1]; merged {
|
||||
preferred1 = pid
|
||||
}
|
||||
|
||||
|
||||
preferred2 := id2
|
||||
if pid, merged := s.mergedFrom[id2]; merged {
|
||||
preferred2 = pid
|
||||
}
|
||||
|
||||
|
||||
return preferred1 == preferred2
|
||||
}
|
||||
|
||||
|
|
@ -341,16 +340,16 @@ func (s *Store) HasPreferredSourceForHostname(hostname string) bool {
|
|||
if hostname == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
hostnameLower := strings.ToLower(hostname)
|
||||
resourceIDs, exists := s.byHostname[hostnameLower]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Check if any resource with this hostname has a preferred source type
|
||||
for _, id := range resourceIDs {
|
||||
if r, ok := s.resources[id]; ok {
|
||||
|
|
@ -360,7 +359,7 @@ func (s *Store) HasPreferredSourceForHostname(hostname string) bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -369,7 +368,7 @@ func (s *Store) HasPreferredSourceForHostname(hostname string) bool {
|
|||
// These methods help reduce redundant API polling when agents are active
|
||||
// ============================================================================
|
||||
|
||||
// ShouldSkipAPIPolling returns true if API polling should be skipped for the
|
||||
// ShouldSkipAPIPolling returns true if API polling should be skipped for the
|
||||
// given hostname because an agent is providing richer, more frequent data.
|
||||
// This is useful for reducing load when both Proxmox API and host agents monitor
|
||||
// the same machine.
|
||||
|
|
@ -383,10 +382,10 @@ func (s *Store) ShouldSkipAPIPolling(hostname string) bool {
|
|||
func (s *Store) GetAgentMonitoredHostnames() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var hostnames []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
|
||||
for _, r := range s.resources {
|
||||
if r.SourceType != SourceAgent && r.SourceType != SourceHybrid {
|
||||
continue
|
||||
|
|
@ -400,7 +399,7 @@ func (s *Store) GetAgentMonitoredHostnames() []string {
|
|||
hostnames = append(hostnames, r.Identity.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return hostnames
|
||||
}
|
||||
|
||||
|
|
@ -412,15 +411,15 @@ func (s *Store) GetAgentMonitoredHostnames() []string {
|
|||
func (s *Store) GetPollingRecommendations() map[string]float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
recommendations := make(map[string]float64)
|
||||
|
||||
|
||||
for _, r := range s.resources {
|
||||
if r.Identity == nil || r.Identity.Hostname == "" {
|
||||
continue
|
||||
}
|
||||
hostname := strings.ToLower(r.Identity.Hostname)
|
||||
|
||||
|
||||
switch r.SourceType {
|
||||
case SourceAgent:
|
||||
// Agent provides all data - skip API polling for metrics
|
||||
|
|
@ -431,7 +430,7 @@ func (s *Store) GetPollingRecommendations() map[string]float64 {
|
|||
recommendations[hostname] = 0.5 // Poll at half frequency
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
|
|
@ -441,7 +440,7 @@ func (s *Store) findDuplicate(r *Resource) string {
|
|||
if r.Identity == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// 1. Machine ID match (most reliable) - but only for same type
|
||||
// A node and host agent on the same machine should coexist as different data sources
|
||||
if r.Identity.MachineID != "" && r.IsInfrastructure() {
|
||||
|
|
@ -453,7 +452,7 @@ func (s *Store) findDuplicate(r *Resource) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2. Hostname match (case-insensitive) - only for same infrastructure type
|
||||
// Workloads (VMs, containers) can have duplicate names across clusters
|
||||
if r.Identity.Hostname != "" && r.IsInfrastructure() {
|
||||
|
|
@ -471,7 +470,7 @@ func (s *Store) findDuplicate(r *Resource) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 3. IP overlap (if same non-localhost IP, likely same machine) - only for same infrastructure type
|
||||
if r.IsInfrastructure() {
|
||||
for _, ip := range r.Identity.IPs {
|
||||
|
|
@ -491,7 +490,7 @@ func (s *Store) findDuplicate(r *Resource) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -501,14 +500,14 @@ func (s *Store) preferredResource(a, b *Resource) *Resource {
|
|||
// Prefer agent over API
|
||||
aScore := s.sourceScore(a.SourceType)
|
||||
bScore := s.sourceScore(b.SourceType)
|
||||
|
||||
|
||||
if aScore > bScore {
|
||||
return a
|
||||
}
|
||||
if bScore > aScore {
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
// Same source type - prefer the one with more recent data
|
||||
if a.LastSeen.After(b.LastSeen) {
|
||||
return a
|
||||
|
|
@ -533,16 +532,16 @@ func (s *Store) addToIndexes(r *Resource) {
|
|||
if r.Identity == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if r.Identity.MachineID != "" {
|
||||
s.byMachineID[r.Identity.MachineID] = r.ID
|
||||
}
|
||||
|
||||
|
||||
if r.Identity.Hostname != "" {
|
||||
hostnameLower := strings.ToLower(r.Identity.Hostname)
|
||||
s.byHostname[hostnameLower] = append(s.byHostname[hostnameLower], r.ID)
|
||||
}
|
||||
|
||||
|
||||
for _, ip := range r.Identity.IPs {
|
||||
if !isNonUniqueIP(ip) {
|
||||
s.byIP[ip] = append(s.byIP[ip], r.ID)
|
||||
|
|
@ -554,13 +553,13 @@ func (s *Store) removeFromIndexes(r *Resource) {
|
|||
if r.Identity == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if r.Identity.MachineID != "" {
|
||||
if s.byMachineID[r.Identity.MachineID] == r.ID {
|
||||
delete(s.byMachineID, r.Identity.MachineID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if r.Identity.Hostname != "" {
|
||||
hostnameLower := strings.ToLower(r.Identity.Hostname)
|
||||
s.byHostname[hostnameLower] = removeFromSlice(s.byHostname[hostnameLower], r.ID)
|
||||
|
|
@ -568,7 +567,7 @@ func (s *Store) removeFromIndexes(r *Resource) {
|
|||
delete(s.byHostname, hostnameLower)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for _, ip := range r.Identity.IPs {
|
||||
s.byIP[ip] = removeFromSlice(s.byIP[ip], r.ID)
|
||||
if len(s.byIP[ip]) == 0 {
|
||||
|
|
@ -594,20 +593,20 @@ func isNonUniqueIP(ip string) bool {
|
|||
if ip == "127.0.0.1" || ip == "::1" || strings.HasPrefix(ip, "127.") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Docker bridge network - 172.17.0.1/16 exists on every Docker host
|
||||
// Also filter other Docker-assigned bridge networks (172.17-31.x.x)
|
||||
if strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") ||
|
||||
strings.HasPrefix(ip, "172.19.") || strings.HasPrefix(ip, "172.20.") ||
|
||||
strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") {
|
||||
if strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") ||
|
||||
strings.HasPrefix(ip, "172.19.") || strings.HasPrefix(ip, "172.20.") ||
|
||||
strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Link-local addresses (fe80::)
|
||||
if strings.HasPrefix(strings.ToLower(ip), "fe80:") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -615,10 +614,10 @@ func isNonUniqueIP(ip string) bool {
|
|||
func (s *Store) MarkStale(threshold time.Duration) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
|
||||
now := time.Now()
|
||||
var staleIDs []string
|
||||
|
||||
|
||||
for id, r := range s.resources {
|
||||
if now.Sub(r.LastSeen) > threshold {
|
||||
// Mark as offline/degraded
|
||||
|
|
@ -628,7 +627,7 @@ func (s *Store) MarkStale(threshold time.Duration) []string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return staleIDs
|
||||
}
|
||||
|
||||
|
|
@ -636,10 +635,10 @@ func (s *Store) MarkStale(threshold time.Duration) []string {
|
|||
func (s *Store) PruneStale(staleThreshold, removeThreshold time.Duration) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
|
||||
now := time.Now()
|
||||
var removedIDs []string
|
||||
|
||||
|
||||
for id, r := range s.resources {
|
||||
if now.Sub(r.LastSeen) > removeThreshold {
|
||||
s.removeFromIndexes(r)
|
||||
|
|
@ -647,7 +646,7 @@ func (s *Store) PruneStale(staleThreshold, removeThreshold time.Duration) []stri
|
|||
removedIDs = append(removedIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return removedIDs
|
||||
}
|
||||
|
||||
|
|
@ -658,17 +657,17 @@ func (s *Store) Query() *ResourceQuery {
|
|||
|
||||
// ResourceQuery provides a fluent query interface.
|
||||
type ResourceQuery struct {
|
||||
store *Store
|
||||
types []ResourceType
|
||||
platforms []PlatformType
|
||||
statuses []ResourceStatus
|
||||
parentID *string
|
||||
clusterID *string
|
||||
hasAlerts *bool
|
||||
sortBy string
|
||||
sortDesc bool
|
||||
limit int
|
||||
offset int
|
||||
store *Store
|
||||
types []ResourceType
|
||||
platforms []PlatformType
|
||||
statuses []ResourceStatus
|
||||
parentID *string
|
||||
clusterID *string
|
||||
hasAlerts *bool
|
||||
sortBy string
|
||||
sortDesc bool
|
||||
limit int
|
||||
offset int
|
||||
}
|
||||
|
||||
// OfType filters by resource types.
|
||||
|
|
@ -731,17 +730,17 @@ func (q *ResourceQuery) Offset(n int) *ResourceQuery {
|
|||
func (q *ResourceQuery) Execute() []Resource {
|
||||
q.store.mu.RLock()
|
||||
defer q.store.mu.RUnlock()
|
||||
|
||||
|
||||
var results []Resource
|
||||
|
||||
|
||||
for _, r := range q.store.resources {
|
||||
if q.matches(r) {
|
||||
results = append(results, *r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO: Implement sorting
|
||||
|
||||
|
||||
// Apply pagination
|
||||
if q.offset > 0 {
|
||||
if q.offset >= len(results) {
|
||||
|
|
@ -749,11 +748,11 @@ func (q *ResourceQuery) Execute() []Resource {
|
|||
}
|
||||
results = results[q.offset:]
|
||||
}
|
||||
|
||||
|
||||
if q.limit > 0 && q.limit < len(results) {
|
||||
results = results[:q.limit]
|
||||
}
|
||||
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
|
|
@ -761,7 +760,7 @@ func (q *ResourceQuery) Execute() []Resource {
|
|||
func (q *ResourceQuery) Count() int {
|
||||
q.store.mu.RLock()
|
||||
defer q.store.mu.RUnlock()
|
||||
|
||||
|
||||
count := 0
|
||||
for _, r := range q.store.resources {
|
||||
if q.matches(r) {
|
||||
|
|
@ -785,7 +784,7 @@ func (q *ResourceQuery) matches(r *Resource) bool {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Platform filter
|
||||
if len(q.platforms) > 0 {
|
||||
found := false
|
||||
|
|
@ -799,7 +798,7 @@ func (q *ResourceQuery) matches(r *Resource) bool {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Status filter
|
||||
if len(q.statuses) > 0 {
|
||||
found := false
|
||||
|
|
@ -813,22 +812,22 @@ func (q *ResourceQuery) matches(r *Resource) bool {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Parent filter
|
||||
if q.parentID != nil && r.ParentID != *q.parentID {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Cluster filter
|
||||
if q.clusterID != nil && r.ClusterID != *q.clusterID {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Alerts filter
|
||||
if q.hasAlerts != nil && *q.hasAlerts && len(r.Alerts) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -842,7 +841,7 @@ func (q *ResourceQuery) matches(r *Resource) bool {
|
|||
func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var resources []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.CPU == nil || r.CPU.Current == 0 {
|
||||
|
|
@ -862,7 +861,7 @@ func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource {
|
|||
}
|
||||
resources = append(resources, *r)
|
||||
}
|
||||
|
||||
|
||||
// Sort by CPU usage descending
|
||||
for i := 0; i < len(resources)-1; i++ {
|
||||
for j := i + 1; j < len(resources); j++ {
|
||||
|
|
@ -871,7 +870,7 @@ func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if limit > 0 && limit < len(resources) {
|
||||
return resources[:limit]
|
||||
}
|
||||
|
|
@ -883,7 +882,7 @@ func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource {
|
|||
func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var resources []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.Memory == nil || r.Memory.Current == 0 {
|
||||
|
|
@ -903,7 +902,7 @@ func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource {
|
|||
}
|
||||
resources = append(resources, *r)
|
||||
}
|
||||
|
||||
|
||||
// Sort by memory usage descending
|
||||
for i := 0; i < len(resources)-1; i++ {
|
||||
for j := i + 1; j < len(resources); j++ {
|
||||
|
|
@ -912,7 +911,7 @@ func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if limit > 0 && limit < len(resources) {
|
||||
return resources[:limit]
|
||||
}
|
||||
|
|
@ -924,7 +923,7 @@ func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource {
|
|||
func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
var resources []Resource
|
||||
for _, r := range s.resources {
|
||||
if r.Disk == nil || r.Disk.Current == 0 {
|
||||
|
|
@ -944,7 +943,7 @@ func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource {
|
|||
}
|
||||
resources = append(resources, *r)
|
||||
}
|
||||
|
||||
|
||||
// Sort by disk usage descending
|
||||
for i := 0; i < len(resources)-1; i++ {
|
||||
for j := i + 1; j < len(resources); j++ {
|
||||
|
|
@ -953,7 +952,7 @@ func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if limit > 0 && limit < len(resources) {
|
||||
return resources[:limit]
|
||||
}
|
||||
|
|
@ -965,21 +964,21 @@ func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource {
|
|||
func (s *Store) GetRelated(resourceID string) map[string][]Resource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
result := make(map[string][]Resource)
|
||||
|
||||
|
||||
r, ok := s.resources[resourceID]
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Get parent
|
||||
if r.ParentID != "" {
|
||||
if parent, ok := s.resources[r.ParentID]; ok {
|
||||
result["parent"] = []Resource{*parent}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get children
|
||||
var children []Resource
|
||||
for _, other := range s.resources {
|
||||
|
|
@ -990,7 +989,7 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource {
|
|||
if len(children) > 0 {
|
||||
result["children"] = children
|
||||
}
|
||||
|
||||
|
||||
// Get siblings (same parent)
|
||||
if r.ParentID != "" {
|
||||
var siblings []Resource
|
||||
|
|
@ -1003,7 +1002,7 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource {
|
|||
result["siblings"] = siblings
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get co-located resources (same cluster)
|
||||
if r.ClusterID != "" {
|
||||
var colocated []Resource
|
||||
|
|
@ -1016,7 +1015,7 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource {
|
|||
result["cluster_members"] = colocated
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -1025,15 +1024,15 @@ func (s *Store) GetRelated(resourceID string) map[string][]Resource {
|
|||
func (s *Store) GetResourceSummary() ResourceSummary {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
|
||||
summary := ResourceSummary{
|
||||
ByType: make(map[ResourceType]TypeSummary),
|
||||
ByPlatform: make(map[PlatformType]PlatformSummary),
|
||||
}
|
||||
|
||||
|
||||
for _, r := range s.resources {
|
||||
summary.TotalResources++
|
||||
|
||||
|
||||
// Count by status
|
||||
switch r.Status {
|
||||
case StatusOnline, StatusRunning:
|
||||
|
|
@ -1043,12 +1042,12 @@ func (s *Store) GetResourceSummary() ResourceSummary {
|
|||
case StatusOffline, StatusStopped, StatusUnknown:
|
||||
summary.Offline++
|
||||
}
|
||||
|
||||
|
||||
// Track alerts
|
||||
if len(r.Alerts) > 0 {
|
||||
summary.WithAlerts++
|
||||
}
|
||||
|
||||
|
||||
// Aggregate by type
|
||||
ts := summary.ByType[r.Type]
|
||||
ts.Count++
|
||||
|
|
@ -1059,13 +1058,13 @@ func (s *Store) GetResourceSummary() ResourceSummary {
|
|||
ts.TotalMemoryPercent += r.MemoryPercent()
|
||||
}
|
||||
summary.ByType[r.Type] = ts
|
||||
|
||||
|
||||
// Aggregate by platform
|
||||
ps := summary.ByPlatform[r.PlatformType]
|
||||
ps.Count++
|
||||
summary.ByPlatform[r.PlatformType] = ps
|
||||
}
|
||||
|
||||
|
||||
// Calculate averages
|
||||
for t, ts := range summary.ByType {
|
||||
if ts.Count > 0 {
|
||||
|
|
@ -1074,7 +1073,7 @@ func (s *Store) GetResourceSummary() ResourceSummary {
|
|||
summary.ByType[t] = ts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
|
|
@ -1153,6 +1152,31 @@ func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) {
|
|||
}
|
||||
}
|
||||
|
||||
// Convert Kubernetes clusters and their resources
|
||||
for _, cluster := range snapshot.KubernetesClusters {
|
||||
r := FromKubernetesCluster(cluster)
|
||||
id := s.Upsert(r)
|
||||
seenIDs[id] = true
|
||||
|
||||
for _, node := range cluster.Nodes {
|
||||
r := FromKubernetesNode(node, cluster)
|
||||
id := s.Upsert(r)
|
||||
seenIDs[id] = true
|
||||
}
|
||||
|
||||
for _, pod := range cluster.Pods {
|
||||
r := FromKubernetesPod(pod, cluster)
|
||||
id := s.Upsert(r)
|
||||
seenIDs[id] = true
|
||||
}
|
||||
|
||||
for _, dep := range cluster.Deployments {
|
||||
r := FromKubernetesDeployment(dep, cluster)
|
||||
id := s.Upsert(r)
|
||||
seenIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert PBS instances
|
||||
for _, pbs := range snapshot.PBSInstances {
|
||||
r := FromPBSInstance(pbs)
|
||||
|
|
@ -1167,14 +1191,14 @@ func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) {
|
|||
seenIDs[id] = true
|
||||
}
|
||||
|
||||
// Remove resources that were NOT in this snapshot BUT were sourced from API
|
||||
// (Agent-sourced resources like hosts from host-agent should persist independently)
|
||||
// Remove resources that were NOT in this snapshot.
|
||||
// The snapshot is the authoritative source for the in-memory store (including agent-sourced data).
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var toRemove []string
|
||||
for id, r := range s.resources {
|
||||
if !seenIDs[id] && r.SourceType == SourceAPI {
|
||||
for id := range s.resources {
|
||||
if !seenIDs[id] {
|
||||
toRemove = append(toRemove, id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
pkg/agents/kubernetes/report.go
Normal file
98
pkg/agents/kubernetes/report.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ param (
|
|||
[string]$Interval = "30s",
|
||||
[bool]$EnableHost = $true,
|
||||
[bool]$EnableDocker = $false,
|
||||
[bool]$EnableKubernetes = $false,
|
||||
[bool]$Insecure = $false,
|
||||
[bool]$Uninstall = $false,
|
||||
[string]$AgentId = ""
|
||||
|
|
@ -21,6 +22,43 @@ $InstallDir = "C:\Program Files\Pulse"
|
|||
$LogFile = "$env:ProgramData\Pulse\pulse-agent.log"
|
||||
$DownloadTimeoutSec = 300
|
||||
|
||||
function Parse-Bool {
|
||||
param(
|
||||
[string]$Value,
|
||||
[bool]$Default = $false
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return $Default
|
||||
}
|
||||
switch ($Value.Trim().ToLowerInvariant()) {
|
||||
'1' { return $true }
|
||||
'true' { return $true }
|
||||
'yes' { return $true }
|
||||
'y' { return $true }
|
||||
'on' { return $true }
|
||||
'0' { return $false }
|
||||
'false' { return $false }
|
||||
'no' { return $false }
|
||||
'n' { return $false }
|
||||
'off' { return $false }
|
||||
default { return $Default }
|
||||
}
|
||||
}
|
||||
|
||||
# Support env-var configuration for boolean flags (unless explicitly passed as parameters).
|
||||
if (-not $PSBoundParameters.ContainsKey('EnableHost') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_HOST)) {
|
||||
$EnableHost = Parse-Bool $env:PULSE_ENABLE_HOST $EnableHost
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('EnableDocker') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_DOCKER)) {
|
||||
$EnableDocker = Parse-Bool $env:PULSE_ENABLE_DOCKER $EnableDocker
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('EnableKubernetes') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_KUBERNETES)) {
|
||||
$EnableKubernetes = Parse-Bool $env:PULSE_ENABLE_KUBERNETES $EnableKubernetes
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('Insecure') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_INSECURE_SKIP_VERIFY)) {
|
||||
$Insecure = Parse-Bool $env:PULSE_INSECURE_SKIP_VERIFY $Insecure
|
||||
}
|
||||
|
||||
# --- Administrator Check ---
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
|
|
@ -309,6 +347,7 @@ $ServiceArgs = @(
|
|||
)
|
||||
if ($EnableHost) { $ServiceArgs += "--enable-host" }
|
||||
if ($EnableDocker) { $ServiceArgs += "--enable-docker" }
|
||||
if ($EnableKubernetes) { $ServiceArgs += "--enable-kubernetes" }
|
||||
if ($Insecure) { $ServiceArgs += "--insecure" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") }
|
||||
|
||||
|
|
@ -319,7 +358,7 @@ try {
|
|||
New-Service -Name $AgentName `
|
||||
-BinaryPathName $BinPath `
|
||||
-DisplayName "Pulse Unified Agent" `
|
||||
-Description "Pulse Unified Agent for Host and Docker monitoring" `
|
||||
-Description "Pulse Unified Agent for Host, Docker, and Kubernetes monitoring" `
|
||||
-StartupType Automatic | Out-Null
|
||||
Write-Host "Service created successfully" -ForegroundColor Green
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
# Options:
|
||||
# --enable-host Enable host metrics (default: true)
|
||||
# --enable-docker Enable docker metrics (default: false)
|
||||
# --enable-kubernetes Enable Kubernetes metrics (default: false)
|
||||
# --interval <dur> Reporting interval (default: 30s)
|
||||
# --agent-id <id> Custom agent identifier (default: auto-generated)
|
||||
# --uninstall Remove the agent
|
||||
|
|
@ -54,6 +55,7 @@ PULSE_TOKEN=""
|
|||
INTERVAL="30s"
|
||||
ENABLE_HOST="true"
|
||||
ENABLE_DOCKER="false"
|
||||
ENABLE_KUBERNETES="false"
|
||||
ENABLE_PROXMOX="false"
|
||||
PROXMOX_TYPE=""
|
||||
UNINSTALL="false"
|
||||
|
|
@ -85,6 +87,7 @@ build_exec_args() {
|
|||
EXEC_ARGS="$EXEC_ARGS --enable-host=false"
|
||||
fi
|
||||
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; fi
|
||||
if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-kubernetes"; fi
|
||||
if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-proxmox"; fi
|
||||
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS="$EXEC_ARGS --proxmox-type ${PROXMOX_TYPE}"; fi
|
||||
if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi
|
||||
|
|
@ -102,6 +105,7 @@ build_exec_args_array() {
|
|||
EXEC_ARGS_ARRAY+=(--enable-host=false)
|
||||
fi
|
||||
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); fi
|
||||
if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-kubernetes); fi
|
||||
if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-proxmox); fi
|
||||
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS_ARRAY+=(--proxmox-type "$PROXMOX_TYPE"); fi
|
||||
if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi
|
||||
|
|
@ -118,6 +122,8 @@ while [[ $# -gt 0 ]]; do
|
|||
--disable-host) ENABLE_HOST="false"; shift ;;
|
||||
--enable-docker) ENABLE_DOCKER="true"; shift ;;
|
||||
--disable-docker) ENABLE_DOCKER="false"; shift ;;
|
||||
--enable-kubernetes) ENABLE_KUBERNETES="true"; shift ;;
|
||||
--disable-kubernetes) ENABLE_KUBERNETES="false"; shift ;;
|
||||
--enable-proxmox) ENABLE_PROXMOX="true"; shift ;;
|
||||
--proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;;
|
||||
--insecure) INSECURE="true"; shift ;;
|
||||
|
|
@ -406,6 +412,10 @@ if [[ "$OS" == "darwin" ]]; then
|
|||
PLIST_ARGS="${PLIST_ARGS}
|
||||
<string>--enable-docker</string>"
|
||||
fi
|
||||
if [[ "$ENABLE_KUBERNETES" == "true" ]]; then
|
||||
PLIST_ARGS="${PLIST_ARGS}
|
||||
<string>--enable-kubernetes</string>"
|
||||
fi
|
||||
if [[ "$INSECURE" == "true" ]]; then
|
||||
PLIST_ARGS="${PLIST_ARGS}
|
||||
<string>--insecure</string>"
|
||||
|
|
@ -694,6 +704,7 @@ PULSE_TOKEN=${PULSE_TOKEN}
|
|||
PULSE_INTERVAL=${INTERVAL}
|
||||
PULSE_ENABLE_HOST=${ENABLE_HOST}
|
||||
PULSE_ENABLE_DOCKER=${ENABLE_DOCKER}
|
||||
PULSE_ENABLE_KUBERNETES=${ENABLE_KUBERNETES}
|
||||
EOF
|
||||
chmod 600 "$TRUENAS_ENV_FILE"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue