mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 12:36:26 +00:00
refactor(workload): centralize workload listing with registry-based listers and add Argo Rollouts support
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user