mirror of
https://github.com/lukaszraczylo/kubernetes-images-sync-operator.git
synced 2026-06-05 22:53:39 +00:00
1032 lines
32 KiB
Go
1032 lines
32 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 (
|
|
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, namespace 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: namespace,
|
|
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, namespace, 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: namespace,
|
|
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", "default")
|
|
image := s.createClusterImage("test-image", "default", "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) //lint:ignore SA1019 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", "default", "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", "default")
|
|
image := s.createClusterImage("test-image", "default", "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", "default")
|
|
image := s.createClusterImage("test-image", "default", "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", "default")
|
|
image := s.createClusterImage("test-image", "default", "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", "default")
|
|
image := s.createClusterImage("test-image", "default", "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", "default")
|
|
|
|
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", "default")
|
|
|
|
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", "default")
|
|
image := s.createClusterImage("image-1", "default", "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", "default", "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", "default")
|
|
image := s.createClusterImage("success-image", "default", "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", "default")
|
|
image := s.createClusterImage("failed-image", "default", "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", "default")
|
|
image := s.createClusterImage("concurrent-image", "default", "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) //lint:ignore SA1019 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", "default")
|
|
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", "default")
|
|
image := s.createClusterImage("sha-image", "default", "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) //lint:ignore SA1019 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", "default")
|
|
objs := []client.Object{export}
|
|
|
|
for _, reg := range registries {
|
|
img := s.createClusterImage(reg.name, "default", "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) //lint:ignore SA1019 testing controller's actual behavior
|
|
}
|
|
}
|
|
|
|
// ==================== Storage Configuration Tests ====================
|
|
|
|
func (s *ControllerTestSuite) TestClusterImageExport_S3Storage() {
|
|
// Scenario: Good - S3 storage configuration
|
|
export := s.createClusterImageExport("s3-export", "default", 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", "default", 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", "default")
|
|
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) //lint:ignore SA1019 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", "default", 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", "default", 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", "default", 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", "default")
|
|
image := s.createClusterImage("secret-image", "default", "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) //lint:ignore SA1019 testing controller's actual behavior
|
|
}
|
|
|
|
func (s *ControllerTestSuite) TestClusterImage_WithJobAnnotations() {
|
|
// Scenario: Good - image with job annotations
|
|
export := s.createClusterImageExport("annotated-export", "default", 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", "default", "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) //lint:ignore SA1019 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", "default")
|
|
image := s.createClusterImage("job-success-image", "default", "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", "default")
|
|
image := s.createClusterImage("retry-image", "default", "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", "default")
|
|
image := s.createClusterImage("max-retry-image", "default", "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)
|
|
}
|