Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/config/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
115 changes: 69 additions & 46 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,72 +54,95 @@ 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"),
Labels: parseCommaSeparated(getEnv("LABELS", "")),
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
Expand Down
122 changes: 122 additions & 0 deletions internal/monitor/memory_status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
50 changes: 28 additions & 22 deletions internal/monitor/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading