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