diff --git a/internal/config/cli_test.go b/internal/config/cli_test.go index 33e9265..6afe023 100644 --- a/internal/config/cli_test.go +++ b/internal/config/cli_test.go @@ -171,3 +171,17 @@ func TestNamespaceLogic(t *testing.T) { }) } } + +func TestLoadWithCLI_LabelAnnotationOverride(t *testing.T) { + cli := &CLIConfig{Labels: "app,team", Annotations: "owner"} + cfg, err := LoadWithCLI(cli) + if err != nil { + t.Fatalf("LoadWithCLI() failed: %v", err) + } + if len(cfg.Labels) != 2 || cfg.Labels[0] != "app" || cfg.Labels[1] != "team" { + t.Errorf("expected labels [app team], got %v", cfg.Labels) + } + if len(cfg.Annotations) != 1 || cfg.Annotations[0] != "owner" { + t.Errorf("expected annotations [owner], got %v", cfg.Annotations) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 13af141..7490553 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,14 +54,23 @@ func Load() (*Config, error) { // LoadWithCLI loads configuration from environment variables and CLI flags // CLI flags take precedence over environment variables func LoadWithCLI(cli *CLIConfig) (*Config, error) { - cfg := &Config{ - // Default values from environment variables + cfg := defaultConfigFromEnv() + applyCLIOverrides(cfg, cli) + applyDefaultNamespace(cfg) + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("configuration validation failed: %w", err) + } + return cfg, nil +} + +func defaultConfigFromEnv() *Config { + return &Config{ Namespace: getEnv("NAMESPACE", ""), AllNamespaces: getEnvBool("ALL_NAMESPACES", false), KubeConfig: getEnv("KUBECONFIG", ""), InCluster: getEnvBool("IN_CLUSTER", false), CheckInterval: getEnvDuration("CHECK_INTERVAL", "30s"), - MemoryThresholdMB: getEnvInt64("MEMORY_THRESHOLD_MB", 1024), // 1GB default + MemoryThresholdMB: getEnvInt64("MEMORY_THRESHOLD_MB", 1024), MemoryWarningPercent: getEnvFloat("MEMORY_WARNING_PERCENT", 80.0), LogLevel: getEnv("LOG_LEVEL", "info"), LogFormat: getEnv("LOG_FORMAT", "json"), @@ -69,57 +78,71 @@ func LoadWithCLI(cli *CLIConfig) (*Config, error) { Annotations: parseCommaSeparated(getEnv("ANNOTATIONS", "")), Output: getEnv("OUTPUT", "table"), } +} - // Apply CLI flags (they override environment variables) - if cli != nil { - if cli.Namespace != "" { - cfg.Namespace = cli.Namespace - } - if cli.AllNamespaces { - cfg.AllNamespaces = cli.AllNamespaces - } - if cli.KubeConfig != "" { - cfg.KubeConfig = cli.KubeConfig - } - if cli.InCluster { - cfg.InCluster = cli.InCluster - } - if cli.CheckInterval != 0 { - cfg.CheckInterval = cli.CheckInterval - } - if cli.MemoryThresholdMB != 0 { - cfg.MemoryThresholdMB = cli.MemoryThresholdMB - } - if cli.MemoryWarningPercent != 0 { - cfg.MemoryWarningPercent = cli.MemoryWarningPercent - } - if cli.LogLevel != "" { - cfg.LogLevel = cli.LogLevel - } - if cli.Labels != "" { - cfg.Labels = parseCommaSeparated(cli.Labels) - } - if cli.Annotations != "" { - cfg.Annotations = parseCommaSeparated(cli.Annotations) - } - if cli.Output != "" { - cfg.Output = cli.Output - } +func applyCLIOverrides(cfg *Config, cli *CLIConfig) { + if cli == nil { + return } + overrideNamespace(cfg, cli) + overrideKubeConfig(cfg, cli) + overrideIntervals(cfg, cli) + overrideLogging(cfg, cli) + overrideDisplay(cfg, cli) +} - // Apply default behavior for namespace selection - if cfg.Namespace == "" && !cfg.AllNamespaces { - // If no specific namespace and not explicitly all-namespaces, - // default to all namespaces (Kubernetes-style behavior) +func overrideNamespace(cfg *Config, cli *CLIConfig) { + if cli.Namespace != "" { + cfg.Namespace = cli.Namespace + } + if cli.AllNamespaces { cfg.AllNamespaces = true } +} - // Validate configuration - if err := cfg.validate(); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) +func overrideKubeConfig(cfg *Config, cli *CLIConfig) { + if cli.KubeConfig != "" { + cfg.KubeConfig = cli.KubeConfig + } + if cli.InCluster { + cfg.InCluster = true } +} - return cfg, nil +func overrideIntervals(cfg *Config, cli *CLIConfig) { + if cli.CheckInterval != 0 { + cfg.CheckInterval = cli.CheckInterval + } + if cli.MemoryThresholdMB != 0 { + cfg.MemoryThresholdMB = cli.MemoryThresholdMB + } + if cli.MemoryWarningPercent != 0 { + cfg.MemoryWarningPercent = cli.MemoryWarningPercent + } +} + +func overrideLogging(cfg *Config, cli *CLIConfig) { + if cli.LogLevel != "" { + cfg.LogLevel = cli.LogLevel + } + if cli.Output != "" { + cfg.Output = cli.Output + } +} + +func overrideDisplay(cfg *Config, cli *CLIConfig) { + if cli.Labels != "" { + cfg.Labels = parseCommaSeparated(cli.Labels) + } + if cli.Annotations != "" { + cfg.Annotations = parseCommaSeparated(cli.Annotations) + } +} + +func applyDefaultNamespace(cfg *Config) { + if cfg.Namespace == "" && !cfg.AllNamespaces { + cfg.AllNamespaces = true + } } // validate checks that the configuration is valid diff --git a/internal/monitor/memory_status_test.go b/internal/monitor/memory_status_test.go new file mode 100644 index 0000000..27d2a09 --- /dev/null +++ b/internal/monitor/memory_status_test.go @@ -0,0 +1,122 @@ +package monitor + +import ( + "testing" + + "github.com/eduardoferro/k8s-memory-watch/internal/config" + "github.com/eduardoferro/k8s-memory-watch/internal/k8s" + "k8s.io/apimachinery/pkg/api/resource" +) + +func qty(v int64) *resource.Quantity { + q := resource.NewQuantity(v, resource.BinarySI) + return q +} + +func pct(v float64) *float64 { + return &v +} + +func TestGetMemoryStatus_NoData(t *testing.T) { + pod := &k8s.PodMemoryInfo{} + status := getMemoryStatus(pod, &config.Config{}) + if status != "no_data" { + t.Errorf("expected no_data, got %s", status) + } +} + +func TestGetMemoryStatus_NoConfig(t *testing.T) { + pod := &k8s.PodMemoryInfo{CurrentUsage: qty(1)} + status := getMemoryStatus(pod, &config.Config{}) + if status != "no_config" { + t.Errorf("expected no_config, got %s", status) + } +} + +func TestGetMemoryStatus_NoRequest(t *testing.T) { + pod := &k8s.PodMemoryInfo{CurrentUsage: qty(1), MemoryLimit: qty(1)} + status := getMemoryStatus(pod, &config.Config{}) + if status != "no_request" { + t.Errorf("expected no_request, got %s", status) + } +} + +func TestGetMemoryStatus_NoLimit(t *testing.T) { + pod := &k8s.PodMemoryInfo{CurrentUsage: qty(1), MemoryRequest: qty(1)} + status := getMemoryStatus(pod, &config.Config{}) + if status != "no_limit" { + t.Errorf("expected no_limit, got %s", status) + } +} + +func TestGetMemoryStatus_CriticalByUsage(t *testing.T) { + pod := &k8s.PodMemoryInfo{ + CurrentUsage: qty(1), + MemoryRequest: qty(1), + MemoryLimit: qty(1), + UsagePercent: pct(95), + } + status := getMemoryStatus(pod, &config.Config{}) + if status != "critical" { + t.Errorf("expected critical, got %s", status) + } +} + +func TestGetMemoryStatus_CriticalByLimitUsage(t *testing.T) { + pod := &k8s.PodMemoryInfo{ + CurrentUsage: qty(1), + MemoryRequest: qty(1), + MemoryLimit: qty(1), + LimitUsagePercent: pct(90), + } + status := getMemoryStatus(pod, &config.Config{}) + if status != "critical" { + t.Errorf("expected critical, got %s", status) + } +} + +func TestGetMemoryStatus_Warning(t *testing.T) { + pod := &k8s.PodMemoryInfo{ + CurrentUsage: qty(1), + MemoryRequest: qty(1), + MemoryLimit: qty(1), + UsagePercent: pct(80), + } + cfg := &config.Config{MemoryWarningPercent: 70} + status := getMemoryStatus(pod, cfg) + if status != "warning" { + t.Errorf("expected warning, got %s", status) + } +} + +func TestGetMemoryStatus_NotReady(t *testing.T) { + pod := &k8s.PodMemoryInfo{ + CurrentUsage: qty(1), + MemoryRequest: qty(1), + MemoryLimit: qty(1), + UsagePercent: pct(10), + Ready: false, + Phase: "Pending", + } + cfg := &config.Config{MemoryWarningPercent: 80} + status := getMemoryStatus(pod, cfg) + if status != "not_ready" { + t.Errorf("expected not_ready, got %s", status) + } +} + +func TestGetMemoryStatus_Ok(t *testing.T) { + pod := &k8s.PodMemoryInfo{ + CurrentUsage: qty(1), + MemoryRequest: qty(2), + MemoryLimit: qty(3), + UsagePercent: pct(50), + Ready: true, + Phase: "Running", + } + cfg := &config.Config{MemoryWarningPercent: 80} + status := getMemoryStatus(pod, cfg) + if status != "ok" { + t.Errorf("expected ok, got %s", status) + } +} diff --git a/internal/monitor/types.go b/internal/monitor/types.go index feeb2c6..19dd495 100644 --- a/internal/monitor/types.go +++ b/internal/monitor/types.go @@ -177,47 +177,53 @@ func formatPercentForCSV(percent *float64) string { // getMemoryStatus determines the memory status of a pod for CSV output func getMemoryStatus(pod *k8s.PodMemoryInfo, cfg *config.Config) string { - // No metrics available if pod.CurrentUsage == nil { return "no_data" } - // Check for missing configurations first - if pod.MemoryRequest == nil && pod.MemoryLimit == nil { - return "no_config" + if status, missing := missingConfigStatus(pod); missing { + return status } - if pod.MemoryRequest == nil { - return "no_request" - } - - if pod.MemoryLimit == nil { - return "no_limit" - } - - // Critical level checks (highest priority) - if pod.UsagePercent != nil && *pod.UsagePercent >= 95.0 { + if isCritical(pod) { return "critical" } - if pod.LimitUsagePercent != nil && *pod.LimitUsagePercent >= 90.0 { - return "critical" - } - - // Warning level check - if pod.UsagePercent != nil && *pod.UsagePercent >= cfg.MemoryWarningPercent { + if isWarning(pod, cfg) { return "warning" } - // Pod not running properly if !pod.Ready || pod.Phase != "Running" { return "not_ready" } - // Everything looks good return "ok" } +func missingConfigStatus(pod *k8s.PodMemoryInfo) (string, bool) { + switch { + case pod.MemoryRequest == nil && pod.MemoryLimit == nil: + return "no_config", true + case pod.MemoryRequest == nil: + return "no_request", true + case pod.MemoryLimit == nil: + return "no_limit", true + default: + return "", false + } +} + +func isCritical(pod *k8s.PodMemoryInfo) bool { + if pod.UsagePercent != nil && *pod.UsagePercent >= 95.0 { + return true + } + return pod.LimitUsagePercent != nil && *pod.LimitUsagePercent >= 90.0 +} + +func isWarning(pod *k8s.PodMemoryInfo, cfg *config.Config) bool { + return pod.UsagePercent != nil && *pod.UsagePercent >= cfg.MemoryWarningPercent +} + // PrintAnalysis prints the analysis results with warnings and recommendations func (a *AnalysisResult) PrintAnalysis(cfg *config.Config) { reporter := NewAnalysisReporter()