Files
kubernetes-images-sync-oper…/internal/controller/raczylo.com/controller_unit_test.go
T
lukaszraczylo 2fdce1bf21 fix(lint): resolve all golangci-lint findings
Migrate the lint config to the v2 format.
Replace the deprecated scheme.Builder with runtime.NewSchemeBuilder.
Handle Close errors in the worker and replace repeated strings with constants.
Remove unused parameters and use switch statements where the linter asks.
2026-10-02 10:37:06 +01:00

1035 lines
31 KiB
Go

package raczylocom
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
raczylocomv1 "github.com/lukaszraczylo/kubernetes-images-sync-operator/api/raczylo.com/v1"
"github.com/lukaszraczylo/kubernetes-images-sync-operator/internal/shared"
)
type TestScenario string
const testNamespace = "default"
const (
ScenarioGood TestScenario = "good"
ScenarioNotGood TestScenario = "not_good"
ScenarioReallyBad TestScenario = "really_bad"
)
type ControllerTestSuite struct {
suite.Suite
scheme *runtime.Scheme
ctx context.Context
}
func TestControllerTestSuite(t *testing.T) {
suite.Run(t, new(ControllerTestSuite))
}
func (s *ControllerTestSuite) SetupSuite() {
s.scheme = runtime.NewScheme()
require.NoError(s.T(), clientgoscheme.AddToScheme(s.scheme))
require.NoError(s.T(), raczylocomv1.AddToScheme(s.scheme))
s.ctx = context.Background()
}
func (s *ControllerTestSuite) newFakeClient(objs ...client.Object) client.Client {
return fake.NewClientBuilder().
WithScheme(s.scheme).
WithObjects(objs...).
WithStatusSubresource(&raczylocomv1.ClusterImage{}, &raczylocomv1.ClusterImageExport{}).
WithIndex(&raczylocomv1.ClusterImage{}, "spec.exportName", func(obj client.Object) []string {
ci := obj.(*raczylocomv1.ClusterImage)
return []string{ci.Spec.ExportName}
}).
Build()
}
// Helper to create a test ClusterImageExport
func (s *ControllerTestSuite) createClusterImageExport(name string, opts ...func(*raczylocomv1.ClusterImageExport)) *raczylocomv1.ClusterImageExport {
export := &raczylocomv1.ClusterImageExport{
TypeMeta: metav1.TypeMeta{
APIVersion: "raczylo.com/v1",
Kind: "ClusterImageExport",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: testNamespace,
UID: "test-uid-export",
},
Spec: raczylocomv1.ClusterImageExportSpec{
Name: name,
BasePath: "/backup",
Storage: raczylocomv1.ClusterImageStorageSpec{
StorageTarget: shared.STORAGE_S3,
S3: raczylocomv1.ClusterImageStorageS3{
Bucket: "test-bucket",
Region: "us-east-1",
},
},
MaxConcurrentJobs: 5,
},
}
for _, opt := range opts {
opt(export)
}
return export
}
// Helper to create a test ClusterImage
func (s *ControllerTestSuite) createClusterImage(name, exportName string, opts ...func(*raczylocomv1.ClusterImage)) *raczylocomv1.ClusterImage {
image := &raczylocomv1.ClusterImage{
TypeMeta: metav1.TypeMeta{
APIVersion: "raczylo.com/v1",
Kind: "ClusterImage",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: testNamespace,
UID: "test-uid-image",
},
Spec: raczylocomv1.ClusterImageSpec{
Image: "nginx",
Tag: "latest",
FullName: "nginx:latest",
Storage: shared.STORAGE_S3,
ExportName: exportName,
ExportPath: "/backup",
},
}
for _, opt := range opts {
opt(image)
}
return image
}
// ==================== ClusterImage Controller Tests ====================
func (s *ControllerTestSuite) TestClusterImageReconcile_NotFound() {
// Scenario: Good - resource doesn't exist, nothing to do
client := s.newFakeClient()
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "non-existent",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
}
func (s *ControllerTestSuite) TestClusterImageReconcile_InitialStatus_Pending() {
// Scenario: Good - new ClusterImage should be set to PENDING
export := s.createClusterImageExport("test-export")
image := s.createClusterImage("test-image", "test-export")
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
require.NoError(s.T(), err)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
// Verify status was updated to PENDING
updatedImage := &raczylocomv1.ClusterImage{}
err = client.Get(s.ctx, types.NamespacedName{Name: "test-image", Namespace: "default"}, updatedImage)
require.NoError(s.T(), err)
assert.Equal(s.T(), shared.STATUS_PENDING, updatedImage.Status.Progress)
}
func (s *ControllerTestSuite) TestClusterImageReconcile_MissingExport() {
// Scenario: Not Good - ClusterImageExport doesn't exist
image := s.createClusterImage("test-image", "non-existent-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_PENDING
})
client := s.newFakeClient(image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.Error(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
}
func (s *ControllerTestSuite) TestClusterImageReconcile_MaxParallelJobsReached() {
// Scenario: Good - should requeue when max jobs reached
export := s.createClusterImageExport("test-export")
image := s.createClusterImage("test-image", "test-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_PENDING
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
ActiveJobs: 5, // Already at max
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
require.NoError(s.T(), err)
assert.Equal(s.T(), time.Second*30, result.RequeueAfter)
}
func (s *ControllerTestSuite) TestClusterImageReconcile_SuccessStatus() {
// Scenario: Good - success status should not trigger further action
export := s.createClusterImageExport("test-export")
image := s.createClusterImage("test-image", "test-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_SUCCESS
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
}
func (s *ControllerTestSuite) TestClusterImageReconcile_FailedStatus() {
// Scenario: Good - failed status should not trigger further action
export := s.createClusterImageExport("test-export")
image := s.createClusterImage("test-image", "test-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_FAILED
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
}
func (s *ControllerTestSuite) TestClusterImageReconcile_PresentStatus() {
// Scenario: Good - present status should not trigger further action
export := s.createClusterImageExport("test-export")
image := s.createClusterImage("test-image", "test-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_PRESENT
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
}
// ==================== ClusterImageExport Controller Tests ====================
func (s *ControllerTestSuite) TestClusterImageExportReconcile_NotFound() {
// Scenario: Good - resource doesn't exist, nothing to do
client := s.newFakeClient()
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "non-existent",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
}
func (s *ControllerTestSuite) TestClusterImageExportReconcile_AddFinalizer() {
// Scenario: Good - should add finalizer to new export
export := s.createClusterImageExport("test-export")
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
require.NoError(s.T(), err)
// Verify finalizer was added
updatedExport := &raczylocomv1.ClusterImageExport{}
err = client.Get(s.ctx, types.NamespacedName{Name: "test-export", Namespace: "default"}, updatedExport)
require.NoError(s.T(), err)
assert.Contains(s.T(), updatedExport.Finalizers, clusterImageExportFinalizer)
}
func (s *ControllerTestSuite) TestClusterImageExportReconcile_AddCreationTimestamp() {
// Scenario: Good - should add creation timestamp annotation
export := s.createClusterImageExport("test-export")
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "test-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
require.NoError(s.T(), err)
// Verify annotation was added
updatedExport := &raczylocomv1.ClusterImageExport{}
err = client.Get(s.ctx, types.NamespacedName{Name: "test-export", Namespace: "default"}, updatedExport)
require.NoError(s.T(), err)
assert.NotNil(s.T(), updatedExport.Annotations)
_, exists := updatedExport.Annotations["export.raczylo.com/creation-timestamp"]
assert.True(s.T(), exists)
}
func (s *ControllerTestSuite) TestClusterImageExportReconcile_InjectPodAnnotations() {
// Scenario: Good - pod annotations should be injectable
reconciler := &ClusterImageExportReconciler{}
annotations := map[string]string{
"prometheus.io/scrape": "true",
"prometheus.io/port": "8080",
}
reconciler.InjectPodAnnotations(annotations)
assert.Equal(s.T(), annotations, reconciler.podAnnotations)
}
// ==================== Matrix Test Scenarios ====================
type ClusterImageScenario struct {
Name string
Scenario TestScenario
SetupFunc func(*ControllerTestSuite) (client.Client, *ClusterImageReconciler, reconcile.Request)
ExpectedError bool
ExpectedStatus string
Description string
}
func (s *ControllerTestSuite) TestClusterImageReconcile_MatrixScenarios() {
scenarios := []ClusterImageScenario{
{
Name: "new_image_initialization",
Scenario: ScenarioGood,
Description: "New ClusterImage should be initialized to PENDING",
SetupFunc: func(s *ControllerTestSuite) (client.Client, *ClusterImageReconciler, reconcile.Request) {
export := s.createClusterImageExport("export-1")
image := s.createClusterImage("image-1", "export-1")
c := s.newFakeClient(export, image)
r := &ClusterImageReconciler{Client: c, Scheme: s.scheme, MaxParallelJobs: 5}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "image-1", Namespace: "default"}}
return c, r, req
},
ExpectedError: false,
ExpectedStatus: shared.STATUS_PENDING,
},
{
Name: "missing_export_reference",
Scenario: ScenarioNotGood,
Description: "ClusterImage with missing export should error",
SetupFunc: func(s *ControllerTestSuite) (client.Client, *ClusterImageReconciler, reconcile.Request) {
image := s.createClusterImage("orphan-image", "missing-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_PENDING
})
c := s.newFakeClient(image)
r := &ClusterImageReconciler{Client: c, Scheme: s.scheme, MaxParallelJobs: 5}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "orphan-image", Namespace: "default"}}
return c, r, req
},
ExpectedError: true,
},
{
Name: "success_status_no_action",
Scenario: ScenarioGood,
Description: "ClusterImage with SUCCESS status should not trigger action",
SetupFunc: func(s *ControllerTestSuite) (client.Client, *ClusterImageReconciler, reconcile.Request) {
export := s.createClusterImageExport("export-2")
image := s.createClusterImage("success-image", "export-2", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_SUCCESS
})
c := s.newFakeClient(export, image)
r := &ClusterImageReconciler{Client: c, Scheme: s.scheme}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "success-image", Namespace: "default"}}
return c, r, req
},
ExpectedError: false,
ExpectedStatus: shared.STATUS_SUCCESS,
},
{
Name: "failed_status_no_action",
Scenario: ScenarioNotGood,
Description: "ClusterImage with FAILED status should not trigger action",
SetupFunc: func(s *ControllerTestSuite) (client.Client, *ClusterImageReconciler, reconcile.Request) {
export := s.createClusterImageExport("export-3")
image := s.createClusterImage("failed-image", "export-3", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_FAILED
})
c := s.newFakeClient(export, image)
r := &ClusterImageReconciler{Client: c, Scheme: s.scheme}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "failed-image", Namespace: "default"}}
return c, r, req
},
ExpectedError: false,
ExpectedStatus: shared.STATUS_FAILED,
},
{
Name: "empty_namespace",
Scenario: ScenarioReallyBad,
Description: "ClusterImage in empty namespace should be handled gracefully",
SetupFunc: func(s *ControllerTestSuite) (client.Client, *ClusterImageReconciler, reconcile.Request) {
c := s.newFakeClient()
r := &ClusterImageReconciler{Client: c, Scheme: s.scheme}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "bad-image", Namespace: ""}}
return c, r, req
},
ExpectedError: false, // Not found, gracefully handled
},
}
for _, tc := range scenarios {
s.Run(tc.Name, func() {
client, reconciler, req := tc.SetupFunc(s)
result, err := reconciler.Reconcile(s.ctx, req)
if tc.ExpectedError {
assert.Error(s.T(), err, "Expected error for scenario: %s", tc.Name)
} else {
assert.NoError(s.T(), err, "Unexpected error for scenario: %s", tc.Name)
}
if tc.ExpectedStatus != "" && !tc.ExpectedError {
image := &raczylocomv1.ClusterImage{}
getErr := client.Get(s.ctx, req.NamespacedName, image)
if getErr == nil {
assert.Equal(s.T(), tc.ExpectedStatus, image.Status.Progress)
}
}
_ = result // Result validation varies by scenario
})
}
}
// ==================== Kubernetes Volatility Tests ====================
func (s *ControllerTestSuite) TestClusterImage_ConcurrentUpdates() {
// Scenario: Good - simulate concurrent reconciliation on PENDING status
// This test verifies that multiple reconciliations don't corrupt state
export := s.createClusterImageExport("concurrent-export")
image := s.createClusterImage("concurrent-image", "concurrent-export")
fakeClient := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: fakeClient,
Scheme: s.scheme,
MaxParallelJobs: 10,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "concurrent-image",
Namespace: "default",
},
}
// First reconciliation should set status to PENDING
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
// Verify status was set
finalImage := &raczylocomv1.ClusterImage{}
err = fakeClient.Get(s.ctx, req.NamespacedName, finalImage)
require.NoError(s.T(), err)
assert.Equal(s.T(), shared.STATUS_PENDING, finalImage.Status.Progress)
}
func (s *ControllerTestSuite) TestClusterImage_ActiveJobsMutex() {
// Scenario: Good - verify mutex protects ActiveJobs counter
reconciler := &ClusterImageReconciler{
MaxParallelJobs: 10,
ActiveJobs: 0,
}
done := make(chan bool)
iterations := 100
// Concurrent increments
go func() {
for i := 0; i < iterations; i++ {
reconciler.activeJobsMu.Lock()
reconciler.ActiveJobs++
reconciler.activeJobsMu.Unlock()
}
done <- true
}()
// Concurrent decrements
go func() {
for i := 0; i < iterations; i++ {
reconciler.activeJobsMu.Lock()
reconciler.ActiveJobs--
reconciler.activeJobsMu.Unlock()
}
done <- true
}()
<-done
<-done
// Final count should be 0
reconciler.activeJobsMu.Lock()
finalCount := reconciler.ActiveJobs
reconciler.activeJobsMu.Unlock()
assert.Equal(s.T(), 0, finalCount)
}
func (s *ControllerTestSuite) TestClusterImageExport_WithDeletionTimestamp() {
// Scenario: Good - export being deleted should trigger cleanup
export := s.createClusterImageExport("deleting-export")
now := metav1.Now()
export.DeletionTimestamp = &now
export.Finalizers = []string{clusterImageExportFinalizer}
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "deleting-export",
Namespace: "default",
},
}
// This should trigger deletion handling
result, err := reconciler.Reconcile(s.ctx, req)
// May error due to cleanup job creation, but should not panic
_ = err
_ = result
}
// ==================== Image Parsing Scenarios (Controller Integration) ====================
func (s *ControllerTestSuite) TestClusterImage_SHAPinnedImages() {
// Scenario: Good - SHA-pinned images should be handled correctly
export := s.createClusterImageExport("sha-export")
image := s.createClusterImage("sha-image", "sha-export", func(i *raczylocomv1.ClusterImage) {
i.Spec.Image = "quay.io/cilium/cilium"
i.Spec.Tag = "v1.18.4"
i.Spec.Sha = "sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f"
i.Spec.FullName = "quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f"
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "sha-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
}
func (s *ControllerTestSuite) TestClusterImage_MultipleRegistries() {
// Scenario: Good - images from different registries
registries := []struct {
name string
image string
fullName string
}{
{"gcr-image", "gcr.io/distroless/static", "gcr.io/distroless/static:nonroot"},
{"ecr-image", "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp", "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0"},
{"dockerhub-image", "library/nginx", "nginx:latest"},
{"quay-image", "quay.io/coreos/etcd", "quay.io/coreos/etcd:v3.5.0"},
}
export := s.createClusterImageExport("multi-registry-export")
objs := make([]client.Object, 0, 1+len(registries))
objs = append(objs, export)
for _, reg := range registries {
img := s.createClusterImage(reg.name, "multi-registry-export", func(i *raczylocomv1.ClusterImage) {
i.Spec.Image = reg.image
i.Spec.FullName = reg.fullName
})
objs = append(objs, img)
}
client := s.newFakeClient(objs...)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 10,
}
for _, reg := range registries {
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: reg.name,
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err, "Failed for registry: %s", reg.name)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
}
}
// ==================== Storage Configuration Tests ====================
func (s *ControllerTestSuite) TestClusterImageExport_S3Storage() {
// Scenario: Good - S3 storage configuration
export := s.createClusterImageExport("s3-export", func(e *raczylocomv1.ClusterImageExport) {
e.Spec.Storage = raczylocomv1.ClusterImageStorageSpec{
StorageTarget: shared.STORAGE_S3,
S3: raczylocomv1.ClusterImageStorageS3{
Bucket: "my-backup-bucket",
Region: "eu-west-1",
UseRole: true,
RoleARN: "arn:aws:iam::123456789:role/BackupRole",
Endpoint: "https://s3.eu-west-1.amazonaws.com",
},
}
})
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "s3-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
// Verify export was updated
updatedExport := &raczylocomv1.ClusterImageExport{}
err = client.Get(s.ctx, req.NamespacedName, updatedExport)
require.NoError(s.T(), err)
assert.Equal(s.T(), shared.STORAGE_S3, updatedExport.Spec.Storage.StorageTarget)
}
func (s *ControllerTestSuite) TestClusterImageExport_FileStorage() {
// Scenario: Good - File storage configuration
export := s.createClusterImageExport("file-export", func(e *raczylocomv1.ClusterImageExport) {
e.Spec.Storage = raczylocomv1.ClusterImageStorageSpec{
StorageTarget: shared.STORAGE_FILE,
}
e.Spec.BasePath = "/mnt/backup"
})
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "file-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
}
// ==================== Edge Cases ====================
func (s *ControllerTestSuite) TestClusterImage_EmptySpec() {
// Scenario: Really Bad - empty spec should be handled
export := s.createClusterImageExport("empty-spec-export")
image := &raczylocomv1.ClusterImage{
TypeMeta: metav1.TypeMeta{
APIVersion: "raczylo.com/v1",
Kind: "ClusterImage",
},
ObjectMeta: metav1.ObjectMeta{
Name: "empty-spec-image",
Namespace: "default",
UID: "test-uid",
},
Spec: raczylocomv1.ClusterImageSpec{
ExportName: "empty-spec-export",
},
}
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "empty-spec-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
// Should not error, just set to pending
assert.NoError(s.T(), err)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
}
func (s *ControllerTestSuite) TestClusterImageExport_EmptyNamespaces() {
// Scenario: Good - export with no namespace filters should process all
export := s.createClusterImageExport("all-ns-export", func(e *raczylocomv1.ClusterImageExport) {
e.Spec.Namespaces = []string{}
e.Spec.ExcludedNamespaces = []string{}
})
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "all-ns-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
}
func (s *ControllerTestSuite) TestClusterImageExport_WithAdditionalImages() {
// Scenario: Good - export with additional images specified
export := s.createClusterImageExport("additional-images-export", func(e *raczylocomv1.ClusterImageExport) {
e.Spec.AdditionalImages = []string{
"nginx:1.21",
"redis:7.0",
"postgres:15@sha256:abc123",
}
})
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "additional-images-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
}
func (s *ControllerTestSuite) TestClusterImageExport_WithIncludesExcludes() {
// Scenario: Good - export with include/exclude filters
export := s.createClusterImageExport("filtered-export", func(e *raczylocomv1.ClusterImageExport) {
e.Spec.Includes = []string{"nginx", "redis"}
e.Spec.Excludes = []string{"test", "dev"}
e.Spec.Namespaces = []string{"production", "staging"}
e.Spec.ExcludedNamespaces = []string{"kube-system"}
})
client := s.newFakeClient(export)
reconciler := &ClusterImageExportReconciler{
Client: client,
Scheme: s.scheme,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "filtered-export",
Namespace: "default",
},
}
_, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
}
func (s *ControllerTestSuite) TestClusterImage_WithImagePullSecrets() {
// Scenario: Good - image with pull secrets should work
export := s.createClusterImageExport("secret-export")
image := s.createClusterImage("secret-image", "secret-export", func(i *raczylocomv1.ClusterImage) {
i.Spec.ImagePullSecrets = []corev1.LocalObjectReference{
{Name: "docker-registry-secret"},
{Name: "gcr-json-key"},
}
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "secret-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
}
func (s *ControllerTestSuite) TestClusterImage_WithJobAnnotations() {
// Scenario: Good - image with job annotations
export := s.createClusterImageExport("annotated-export", func(e *raczylocomv1.ClusterImageExport) {
e.Spec.JobAnnotations = map[string]string{
"iam.amazonaws.com/role": "arn:aws:iam::123456789:role/BackupRole",
}
})
image := s.createClusterImage("annotated-image", "annotated-export", func(i *raczylocomv1.ClusterImage) {
i.Spec.JobAnnotations = map[string]string{
"custom/annotation": "value",
}
})
client := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: client,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "annotated-image",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.True(s.T(), result.Requeue) //nolint:staticcheck // testing controller's actual behavior
}
// ==================== Job Status Tests ====================
func (s *ControllerTestSuite) TestClusterImage_SuccessfulJobCompletion() {
// Scenario: Good - verify job success status detection logic
// Note: Full integration testing requires envtest for KubeClient
// This test validates the basic state machine transitions
export := s.createClusterImageExport("job-success-export")
image := s.createClusterImage("job-success-image", "job-success-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_SUCCESS // Already completed
})
fakeClient := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: fakeClient,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "job-success-image",
Namespace: "default",
},
}
// SUCCESS status should result in no-op
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
// Status should remain unchanged
finalImage := &raczylocomv1.ClusterImage{}
err = fakeClient.Get(s.ctx, req.NamespacedName, finalImage)
require.NoError(s.T(), err)
assert.Equal(s.T(), shared.STATUS_SUCCESS, finalImage.Status.Progress)
}
func (s *ControllerTestSuite) TestClusterImage_RetryCount() {
// Scenario: Good - verify retry count is tracked properly
export := s.createClusterImageExport("retry-export")
image := s.createClusterImage("retry-image", "retry-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_FAILED // Terminal state
i.Status.RetryCount = 2
})
fakeClient := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: fakeClient,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "retry-image",
Namespace: "default",
},
}
// FAILED status should result in no-op
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
// Verify retry count is preserved
finalImage := &raczylocomv1.ClusterImage{}
err = fakeClient.Get(s.ctx, req.NamespacedName, finalImage)
require.NoError(s.T(), err)
assert.Equal(s.T(), 2, finalImage.Status.RetryCount)
}
func (s *ControllerTestSuite) TestClusterImage_MaxRetriesReached() {
// Scenario: Not Good - max retries reached and FAILED status
export := s.createClusterImageExport("max-retry-export")
image := s.createClusterImage("max-retry-image", "max-retry-export", func(i *raczylocomv1.ClusterImage) {
i.Status.Progress = shared.STATUS_FAILED
i.Status.RetryCount = 3 // Max retries reached
})
fakeClient := s.newFakeClient(export, image)
reconciler := &ClusterImageReconciler{
Client: fakeClient,
Scheme: s.scheme,
MaxParallelJobs: 5,
}
req := reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "max-retry-image",
Namespace: "default",
},
}
// FAILED status with max retries should be terminal
result, err := reconciler.Reconcile(s.ctx, req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), ctrl.Result{}, result)
// Verify final state
finalImage := &raczylocomv1.ClusterImage{}
err = fakeClient.Get(s.ctx, req.NamespacedName, finalImage)
require.NoError(s.T(), err)
assert.Equal(s.T(), shared.STATUS_FAILED, finalImage.Status.Progress)
assert.Equal(s.T(), 3, finalImage.Status.RetryCount)
}