From 3cf01197487dfd7c58dffaa33d45d23cfda91ace Mon Sep 17 00:00:00 2001 From: TheiLLeniumStudios <104288623+TheiLLeniumStudios@users.noreply.github.com> Date: Sun, 28 Dec 2025 08:47:56 +0100 Subject: [PATCH] refactor(workload): centralize workload listing with registry-based listers and add Argo Rollouts support --- internal/pkg/config/config.go | 25 +- internal/pkg/config/config_test.go | 26 -- internal/pkg/config/validation.go | 11 +- internal/pkg/config/validation_test.go | 14 + .../pkg/controller/deployment_reconciler.go | 2 +- internal/pkg/events/recorder_test.go | 184 ++++++++++++ internal/pkg/metrics/prometheus_test.go | 194 ++++++++++++ internal/pkg/webhook/webhook_test.go | 283 ++++++++++++++++++ internal/pkg/workload/lister.go | 133 ++++---- internal/pkg/workload/registry.go | 59 +++- 10 files changed, 808 insertions(+), 123 deletions(-) create mode 100644 internal/pkg/events/recorder_test.go create mode 100644 internal/pkg/metrics/prometheus_test.go create mode 100644 internal/pkg/webhook/webhook_test.go diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index e7ec77f7..c833328f 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( + "strings" "time" "k8s.io/apimachinery/pkg/labels" @@ -157,7 +158,7 @@ func DefaultAnnotations() AnnotationConfig { // IsResourceIgnored checks if a resource name should be ignored (case-insensitive). func (c *Config) IsResourceIgnored(name string) bool { for _, ignored := range c.IgnoredResources { - if equalFold(ignored, name) { + if strings.EqualFold(ignored, name) { return true } } @@ -167,7 +168,7 @@ func (c *Config) IsResourceIgnored(name string) bool { // IsWorkloadIgnored checks if a workload type should be ignored (case-insensitive). func (c *Config) IsWorkloadIgnored(workloadType string) bool { for _, ignored := range c.IgnoredWorkloads { - if equalFold(ignored, workloadType) { + if strings.EqualFold(ignored, workloadType) { return true } } @@ -184,23 +185,3 @@ func (c *Config) IsNamespaceIgnored(namespace string) bool { return false } -func equalFold(s, t string) bool { - if len(s) != len(t) { - return false - } - for i := 0; i < len(s); i++ { - c1, c2 := s[i], t[i] - if c1 != c2 { - if 'A' <= c1 && c1 <= 'Z' { - c1 += 'a' - 'A' - } - if 'A' <= c2 && c2 <= 'Z' { - c2 += 'a' - 'A' - } - if c1 != c2 { - return false - } - } - } - return true -} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 38b7ab53..17a3e33d 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -201,29 +201,3 @@ func TestConfig_IsNamespaceIgnored(t *testing.T) { } } -func TestEqualFold(t *testing.T) { - tests := []struct { - s, t string - want bool - }{ - {"abc", "abc", true}, - {"ABC", "abc", true}, - {"abc", "ABC", true}, - {"aBc", "AbC", true}, - {"abc", "abcd", false}, - {"", "", true}, - {"a", "", false}, - {"", "a", false}, - } - - for _, tt := range tests { - t.Run( - tt.s+"_"+tt.t, func(t *testing.T) { - got := equalFold(tt.s, tt.t) - if got != tt.want { - t.Errorf("equalFold(%q, %q) = %v, want %v", tt.s, tt.t, got, tt.want) - } - }, - ) - } -} diff --git a/internal/pkg/config/validation.go b/internal/pkg/config/validation.go index 0ebce400..161102a0 100644 --- a/internal/pkg/config/validation.go +++ b/internal/pkg/config/validation.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/stakater/Reloader/internal/pkg/workload" "k8s.io/apimachinery/pkg/labels" ) @@ -102,8 +103,16 @@ func (c *Config) Validate() error { // Normalize IgnoredResources to lowercase for consistent comparison c.IgnoredResources = normalizeToLower(c.IgnoredResources) - // Normalize IgnoredWorkloads to lowercase + // Validate and normalize IgnoredWorkloads c.IgnoredWorkloads = normalizeToLower(c.IgnoredWorkloads) + for _, w := range c.IgnoredWorkloads { + if _, err := workload.KindFromString(w); err != nil { + errs = append(errs, ValidationError{ + Field: "IgnoredWorkloads", + Message: fmt.Sprintf("unknown workload type %q", w), + }) + } + } if len(errs) > 0 { return errs diff --git a/internal/pkg/config/validation_test.go b/internal/pkg/config/validation_test.go index 54e0a47d..45eafb73 100644 --- a/internal/pkg/config/validation_test.go +++ b/internal/pkg/config/validation_test.go @@ -178,6 +178,20 @@ func TestConfig_Validate_NormalizesIgnoredWorkloads(t *testing.T) { } } +func TestConfig_Validate_InvalidIgnoredWorkload(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredWorkloads = []string{"deployment", "invalidtype"} + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() should return error for invalid workload type") + } + + if !strings.Contains(err.Error(), "invalidtype") { + t.Errorf("Error should mention invalid workload type, got: %v", err) + } +} + func TestConfig_Validate_MultipleErrors(t *testing.T) { cfg := NewDefault() cfg.ReloadStrategy = "invalid" diff --git a/internal/pkg/controller/deployment_reconciler.go b/internal/pkg/controller/deployment_reconciler.go index 08c71ab4..b0acb643 100644 --- a/internal/pkg/controller/deployment_reconciler.go +++ b/internal/pkg/controller/deployment_reconciler.go @@ -26,7 +26,7 @@ type DeploymentReconciler struct { // Reconcile handles Deployment pause expiration. func (r *DeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.WithValues("deployment", req.NamespacedName) - log.Info("Deployment reconciling ", "namespace", req.Namespace, "name", req.Name) + log.V(1).Info("reconciling deployment", "namespace", req.Namespace, "name", req.Name) var deploy appsv1.Deployment if err := r.Get(ctx, req.NamespacedName, &deploy); err != nil { diff --git a/internal/pkg/events/recorder_test.go b/internal/pkg/events/recorder_test.go new file mode 100644 index 00000000..9bf6a917 --- /dev/null +++ b/internal/pkg/events/recorder_test.go @@ -0,0 +1,184 @@ +package events + +import ( + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" +) + +func TestNewRecorder_NilInput(t *testing.T) { + r := NewRecorder(nil) + if r != nil { + t.Error("NewRecorder(nil) should return nil") + } +} + +func TestNewRecorder_ValidInput(t *testing.T) { + fakeRecorder := record.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + if r == nil { + t.Error("NewRecorder with valid recorder should not return nil") + } +} + +func TestReloadSuccess_RecordsEvent(t *testing.T) { + fakeRecorder := record.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + r.ReloadSuccess(pod, "ConfigMap", "my-config") + + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + // Event format: "Normal Reloaded Reloaded due to ConfigMap my-config change" + expectedContains := []string{"Normal", "Reloaded", "ConfigMap", "my-config"} + for _, expected := range expectedContains { + if !contains(event, expected) { + t.Errorf("Event %q should contain %q", event, expected) + } + } + default: + t.Error("Expected event to be recorded, but none was") + } +} + +func TestReloadFailed_RecordsWarningEvent(t *testing.T) { + fakeRecorder := record.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + testErr := errors.New("update conflict") + r.ReloadFailed(pod, "Secret", "my-secret", testErr) + + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + // Event format: "Warning ReloadFailed Failed to reload due to Secret my-secret change: update conflict" + expectedContains := []string{"Warning", "ReloadFailed", "Secret", "my-secret", "update conflict"} + for _, expected := range expectedContains { + if !contains(event, expected) { + t.Errorf("Event %q should contain %q", event, expected) + } + } + default: + t.Error("Expected event to be recorded, but none was") + } +} + +func TestNilRecorder_NoPanic(t *testing.T) { + var r *Recorder = nil + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + // These should not panic + r.ReloadSuccess(pod, "ConfigMap", "my-config") + r.ReloadFailed(pod, "Secret", "my-secret", errors.New("test error")) +} + +func TestRecorder_NilInternalRecorder(t *testing.T) { + // Create a Recorder with nil internal recorder (edge case) + r := &Recorder{recorder: nil} + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + // These should not panic + r.ReloadSuccess(pod, "ConfigMap", "my-config") + r.ReloadFailed(pod, "Secret", "my-secret", errors.New("test error")) +} + +func TestEventConstants(t *testing.T) { + if EventTypeNormal != corev1.EventTypeNormal { + t.Errorf("EventTypeNormal = %q, want %q", EventTypeNormal, corev1.EventTypeNormal) + } + if EventTypeWarning != corev1.EventTypeWarning { + t.Errorf("EventTypeWarning = %q, want %q", EventTypeWarning, corev1.EventTypeWarning) + } + if ReasonReloaded != "Reloaded" { + t.Errorf("ReasonReloaded = %q, want %q", ReasonReloaded, "Reloaded") + } + if ReasonReloadFailed != "ReloadFailed" { + t.Errorf("ReasonReloadFailed = %q, want %q", ReasonReloadFailed, "ReloadFailed") + } +} + +func TestReloadSuccess_DifferentObjectTypes(t *testing.T) { + fakeRecorder := record.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + + tests := []struct { + name string + object runtime.Object + }{ + { + name: "Pod", + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}, + }, + }, + { + name: "ConfigMap", + object: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r.ReloadSuccess(tt.object, "ConfigMap", "my-config") + + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + default: + t.Error("Expected event to be recorded") + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/pkg/metrics/prometheus_test.go b/internal/pkg/metrics/prometheus_test.go new file mode 100644 index 00000000..47b4392d --- /dev/null +++ b/internal/pkg/metrics/prometheus_test.go @@ -0,0 +1,194 @@ +package metrics + +import ( + "os" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func TestNewCollectors_CreatesCounters(t *testing.T) { + collectors := NewCollectors() + + if collectors.Reloaded == nil { + t.Error("NewCollectors() should create Reloaded counter") + } + if collectors.ReloadedByNamespace == nil { + t.Error("NewCollectors() should create ReloadedByNamespace counter") + } +} + +func TestNewCollectors_InitializesWithZero(t *testing.T) { + collectors := NewCollectors() + + // Check that success=true counter is initialized to 0 + metric := &dto.Metric{} + err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 0 { + t.Errorf("Initial success=true counter = %v, want 0", metric.Counter.GetValue()) + } + + // Check that success=false counter is initialized to 0 + err = collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 0 { + t.Errorf("Initial success=false counter = %v, want 0", metric.Counter.GetValue()) + } +} + +func TestRecordReload_Success(t *testing.T) { + collectors := NewCollectors() + collectors.RecordReload(true, "default") + + metric := &dto.Metric{} + err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("success=true counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_Failure(t *testing.T) { + collectors := NewCollectors() + collectors.RecordReload(false, "default") + + metric := &dto.Metric{} + err := collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("success=false counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_MultipleIncrements(t *testing.T) { + collectors := NewCollectors() + collectors.RecordReload(true, "default") + collectors.RecordReload(true, "default") + collectors.RecordReload(false, "default") + + metric := &dto.Metric{} + + err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 2 { + t.Errorf("success=true counter = %v, want 2", metric.Counter.GetValue()) + } + + err = collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("success=false counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_WithNamespaceTracking(t *testing.T) { + // Enable namespace tracking + os.Setenv("METRICS_COUNT_BY_NAMESPACE", "enabled") + defer os.Unsetenv("METRICS_COUNT_BY_NAMESPACE") + + collectors := NewCollectors() + collectors.RecordReload(true, "kube-system") + + metric := &dto.Metric{} + err := collectors.ReloadedByNamespace.With(prometheus.Labels{ + "success": "true", + "namespace": "kube-system", + }).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("namespace counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_WithoutNamespaceTracking(t *testing.T) { + // Ensure namespace tracking is disabled + os.Unsetenv("METRICS_COUNT_BY_NAMESPACE") + + collectors := NewCollectors() + collectors.RecordReload(true, "kube-system") + + // The ReloadedByNamespace counter should not be incremented + // We can verify by checking countByNamespace is false + if collectors.countByNamespace { + t.Error("countByNamespace should be false when env var is not set") + } +} + +func TestNilCollectors_NoPanic(t *testing.T) { + var c *Collectors = nil + + // This should not panic + c.RecordReload(true, "default") + c.RecordReload(false, "default") +} + +func TestRecordReload_DifferentNamespaces(t *testing.T) { + os.Setenv("METRICS_COUNT_BY_NAMESPACE", "enabled") + defer os.Unsetenv("METRICS_COUNT_BY_NAMESPACE") + + collectors := NewCollectors() + collectors.RecordReload(true, "namespace-a") + collectors.RecordReload(true, "namespace-b") + collectors.RecordReload(true, "namespace-a") + + metric := &dto.Metric{} + + // Check namespace-a has 2 reloads + err := collectors.ReloadedByNamespace.With(prometheus.Labels{ + "success": "true", + "namespace": "namespace-a", + }).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 2 { + t.Errorf("namespace-a counter = %v, want 2", metric.Counter.GetValue()) + } + + // Check namespace-b has 1 reload + err = collectors.ReloadedByNamespace.With(prometheus.Labels{ + "success": "true", + "namespace": "namespace-b", + }).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("namespace-b counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestCollectors_MetricNames(t *testing.T) { + collectors := NewCollectors() + + // Verify the Reloaded metric has correct description + ch := make(chan *prometheus.Desc, 10) + collectors.Reloaded.Describe(ch) + close(ch) + + found := false + for desc := range ch { + if desc.String() != "" { + found = true + } + } + if !found { + t.Error("Expected Reloaded metric to have a description") + } +} diff --git a/internal/pkg/webhook/webhook_test.go b/internal/pkg/webhook/webhook_test.go new file mode 100644 index 00000000..acb7b983 --- /dev/null +++ b/internal/pkg/webhook/webhook_test.go @@ -0,0 +1,283 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-logr/logr" +) + +func TestNewClient_SetsURL(t *testing.T) { + c := NewClient("http://example.com/webhook", logr.Discard()) + + if c == nil { + t.Fatal("NewClient should not return nil") + } + if c.url != "http://example.com/webhook" { + t.Errorf("URL = %q, want %q", c.url, "http://example.com/webhook") + } + if c.httpClient == nil { + t.Error("httpClient should not be nil") + } + if c.httpClient.Timeout != 30*time.Second { + t.Errorf("Timeout = %v, want %v", c.httpClient.Timeout, 30*time.Second) + } +} + +func TestIsConfigured_NilClient(t *testing.T) { + var c *Client = nil + + if c.IsConfigured() { + t.Error("IsConfigured() should return false for nil client") + } +} + +func TestIsConfigured_EmptyURL(t *testing.T) { + c := NewClient("", logr.Discard()) + + if c.IsConfigured() { + t.Error("IsConfigured() should return false for empty URL") + } +} + +func TestIsConfigured_ValidURL(t *testing.T) { + c := NewClient("http://example.com/webhook", logr.Discard()) + + if !c.IsConfigured() { + t.Error("IsConfigured() should return true for valid URL") + } +} + +func TestSend_EmptyURL_ReturnsNil(t *testing.T) { + c := NewClient("", logr.Discard()) + + payload := Payload{ + Kind: "ConfigMap", + Namespace: "default", + ResourceName: "my-config", + ResourceType: "configmap", + } + + err := c.Send(context.Background(), payload) + if err != nil { + t.Errorf("Send() with empty URL should return nil, got %v", err) + } +} + +func TestSend_MarshalPayload(t *testing.T) { + var receivedPayload Payload + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedPayload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + payload := Payload{ + Kind: "ConfigMap", + Namespace: "default", + ResourceName: "my-config", + ResourceType: "configmap", + Hash: "abc123", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + Workloads: []WorkloadInfo{ + {Kind: "Deployment", Name: "my-deploy", Namespace: "default"}, + }, + } + + err := c.Send(context.Background(), payload) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if receivedPayload.Kind != "ConfigMap" { + t.Errorf("Received Kind = %q, want %q", receivedPayload.Kind, "ConfigMap") + } + if receivedPayload.Namespace != "default" { + t.Errorf("Received Namespace = %q, want %q", receivedPayload.Namespace, "default") + } + if receivedPayload.ResourceName != "my-config" { + t.Errorf("Received ResourceName = %q, want %q", receivedPayload.ResourceName, "my-config") + } + if receivedPayload.Hash != "abc123" { + t.Errorf("Received Hash = %q, want %q", receivedPayload.Hash, "abc123") + } + if len(receivedPayload.Workloads) != 1 { + t.Errorf("Received Workloads count = %d, want 1", len(receivedPayload.Workloads)) + } +} + +func TestSend_SetsCorrectHeaders(t *testing.T) { + var contentType, userAgent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + userAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + err := c.Send(context.Background(), Payload{}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if contentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/json") + } + if userAgent != "Reloader/2.0" { + t.Errorf("User-Agent = %q, want %q", userAgent, "Reloader/2.0") + } +} + +func TestSend_UsesPostMethod(t *testing.T) { + var method string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + err := c.Send(context.Background(), Payload{}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if method != http.MethodPost { + t.Errorf("Method = %q, want %q", method, http.MethodPost) + } +} + +func TestSend_Non2xxResponse(t *testing.T) { + tests := []struct { + name string + statusCode int + wantErr bool + }{ + {"200 OK", 200, false}, + {"201 Created", 201, false}, + {"204 No Content", 204, false}, + {"299 upper bound", 299, false}, + {"300 redirect", 300, true}, + {"400 Bad Request", 400, true}, + {"404 Not Found", 404, true}, + {"500 Internal Error", 500, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + err := c.Send(context.Background(), Payload{}) + + if (err != nil) != tt.wantErr { + t.Errorf("Send() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestSend_NetworkError(t *testing.T) { + // Use a URL that won't connect + c := NewClient("http://127.0.0.1:1", logr.Discard()) + + err := c.Send(context.Background(), Payload{}) + if err == nil { + t.Error("Send() should return error for network failure") + } +} + +func TestSend_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err := c.Send(ctx, Payload{}) + if err == nil { + t.Error("Send() should return error for cancelled context") + } +} + +func TestPayload_JSONSerialization(t *testing.T) { + payload := Payload{ + Kind: "ConfigMap", + Namespace: "default", + ResourceName: "my-config", + ResourceType: "configmap", + Hash: "abc123", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + Workloads: []WorkloadInfo{ + {Kind: "Deployment", Name: "my-deploy", Namespace: "default"}, + {Kind: "StatefulSet", Name: "my-sts", Namespace: "default"}, + }, + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal payload: %v", err) + } + + var unmarshaled Payload + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal payload: %v", err) + } + + if unmarshaled.Kind != payload.Kind { + t.Errorf("Kind = %q, want %q", unmarshaled.Kind, payload.Kind) + } + if len(unmarshaled.Workloads) != 2 { + t.Errorf("Workloads count = %d, want 2", len(unmarshaled.Workloads)) + } +} + +func TestWorkloadInfo_JSONSerialization(t *testing.T) { + info := WorkloadInfo{ + Kind: "Deployment", + Name: "my-deploy", + Namespace: "production", + } + + data, err := json.Marshal(info) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var unmarshaled WorkloadInfo + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if unmarshaled.Kind != "Deployment" { + t.Errorf("Kind = %q, want %q", unmarshaled.Kind, "Deployment") + } + if unmarshaled.Name != "my-deploy" { + t.Errorf("Name = %q, want %q", unmarshaled.Name, "my-deploy") + } + if unmarshaled.Namespace != "production" { + t.Errorf("Namespace = %q, want %q", unmarshaled.Namespace, "production") + } +} diff --git a/internal/pkg/workload/lister.go b/internal/pkg/workload/lister.go index a1487bb7..a30bdfcc 100644 --- a/internal/pkg/workload/lister.go +++ b/internal/pkg/workload/lister.go @@ -3,6 +3,7 @@ package workload import ( "context" + argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -49,63 +50,81 @@ func (l *Lister) List(ctx context.Context, namespace string) ([]WorkloadAccessor } func (l *Lister) listByKind(ctx context.Context, namespace string, kind Kind) ([]WorkloadAccessor, error) { - switch kind { - case KindDeployment: - var list appsv1.DeploymentList - if err := l.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { - return nil, err - } - result := make([]WorkloadAccessor, len(list.Items)) - for i := range list.Items { - result[i] = NewDeploymentWorkload(&list.Items[i]) - } - return result, nil - - case KindDaemonSet: - var list appsv1.DaemonSetList - if err := l.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { - return nil, err - } - result := make([]WorkloadAccessor, len(list.Items)) - for i := range list.Items { - result[i] = NewDaemonSetWorkload(&list.Items[i]) - } - return result, nil - - case KindStatefulSet: - var list appsv1.StatefulSetList - if err := l.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { - return nil, err - } - result := make([]WorkloadAccessor, len(list.Items)) - for i := range list.Items { - result[i] = NewStatefulSetWorkload(&list.Items[i]) - } - return result, nil - - case KindJob: - var list batchv1.JobList - if err := l.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { - return nil, err - } - result := make([]WorkloadAccessor, len(list.Items)) - for i := range list.Items { - result[i] = NewJobWorkload(&list.Items[i]) - } - return result, nil - - case KindCronJob: - var list batchv1.CronJobList - if err := l.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { - return nil, err - } - result := make([]WorkloadAccessor, len(list.Items)) - for i := range list.Items { - result[i] = NewCronJobWorkload(&list.Items[i]) - } - return result, nil - - default: + lister := l.Registry.ListerFor(kind) + if lister == nil { return nil, nil } + return lister(ctx, l.Client, namespace) +} + +func listDeployments(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) { + var list appsv1.DeploymentList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = NewDeploymentWorkload(&list.Items[i]) + } + return result, nil +} + +func listDaemonSets(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) { + var list appsv1.DaemonSetList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = NewDaemonSetWorkload(&list.Items[i]) + } + return result, nil +} + +func listStatefulSets(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) { + var list appsv1.StatefulSetList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = NewStatefulSetWorkload(&list.Items[i]) + } + return result, nil +} + +func listJobs(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) { + var list batchv1.JobList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = NewJobWorkload(&list.Items[i]) + } + return result, nil +} + +func listCronJobs(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) { + var list batchv1.CronJobList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = NewCronJobWorkload(&list.Items[i]) + } + return result, nil +} + +func listRollouts(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) { + var list argorolloutv1alpha1.RolloutList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = NewRolloutWorkload(&list.Items[i]) + } + return result, nil } diff --git a/internal/pkg/workload/registry.go b/internal/pkg/workload/registry.go index 8525d562..5e7ad0c1 100644 --- a/internal/pkg/workload/registry.go +++ b/internal/pkg/workload/registry.go @@ -1,7 +1,9 @@ package workload import ( + "context" "fmt" + "strings" argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" appsv1 "k8s.io/api/apps/v1" @@ -9,16 +11,36 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// WorkloadLister is a function that lists workloads of a specific kind. +type WorkloadLister func(ctx context.Context, c client.Client, namespace string) ([]WorkloadAccessor, error) + // Registry provides factory methods for creating Workload instances. type Registry struct { argoRolloutsEnabled bool + listers map[Kind]WorkloadLister } // NewRegistry creates a new workload registry. func NewRegistry(argoRolloutsEnabled bool) *Registry { - return &Registry{ + r := &Registry{ argoRolloutsEnabled: argoRolloutsEnabled, + listers: map[Kind]WorkloadLister{ + KindDeployment: listDeployments, + KindDaemonSet: listDaemonSets, + KindStatefulSet: listStatefulSets, + KindJob: listJobs, + KindCronJob: listCronJobs, + }, } + if argoRolloutsEnabled { + r.listers[KindArgoRollout] = listRollouts + } + return r +} + +// ListerFor returns the lister function for the given kind, or nil if not found. +func (r *Registry) ListerFor(kind Kind) WorkloadLister { + return r.listers[kind] } // SupportedKinds returns all supported workload kinds. @@ -59,22 +81,27 @@ func (r *Registry) FromObject(obj client.Object) (WorkloadAccessor, error) { } } +// kindAliases maps string representations to Kind constants. +// Supports lowercase, title case, and plural forms for user convenience. +var kindAliases = map[string]Kind{ + "deployment": KindDeployment, + "deployments": KindDeployment, + "daemonset": KindDaemonSet, + "daemonsets": KindDaemonSet, + "statefulset": KindStatefulSet, + "statefulsets": KindStatefulSet, + "rollout": KindArgoRollout, + "rollouts": KindArgoRollout, + "job": KindJob, + "jobs": KindJob, + "cronjob": KindCronJob, + "cronjobs": KindCronJob, +} + // KindFromString converts a string to a Kind. func KindFromString(s string) (Kind, error) { - switch s { - case "Deployment", "deployment", "deployments": - return KindDeployment, nil - case "DaemonSet", "daemonset", "daemonsets": - return KindDaemonSet, nil - case "StatefulSet", "statefulset", "statefulsets": - return KindStatefulSet, nil - case "Rollout", "rollout", "rollouts": - return KindArgoRollout, nil - case "Job", "job", "jobs": - return KindJob, nil - case "CronJob", "cronjob", "cronjobs": - return KindCronJob, nil - default: - return "", fmt.Errorf("unknown workload kind: %s", s) + if k, ok := kindAliases[strings.ToLower(s)]; ok { + return k, nil } + return "", fmt.Errorf("unknown workload kind: %s", s) }