new year update (#4)

* Bring operator to the brand new world of build and deployments.

* Clean up the code and basic improvements.

* More fixes, moving from python to golang worker.

* fixup! More fixes, moving from python to golang worker.

* fixup! fixup! More fixes, moving from python to golang worker.

* fixup! fixup! fixup! More fixes, moving from python to golang worker.

* fixup! fixup! fixup! fixup! More fixes, moving from python to golang worker.

* fixup! fixup! fixup! fixup! fixup! More fixes, moving from python to golang worker.

* fixup! fixup! fixup! fixup! fixup! fixup! More fixes, moving from python to golang worker.
This commit is contained in:
2025-12-18 14:41:24 +00:00
committed by GitHub
parent 067a51c9c7
commit 3880af56a7
56 changed files with 5829 additions and 1118 deletions
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/go-logr/logr"
@@ -13,8 +14,9 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/kubernetes"
"k8s.io/utils/pointer"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
@@ -28,6 +30,7 @@ type ClusterImageReconciler struct {
Scheme *runtime.Scheme
MaxParallelJobs int
ActiveJobs int
activeJobsMu sync.Mutex // protects ActiveJobs counter
KubeClient *kubernetes.Clientset
}
@@ -66,7 +69,7 @@ func (r *ClusterImageReconciler) Reconcile(ctx context.Context, req ctrl.Request
if err := r.Get(ctx, req.NamespacedName, latest); err != nil {
return ctrl.Result{}, err
}
latest.Status.Progress = shared.STATUS_PENDING
if err := r.Status().Update(ctx, latest); err != nil {
if errors.IsConflict(err) {
@@ -80,7 +83,10 @@ func (r *ClusterImageReconciler) Reconcile(ctx context.Context, req ctrl.Request
}
// If we've reached the maximum number of parallel jobs, requeue
if r.ActiveJobs >= r.MaxParallelJobs && clusterImage.Status.Progress == shared.STATUS_PENDING {
r.activeJobsMu.Lock()
activeJobs := r.ActiveJobs
r.activeJobsMu.Unlock()
if activeJobs >= r.MaxParallelJobs && clusterImage.Status.Progress == shared.STATUS_PENDING {
return ctrl.Result{RequeueAfter: time.Second * 30}, nil
}
@@ -117,7 +123,7 @@ func (r *ClusterImageReconciler) handlePendingClusterImage(ctx context.Context,
if err := r.Get(ctx, types.NamespacedName{Name: clusterImage.Name, Namespace: clusterImage.Namespace}, latest); err != nil {
return ctrl.Result{}, err
}
latest.Status.Progress = shared.STATUS_PRESENT
if err := r.Status().Update(ctx, latest); err != nil {
if errors.IsConflict(err) {
@@ -151,7 +157,9 @@ func (r *ClusterImageReconciler) handlePendingClusterImage(ctx context.Context,
}
// Increment the active jobs count
r.activeJobsMu.Lock()
r.ActiveJobs++
r.activeJobsMu.Unlock()
return ctrl.Result{Requeue: true}, nil
}
@@ -217,7 +225,9 @@ func (r *ClusterImageReconciler) handleRunningClusterImage(ctx context.Context,
}
latest.Status.Progress = shared.STATUS_SUCCESS
r.activeJobsMu.Lock()
r.ActiveJobs--
r.activeJobsMu.Unlock()
// Update the status before cleaning up the job
if err := r.Status().Update(ctx, latest); err != nil {
if errors.IsConflict(err) {
@@ -232,7 +242,9 @@ func (r *ClusterImageReconciler) handleRunningClusterImage(ctx context.Context,
return ctrl.Result{}, err
}
} else if existingJob.Status.Failed > 0 {
r.activeJobsMu.Lock()
r.ActiveJobs--
r.activeJobsMu.Unlock()
if clusterImage.Status.RetryCount < 3 {
// Cleanup the failed job before retrying
if err := r.cleanupJobAndPods(ctx, existingJob); err != nil {
@@ -300,8 +312,22 @@ func (r *ClusterImageReconciler) handleRunningClusterImage(ctx context.Context,
return r.updateClusterImageExportStatus(ctx, clusterImage)
}
func (r *ClusterImageReconciler) cleanupJobAndPods(ctx context.Context, job *v1batch.Job) error {
// Add a short delay to allow status updates to propagate
time.Sleep(2 * time.Second)
// Wait for job status to propagate before deletion
jobKey := types.NamespacedName{Name: job.Name, Namespace: job.Namespace}
err := wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (done bool, err error) {
currentJob := &v1batch.Job{}
if err := r.Get(ctx, jobKey, currentJob); err != nil {
if errors.IsNotFound(err) {
return true, nil // Job already deleted
}
return false, nil // Retry on transient errors
}
// Job status has been updated, proceed with deletion
return currentJob.Status.Active == 0, nil
})
if err != nil && !errors.IsNotFound(err) && err != context.DeadlineExceeded {
return fmt.Errorf("failed to wait for job status: %w", err)
}
// Delete the job
if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !errors.IsNotFound(err) {
@@ -334,12 +360,12 @@ func (r *ClusterImageReconciler) createBackupJob(ctx context.Context, clusterIma
if clusterImage.Spec.Storage == shared.STORAGE_S3 {
s3Params := shared.SetupS3Params(clusterImageExport.Spec.Storage.S3)
additionalCommands := []string{
"./export.py " + strings.Join(s3Params, " ") + " '/tmp/" + normalisedImageName + ".tar' " + "'s3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar'",
"./worker export " + strings.Join(s3Params, " ") + " '/tmp/" + normalisedImageName + ".tar' " + "'s3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar'",
}
defaultCommands = append(defaultCommands, additionalCommands...)
} else if clusterImage.Spec.Storage == shared.STORAGE_FILE {
additionalCommands := []string{
"./export.py /tmp/" + normalisedImageName + ".tar" + " " + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar",
"./worker export '/tmp/" + normalisedImageName + ".tar' '" + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar'",
}
defaultCommands = append(defaultCommands, additionalCommands...)
}
@@ -374,8 +400,8 @@ func (r *ClusterImageReconciler) createBackupJob(ctx context.Context, clusterIma
Kind: clusterImage.Kind,
Name: clusterImage.Name,
UID: clusterImage.UID,
BlockOwnerDeletion: pointer.Bool(true),
Controller: pointer.Bool(true),
BlockOwnerDeletion: ptr.To(true),
Controller: ptr.To(true),
},
},
}
@@ -33,38 +33,73 @@ import (
var _ = Describe("ClusterImage Controller", func() {
Context("When reconciling a resource", func() {
const resourceName = "test-resource"
const exportName = "test-export"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default", // TODO(user):Modify as needed
Namespace: "default",
}
exportNamespacedName := types.NamespacedName{
Name: exportName,
Namespace: "default",
}
clusterimage := &raczylocomv1.ClusterImage{}
BeforeEach(func() {
By("creating the ClusterImageExport that the ClusterImage references")
export := &raczylocomv1.ClusterImageExport{}
err := k8sClient.Get(ctx, exportNamespacedName, export)
if err != nil && errors.IsNotFound(err) {
exportResource := &raczylocomv1.ClusterImageExport{
ObjectMeta: metav1.ObjectMeta{
Name: exportName,
Namespace: "default",
},
Spec: raczylocomv1.ClusterImageExportSpec{
Name: exportName,
BasePath: "/backups/test",
MaxConcurrentJobs: 1,
Storage: raczylocomv1.ClusterImageStorageSpec{
StorageTarget: "FILE",
},
},
}
Expect(k8sClient.Create(ctx, exportResource)).To(Succeed())
}
By("creating the custom resource for the Kind ClusterImage")
err := k8sClient.Get(ctx, typeNamespacedName, clusterimage)
err = k8sClient.Get(ctx, typeNamespacedName, clusterimage)
if err != nil && errors.IsNotFound(err) {
resource := &raczylocomv1.ClusterImage{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
// TODO(user): Specify other spec details if needed.
Spec: raczylocomv1.ClusterImageSpec{
ExportName: exportName,
Image: "nginx:latest",
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
})
AfterEach(func() {
// TODO(user): Cleanup logic after each test, like removing the resource instance.
By("Cleanup the specific resource instance ClusterImage")
resource := &raczylocomv1.ClusterImage{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
Expect(err).NotTo(HaveOccurred())
if err == nil {
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
}
By("Cleanup the specific resource instance ClusterImage")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
By("Cleanup the ClusterImageExport")
export := &raczylocomv1.ClusterImageExport{}
err = k8sClient.Get(ctx, exportNamespacedName, export)
if err == nil {
Expect(k8sClient.Delete(ctx, export)).To(Succeed())
}
})
It("should successfully reconcile the resource", func() {
By("Reconciling the created resource")
@@ -2,9 +2,10 @@ package raczylocom
import (
"context"
"crypto/md5"
"crypto/md5" // #nosec G501 - MD5 used for non-cryptographic unique identifiers only
"fmt"
"strings"
"time"
"github.com/go-logr/logr"
appsv1 "k8s.io/api/apps/v1"
@@ -14,7 +15,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/util/retry"
"k8s.io/utils/pointer"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
@@ -27,7 +28,7 @@ import (
// ClusterImageExportReconciler reconciles a ClusterImageExport object
type ClusterImageExportReconciler struct {
client.Client
Scheme *runtime.Scheme
Scheme *runtime.Scheme
podAnnotations map[string]string
}
@@ -61,6 +62,19 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
return r.handleDeletion(ctx, clusterImageExport)
}
// Check if this export should be deleted by TTL
if r.shouldDeleteByTTL(clusterImageExport) {
l.Info("Deleting export due to TTL expiration",
"export", clusterImageExport.Name,
"ttlDays", *clusterImageExport.Spec.TTLDaysAfterFinished,
"completedAt", clusterImageExport.Status.CompletedAt)
if err := r.Delete(ctx, clusterImageExport); err != nil && !errors.IsNotFound(err) {
l.Error(err, "Failed to delete export by TTL")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// Add finalizer and creation timestamp annotation if they don't exist
needsUpdate := false
if !controllerutil.ContainsFinalizer(clusterImageExport, clusterImageExportFinalizer) {
@@ -121,6 +135,7 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
for _, image := range fullImagesList.Containers {
// Include creation timestamp in the hash to differentiate between exports with the same name
// #nosec G401 - MD5 used for non-cryptographic unique identifier generation, not security
nameHash := fmt.Sprintf("%x", md5.Sum([]byte(clusterImageExport.Name+image.Image+image.Tag+image.Sha+
clusterImageExport.Annotations["export.raczylo.com/creation-timestamp"])))[:14]
@@ -156,7 +171,7 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
Kind: clusterImageExport.Kind,
Name: clusterImageExport.Name,
UID: clusterImageExport.UID,
Controller: pointer.Bool(true),
Controller: ptr.To(true),
},
},
},
@@ -185,7 +200,7 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
failedCount := 0
pendingCount := 0
clusterImageList := &raczylocomv1.ClusterImageList{}
if err := r.List(ctx, clusterImageList, client.InNamespace(clusterImageExport.Namespace),
if err := r.List(ctx, clusterImageList, client.InNamespace(clusterImageExport.Namespace),
client.MatchingFields{"spec.exportName": clusterImageExport.Name}); err != nil {
l.Error(err, "unable to list ClusterImages")
return ctrl.Result{}, err
@@ -213,6 +228,11 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
} else {
export.Status.Progress = shared.STATUS_SUCCESS
}
// Set CompletedAt timestamp when export completes
if export.Status.CompletedAt == nil {
now := metav1.Now()
export.Status.CompletedAt = &now
}
}
return nil
}); err != nil {
@@ -220,6 +240,15 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
return ctrl.Result{}, err
}
// If export is complete, run retention cleanup
if clusterImageExport.Status.Progress == shared.STATUS_SUCCESS ||
clusterImageExport.Status.Progress == shared.STATUS_FAILED {
if err := r.cleanupByRetention(ctx, clusterImageExport); err != nil {
l.Error(err, "Failed to cleanup by retention policy")
// Don't return error - this is non-critical
}
}
// If there are still pending images, requeue
if pendingCount > 0 {
return ctrl.Result{Requeue: true}, nil
@@ -250,21 +279,6 @@ func (r *ClusterImageExportReconciler) updateStatusWithRetry(ctx context.Context
})
}
func (r *ClusterImageExportReconciler) checkAllClusterImagesCompleted(ctx context.Context, clusterImageExport *raczylocomv1.ClusterImageExport) (bool, error) {
clusterImageList := &raczylocomv1.ClusterImageList{}
if err := r.List(ctx, clusterImageList, client.InNamespace(clusterImageExport.Namespace), client.MatchingFields{"spec.exportName": clusterImageExport.Name}); err != nil {
return false, err
}
for _, ci := range clusterImageList.Items {
if ci.Status.Progress != shared.STATUS_SUCCESS && ci.Status.Progress != shared.STATUS_PRESENT {
return false, nil
}
}
return true, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *ClusterImageExportReconciler) SetupWithManager(mgr ctrl.Manager) error {
@@ -398,18 +412,18 @@ func (r *ClusterImageExportReconciler) runCleanupJob(ctx context.Context, cluste
if clusterImageExport.Spec.Storage.StorageTarget == shared.STORAGE_S3 {
s3Params := shared.SetupS3Params(clusterImageExport.Spec.Storage.S3)
additionalCommands := []string{
"./cleanup.py " + strings.Join(s3Params, " ") + " 's3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
"./worker cleanup " + strings.Join(s3Params, " ") + " 's3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
}
defaultCommands = append(defaultCommands, additionalCommands...)
} else if clusterImageExport.Spec.Storage.StorageTarget == shared.STORAGE_FILE {
additionalCommands := []string{
"./cleanup.py" + "'" + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
"./worker cleanup '" + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
}
defaultCommands = append(defaultCommands, additionalCommands...)
}
// Set up the cleanup job with retry limits and TTL
backoffLimit := int32(2) // 3 total attempts (initial + 2 retries)
backoffLimit := int32(2) // 3 total attempts (initial + 2 retries)
ttlSecondsAfterFinished := int32(300) // Delete job 5 minutes after completion
// Merge annotations from different sources
@@ -443,16 +457,16 @@ func (r *ClusterImageExportReconciler) runCleanupJob(ctx context.Context, cluste
}
jobParams := shared.JobParams{
Name: normalisedImageName,
Namespace: clusterImageExport.Namespace,
Image: shared.BACKUP_JOB_IMAGE,
Commands: defaultCommands,
Annotations: mergedAnnotations,
ServiceAccount: "",
ImagePullSecrets: clusterImageExport.Spec.ImagePullSecrets,
BackoffLimit: &backoffLimit,
Name: normalisedImageName,
Namespace: clusterImageExport.Namespace,
Image: shared.BACKUP_JOB_IMAGE,
Commands: defaultCommands,
Annotations: mergedAnnotations,
ServiceAccount: "",
ImagePullSecrets: clusterImageExport.Spec.ImagePullSecrets,
BackoffLimit: &backoffLimit,
TTLSecondsAfterFinished: &ttlSecondsAfterFinished,
EnvVars: envVars,
EnvVars: envVars,
}
cleanupJob := shared.CreateJob(jobParams, func(raczylocomv1.ClusterImageExport) []string { return nil })
@@ -466,3 +480,110 @@ func (r *ClusterImageExportReconciler) runCleanupJob(ctx context.Context, cluste
l.Info("Created cleanup job with retry limit and TTL")
return nil
}
// shouldDeleteByTTL checks if the export should be deleted based on TTL (in days)
func (r *ClusterImageExportReconciler) shouldDeleteByTTL(clusterImageExport *raczylocomv1.ClusterImageExport) bool {
// Only apply TTL to completed exports
if clusterImageExport.Status.Progress != shared.STATUS_SUCCESS &&
clusterImageExport.Status.Progress != shared.STATUS_FAILED {
return false
}
// Check if TTL is configured
if clusterImageExport.Spec.TTLDaysAfterFinished == nil {
return false
}
// Check if CompletedAt is set
if clusterImageExport.Status.CompletedAt == nil {
return false
}
// Convert days to duration (24 hours per day)
ttlDuration := time.Duration(*clusterImageExport.Spec.TTLDaysAfterFinished) * 24 * time.Hour
expirationTime := clusterImageExport.Status.CompletedAt.Add(ttlDuration)
return time.Now().After(expirationTime)
}
// cleanupByRetention enforces the retention policy for completed exports
func (r *ClusterImageExportReconciler) cleanupByRetention(ctx context.Context, clusterImageExport *raczylocomv1.ClusterImageExport) error {
l := log.FromContext(ctx)
// Check if retention policy is configured
if clusterImageExport.Spec.Retention == nil {
return nil
}
// List all ClusterImageExports in the same namespace
exportList := &raczylocomv1.ClusterImageExportList{}
if err := r.List(ctx, exportList, client.InNamespace(clusterImageExport.Namespace)); err != nil {
return fmt.Errorf("failed to list ClusterImageExports: %w", err)
}
// Separate successful and failed exports, sorted by completion time
var successfulExports, failedExports []*raczylocomv1.ClusterImageExport
for i := range exportList.Items {
export := &exportList.Items[i]
// Skip exports that don't have the same base path (different backup sets)
if export.Spec.BasePath != clusterImageExport.Spec.BasePath {
continue
}
// Skip exports that are still running
if export.Status.Progress != shared.STATUS_SUCCESS &&
export.Status.Progress != shared.STATUS_FAILED {
continue
}
if export.Status.Progress == shared.STATUS_SUCCESS {
successfulExports = append(successfulExports, export)
} else if export.Status.Progress == shared.STATUS_FAILED {
failedExports = append(failedExports, export)
}
}
// Sort by CompletedAt (newest first)
sortByCompletionTime := func(exports []*raczylocomv1.ClusterImageExport) {
for i := 0; i < len(exports); i++ {
for j := i + 1; j < len(exports); j++ {
iTime := exports[i].Status.CompletedAt
jTime := exports[j].Status.CompletedAt
if iTime == nil || (jTime != nil && jTime.After(iTime.Time)) {
exports[i], exports[j] = exports[j], exports[i]
}
}
}
}
sortByCompletionTime(successfulExports)
sortByCompletionTime(failedExports)
// Delete excess successful exports
if clusterImageExport.Spec.Retention.MaxSuccessful != nil {
maxSuccessful := int(*clusterImageExport.Spec.Retention.MaxSuccessful)
if len(successfulExports) > maxSuccessful {
for _, export := range successfulExports[maxSuccessful:] {
l.Info("Deleting export due to retention policy (maxSuccessful exceeded)",
"export", export.Name, "maxSuccessful", maxSuccessful)
if err := r.Delete(ctx, export); err != nil && !errors.IsNotFound(err) {
l.Error(err, "Failed to delete export for retention", "export", export.Name)
}
}
}
}
// Delete excess failed exports
if clusterImageExport.Spec.Retention.MaxFailed != nil {
maxFailed := int(*clusterImageExport.Spec.Retention.MaxFailed)
if len(failedExports) > maxFailed {
for _, export := range failedExports[maxFailed:] {
l.Info("Deleting export due to retention policy (maxFailed exceeded)",
"export", export.Name, "maxFailed", maxFailed)
if err := r.Delete(ctx, export); err != nil && !errors.IsNotFound(err) {
l.Error(err, "Failed to delete export for retention", "export", export.Name)
}
}
}
}
return nil
}
@@ -51,20 +51,26 @@ var _ = Describe("ClusterImageExport Controller", func() {
Name: resourceName,
Namespace: "default",
},
// TODO(user): Specify other spec details if needed.
Spec: raczylocomv1.ClusterImageExportSpec{
Name: resourceName,
BasePath: "/backups/test",
MaxConcurrentJobs: 1,
Storage: raczylocomv1.ClusterImageStorageSpec{
StorageTarget: "FILE",
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
})
AfterEach(func() {
// TODO(user): Cleanup logic after each test, like removing the resource instance.
By("Cleanup the specific resource instance ClusterImageExport")
resource := &raczylocomv1.ClusterImageExport{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
Expect(err).NotTo(HaveOccurred())
By("Cleanup the specific resource instance ClusterImageExport")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
if err == nil {
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
}
})
It("should successfully reconcile the resource", func() {
By("Reconciling the created resource")
File diff suppressed because it is too large Load Diff
+43 -9
View File
@@ -19,6 +19,7 @@ package raczylocom
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
@@ -28,6 +29,8 @@ import (
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
@@ -58,17 +61,18 @@ var _ = BeforeSuite(func() {
ctx, cancel = context.WithCancel(context.TODO())
By("bootstrapping test environment")
// Use KUBEBUILDER_ASSETS if set (CI), otherwise fall back to local path
binaryAssetsDir := os.Getenv("KUBEBUILDER_ASSETS")
if binaryAssetsDir == "" {
binaryAssetsDir = filepath.Join("..", "..", "..", "bin", "k8s",
fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH))
}
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
// The BinaryAssetsDirectory is only required if you want to run the tests directly
// without call the makefile target test. If not informed it will look for the
// default path defined in controller-runtime which is /usr/local/kubebuilder/.
// Note that you must have the required binaries setup under the bin directory to perform
// the tests directly. When we run make test it will be setup and used automatically.
BinaryAssetsDirectory: filepath.Join("..", "..", "..", "bin", "k8s",
fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)),
BinaryAssetsDirectory: binaryAssetsDir,
}
var err error
@@ -82,8 +86,38 @@ var _ = BeforeSuite(func() {
// +kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
// Create a manager to get a cache-backed client that supports field selectors
// Explicitly configure cache to watch ClusterImage and ClusterImageExport resources
// This is required for field selectors to work in tests (without registering controllers)
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Scheme: scheme.Scheme,
Cache: cache.Options{
ByObject: map[client.Object]cache.ByObject{
&raczylocomv1.ClusterImage{}: {},
&raczylocomv1.ClusterImageExport{}: {},
},
},
})
Expect(err).NotTo(HaveOccurred())
// Add field index for spec.exportName on ClusterImage
err = mgr.GetFieldIndexer().IndexField(ctx, &raczylocomv1.ClusterImage{}, "spec.exportName", func(obj client.Object) []string {
clusterImage := obj.(*raczylocomv1.ClusterImage)
return []string{clusterImage.Spec.ExportName}
})
Expect(err).NotTo(HaveOccurred())
// Start the manager cache in background
go func() {
defer GinkgoRecover()
err := mgr.Start(ctx)
Expect(err).NotTo(HaveOccurred())
}()
// Wait for cache to sync
Expect(mgr.GetCache().WaitForCacheSync(ctx)).To(BeTrue())
k8sClient = mgr.GetClient()
Expect(k8sClient).NotTo(BeNil())
})
+4 -4
View File
@@ -10,10 +10,10 @@ import (
var BACKUP_JOB_IMAGE string
func init() {
BACKUP_JOB_IMAGE = os.Getenv("WORKER_IMAGE")
if BACKUP_JOB_IMAGE == "" {
BACKUP_JOB_IMAGE = "ghcr.io/lukaszraczylo/kubernetes-images-sync-worker:latest" // fallback
}
BACKUP_JOB_IMAGE = os.Getenv("WORKER_IMAGE")
if BACKUP_JOB_IMAGE == "" {
BACKUP_JOB_IMAGE = "ghcr.io/lukaszraczylo/kubernetes-images-sync-worker:0.5.54" // fallback to known stable version
}
}
const (
+643
View File
@@ -0,0 +1,643 @@
package shared
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// TestScenario represents a test scenario classification
type TestScenario string
const (
ScenarioGood TestScenario = "good"
ScenarioNotGood TestScenario = "not_good"
ScenarioReallyBad TestScenario = "really_bad"
)
// DefinitionsTestSuite tests the definitions and utility functions
type DefinitionsTestSuite struct {
suite.Suite
}
func TestDefinitionsTestSuite(t *testing.T) {
suite.Run(t, new(DefinitionsTestSuite))
}
// TestNormalizeImageName tests the NormalizeImageName function with matrix strategy
func (s *DefinitionsTestSuite) TestNormalizeImageName() {
testCases := []struct {
name string
scenario TestScenario
input string
expected string
}{
// Good scenarios - standard image names
{
name: "simple image name",
scenario: ScenarioGood,
input: "nginx",
expected: "nginx",
},
{
name: "image with tag",
scenario: ScenarioGood,
input: "nginx:latest",
expected: "nginx-latest",
},
{
name: "image with version tag",
scenario: ScenarioGood,
input: "nginx:1.21.0",
expected: "nginx-1.21.0",
},
{
name: "full registry path",
scenario: ScenarioGood,
input: "quay.io/cilium/cilium:v1.18.4",
expected: "quay.io-cilium-cilium-v1.18.4",
},
{
name: "ghcr registry",
scenario: ScenarioGood,
input: "ghcr.io/owner/repo:v1.0.0",
expected: "ghcr.io-owner-repo-v1.0.0",
},
// Not good scenarios - unusual but valid formats
{
name: "image with SHA digest",
scenario: ScenarioNotGood,
input: "nginx@sha256:abc123def456",
expected: "nginx-sha256-abc123def456",
},
{
name: "image with tag and SHA",
scenario: ScenarioNotGood,
input: "quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
expected: "quay.io-cilium-cilium-v1.18.4-sha256-49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
},
{
name: "multiple colons in path",
scenario: ScenarioNotGood,
input: "registry:5000/image:tag",
expected: "registry-5000-image-tag",
},
// Really bad scenarios - edge cases and potential problems
{
name: "empty string",
scenario: ScenarioReallyBad,
input: "",
expected: "",
},
{
name: "only special characters",
scenario: ScenarioReallyBad,
input: ":///@",
expected: "",
},
{
name: "multiple consecutive special chars",
scenario: ScenarioReallyBad,
input: "image:::tag",
expected: "image-tag",
},
{
name: "leading special characters",
scenario: ScenarioReallyBad,
input: "//image:tag",
expected: "image-tag",
},
{
name: "trailing special characters",
scenario: ScenarioReallyBad,
input: "image:tag//",
expected: "image-tag",
},
{
name: "spaces in name",
scenario: ScenarioReallyBad,
input: "image name:tag",
expected: "image-name-tag",
},
{
name: "unicode characters",
scenario: ScenarioReallyBad,
input: "image:tag-日本語",
expected: "image-tag-日本語",
},
{
name: "very long image name",
scenario: ScenarioReallyBad,
input: "registry.example.com/very/long/path/to/image:tag@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
expected: "registry.example.com-very-long-path-to-image-tag-sha256-abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
},
{
name: "query string characters",
scenario: ScenarioReallyBad,
input: "image?foo=bar&baz=qux",
expected: "image-foo-bar-baz-qux",
},
{
name: "brackets and special chars",
scenario: ScenarioReallyBad,
input: "image[tag]{version}",
expected: "image-tag-version",
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
result := NormalizeImageName(tc.input)
assert.Equal(s.T(), tc.expected, result, "Scenario: %s", tc.scenario)
})
}
}
// TestRemoveDuplicates tests duplicate removal functionality
func (s *DefinitionsTestSuite) TestRemoveDuplicates() {
testCases := []struct {
name string
scenario TestScenario
input ContainersList
expected int
}{
// Good scenarios
{
name: "no duplicates",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "redis", Tag: "6", FullName: "redis:6"},
},
},
expected: 2,
},
{
name: "with duplicates",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "redis", Tag: "6", FullName: "redis:6"},
},
},
expected: 2,
},
// Not good scenarios
{
name: "same image different tags",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "nginx", Tag: "1.21", FullName: "nginx:1.21"},
},
},
expected: 2, // Should keep both
},
{
name: "same image with and without SHA",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "nginx", Tag: "latest", Sha: "sha256:abc", FullName: "nginx:latest@sha256:abc"},
},
},
expected: 2, // Different because of SHA
},
// Really bad scenarios
{
name: "empty list",
scenario: ScenarioReallyBad,
input: ContainersList{Containers: []Container{}},
expected: 0,
},
{
name: "all duplicates",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
},
},
expected: 1,
},
{
name: "nil containers",
scenario: ScenarioReallyBad,
input: ContainersList{Containers: nil},
expected: 0,
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
result := RemoveDuplicates(tc.input)
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
})
}
}
// TestRemoveExcludedImages tests image exclusion functionality
func (s *DefinitionsTestSuite) TestRemoveExcludedImages() {
testCases := []struct {
name string
scenario TestScenario
input ContainersList
excludes []string
expected int
}{
// Good scenarios
{
name: "no exclusions",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
{Image: "redis", Tag: "6"},
},
},
excludes: []string{},
expected: 2,
},
{
name: "exclude one image",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
{Image: "redis", Tag: "6"},
},
},
excludes: []string{"nginx"},
expected: 1,
},
{
name: "exclude by registry",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "gcr.io/google/nginx", Tag: "latest"},
{Image: "docker.io/library/redis", Tag: "6"},
},
},
excludes: []string{"gcr.io"},
expected: 1,
},
// Not good scenarios
{
name: "case insensitive exclusion",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "NGINX", Tag: "latest"},
{Image: "Redis", Tag: "6"},
},
},
excludes: []string{"nginx"},
expected: 1, // Should exclude NGINX
},
{
name: "partial match exclusion",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "my-nginx-custom", Tag: "latest"},
{Image: "redis", Tag: "6"},
},
},
excludes: []string{"nginx"},
expected: 1, // Should exclude my-nginx-custom
},
// Really bad scenarios
{
name: "exclude all images",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
{Image: "redis", Tag: "6"},
},
},
excludes: []string{"nginx", "redis"},
expected: 0,
},
{
name: "empty exclude list on empty containers",
scenario: ScenarioReallyBad,
input: ContainersList{Containers: []Container{}},
excludes: []string{},
expected: 0,
},
{
name: "exclude with empty string",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
},
},
excludes: []string{""},
expected: 0, // Empty string matches all
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
result := RemoveExcludedImages(tc.input, tc.excludes)
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
})
}
}
// TestIncludeOnlyImages tests image inclusion filtering
func (s *DefinitionsTestSuite) TestIncludeOnlyImages() {
testCases := []struct {
name string
scenario TestScenario
input ContainersList
includes []string
expected int
}{
// Good scenarios
{
name: "include specific image",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
{Image: "redis", Tag: "6"},
{Image: "postgres", Tag: "14"},
},
},
includes: []string{"nginx"},
expected: 1,
},
{
name: "include multiple images",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
{Image: "redis", Tag: "6"},
{Image: "postgres", Tag: "14"},
},
},
includes: []string{"nginx", "redis"},
expected: 2,
},
// Not good scenarios
{
name: "include by partial match",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "my-nginx-app", Tag: "latest"},
{Image: "nginx-proxy", Tag: "v1"},
{Image: "redis", Tag: "6"},
},
},
includes: []string{"nginx"},
expected: 2,
},
// Really bad scenarios
{
name: "include non-existent image",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
},
},
includes: []string{"nonexistent"},
expected: 0,
},
{
name: "empty includes list",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", Tag: "latest"},
},
},
includes: []string{},
expected: 0,
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
result := IncludeOnlyImages(tc.input, tc.includes)
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
})
}
}
// TestFilterOnlyFromNamespaces tests namespace filtering
func (s *DefinitionsTestSuite) TestFilterOnlyFromNamespaces() {
testCases := []struct {
name string
scenario TestScenario
input ContainersList
namespaces []string
expected int
}{
// Good scenarios
{
name: "filter single namespace",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
{Image: "redis", ImageNamespace: "kube-system"},
},
},
namespaces: []string{"default"},
expected: 1,
},
// Not good scenarios
{
name: "filter multiple namespaces",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
{Image: "redis", ImageNamespace: "kube-system"},
{Image: "postgres", ImageNamespace: "database"},
},
},
namespaces: []string{"default", "database"},
expected: 2,
},
// Really bad scenarios
{
name: "filter non-existent namespace",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
},
},
namespaces: []string{"nonexistent"},
expected: 0,
},
{
name: "empty namespace filter",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
},
},
namespaces: []string{},
expected: 0,
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
result := FilterOnlyFromNamespaces(tc.input, tc.namespaces)
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
})
}
}
// TestFilterOutWholeNamespaces tests namespace exclusion
func (s *DefinitionsTestSuite) TestFilterOutWholeNamespaces() {
testCases := []struct {
name string
scenario TestScenario
input ContainersList
namespaces []string
expected int
}{
// Good scenarios
{
name: "exclude kube-system",
scenario: ScenarioGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
{Image: "coredns", ImageNamespace: "kube-system"},
{Image: "redis", ImageNamespace: "apps"},
},
},
namespaces: []string{"kube-system"},
expected: 2,
},
// Not good scenarios
{
name: "exclude multiple system namespaces",
scenario: ScenarioNotGood,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
{Image: "coredns", ImageNamespace: "kube-system"},
{Image: "cilium", ImageNamespace: "kube-system"},
{Image: "local-path", ImageNamespace: "local-path-storage"},
},
},
namespaces: []string{"kube-system", "local-path-storage"},
expected: 1,
},
// Really bad scenarios
{
name: "exclude all namespaces",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
{Image: "redis", ImageNamespace: "apps"},
},
},
namespaces: []string{"default", "apps"},
expected: 0,
},
{
name: "empty exclusion list",
scenario: ScenarioReallyBad,
input: ContainersList{
Containers: []Container{
{Image: "nginx", ImageNamespace: "default"},
},
},
namespaces: []string{},
expected: 1, // No exclusions = keep all
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
result := FilterOutWholeNamespaces(tc.input, tc.namespaces)
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
})
}
}
// TestContainerStruct tests Container struct behavior
func (s *DefinitionsTestSuite) TestContainerStruct() {
s.Run("full container with all fields", func() {
c := Container{
Image: "quay.io/cilium/cilium",
Tag: "v1.18.4",
Sha: "sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
FullName: "quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
ImageNamespace: "kube-system",
}
assert.Equal(s.T(), "quay.io/cilium/cilium", c.Image)
assert.Equal(s.T(), "v1.18.4", c.Tag)
assert.Contains(s.T(), c.Sha, "sha256:")
assert.Contains(s.T(), c.FullName, "@")
})
s.Run("container equality for deduplication", func() {
c1 := Container{Image: "nginx", Tag: "latest", FullName: "nginx:latest"}
c2 := Container{Image: "nginx", Tag: "latest", FullName: "nginx:latest"}
c3 := Container{Image: "nginx", Tag: "1.21", FullName: "nginx:1.21"}
assert.Equal(s.T(), c1, c2)
assert.NotEqual(s.T(), c1, c3)
})
}
// TestConstants tests that constants are defined correctly
func (s *DefinitionsTestSuite) TestConstants() {
// Status constants
assert.Equal(s.T(), "PENDING", STATUS_PENDING)
assert.Equal(s.T(), "STARTING", STATUS_STARTING)
assert.Equal(s.T(), "RETRYING", STATUS_RETRYING)
assert.Equal(s.T(), "RUNNING", STATUS_RUNNING)
assert.Equal(s.T(), "FAILED", STATUS_FAILED)
assert.Equal(s.T(), "COMPLETED", STATUS_SUCCESS)
assert.Equal(s.T(), "PRESENT", STATUS_PRESENT)
// Storage constants
assert.Equal(s.T(), "S3", STORAGE_S3)
assert.Equal(s.T(), "FILE", STORAGE_FILE)
}
// TestBackupJobImage tests the BACKUP_JOB_IMAGE initialization
func (s *DefinitionsTestSuite) TestBackupJobImage() {
require.NotEmpty(s.T(), BACKUP_JOB_IMAGE)
assert.Contains(s.T(), BACKUP_JOB_IMAGE, "kubernetes-images-sync-worker")
}
+3 -3
View File
@@ -9,13 +9,13 @@ import (
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/pointer"
"k8s.io/utils/ptr"
)
type JobParams struct {
Name string
Namespace string
Annotations map[string]string
Annotations map[string]string
Image string
Commands []string
EnvVars []corev1.EnvVar
@@ -88,7 +88,7 @@ func CreateJob[T any](params JobParams, setupFunc func(T) []string) *batchv1.Job
VolumeMounts: volumeMounts,
Env: params.EnvVars,
SecurityContext: &corev1.SecurityContext{
Privileged: pointer.Bool(true),
Privileged: ptr.To(true),
},
},
},
+547
View File
@@ -0,0 +1,547 @@
package shared
import (
"testing"
raczylocomv1 "github.com/lukaszraczylo/kubernetes-images-sync-operator/api/raczylo.com/v1"
"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"
)
// JobsTestSuite tests job creation and related functionality
type JobsTestSuite struct {
suite.Suite
}
func TestJobsTestSuite(t *testing.T) {
suite.Run(t, new(JobsTestSuite))
}
// TestCreateJob tests the CreateJob function with various scenarios
func (s *JobsTestSuite) TestCreateJob() {
testCases := []struct {
name string
scenario TestScenario
params JobParams
expectJobName string
expectNamespace string
expectImage string
expectAnnotations map[string]string
expectSecrets int
expectEnvVars int
}{
// Good scenarios
{
name: "basic job creation",
scenario: ScenarioGood,
params: JobParams{
Name: "test-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo hello"},
},
expectJobName: "test-job",
expectNamespace: "default",
expectImage: "worker:latest",
expectSecrets: 0,
expectEnvVars: 0,
},
{
name: "job with annotations",
scenario: ScenarioGood,
params: JobParams{
Name: "annotated-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo hello"},
Annotations: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
expectJobName: "annotated-job",
expectNamespace: "default",
expectImage: "worker:latest",
expectAnnotations: map[string]string{
"key1": "value1",
"key2": "value2",
},
expectSecrets: 0,
expectEnvVars: 0,
},
{
name: "job with image pull secrets",
scenario: ScenarioGood,
params: JobParams{
Name: "secret-job",
Namespace: "default",
Image: "private-registry/image:latest",
Commands: []string{"echo hello"},
ImagePullSecrets: []corev1.LocalObjectReference{
{Name: "my-registry-secret"},
},
},
expectJobName: "secret-job",
expectNamespace: "default",
expectImage: "private-registry/image:latest",
expectSecrets: 1,
expectEnvVars: 0,
},
{
name: "job with multiple secrets",
scenario: ScenarioGood,
params: JobParams{
Name: "multi-secret-job",
Namespace: "default",
Image: "private-registry/image:latest",
Commands: []string{"echo hello"},
ImagePullSecrets: []corev1.LocalObjectReference{
{Name: "secret1"},
{Name: "secret2"},
{Name: "secret3"},
},
},
expectJobName: "multi-secret-job",
expectNamespace: "default",
expectImage: "private-registry/image:latest",
expectSecrets: 3,
expectEnvVars: 0,
},
{
name: "job with environment variables",
scenario: ScenarioGood,
params: JobParams{
Name: "env-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo $MY_VAR"},
EnvVars: []corev1.EnvVar{
{Name: "MY_VAR", Value: "my-value"},
{Name: "AWS_REGION", Value: "us-east-1"},
},
},
expectJobName: "env-job",
expectNamespace: "default",
expectImage: "worker:latest",
expectSecrets: 0,
expectEnvVars: 2,
},
// Not good scenarios
{
name: "job with backoff limit",
scenario: ScenarioNotGood,
params: JobParams{
Name: "retry-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"might-fail"},
BackoffLimit: int32Ptr(3),
},
expectJobName: "retry-job",
expectNamespace: "default",
expectImage: "worker:latest",
expectSecrets: 0,
expectEnvVars: 0,
},
{
name: "job with TTL after finished",
scenario: ScenarioNotGood,
params: JobParams{
Name: "ttl-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo done"},
TTLSecondsAfterFinished: int32Ptr(300),
},
expectJobName: "ttl-job",
expectNamespace: "default",
expectImage: "worker:latest",
expectSecrets: 0,
expectEnvVars: 0,
},
// Really bad scenarios - edge cases
{
name: "job with empty commands",
scenario: ScenarioReallyBad,
params: JobParams{
Name: "empty-cmd-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{},
},
expectJobName: "empty-cmd-job",
expectNamespace: "default",
expectImage: "worker:latest",
expectSecrets: 0,
expectEnvVars: 0,
},
{
name: "job with very long name",
scenario: ScenarioReallyBad,
params: JobParams{
Name: "this-is-a-very-long-job-name-that-might-cause-issues-in-kubernetes-because-names-have-limits",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo hello"},
},
expectJobName: "this-is-a-very-long-job-name-that-might-cause-issues-in-kubernetes-because-names-have-limits",
expectNamespace: "default",
expectImage: "worker:latest",
expectSecrets: 0,
expectEnvVars: 0,
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
job := CreateJob(tc.params, func(raczylocomv1.ClusterImageExport) []string { return nil })
require.NotNil(s.T(), job, "Job should not be nil")
// Verify job metadata
assert.Equal(s.T(), tc.expectJobName, job.ObjectMeta.Name)
assert.Equal(s.T(), tc.expectNamespace, job.ObjectMeta.Namespace)
// Verify labels
assert.Equal(s.T(), "image-export", job.ObjectMeta.Labels["app"])
assert.Equal(s.T(), "image-export", job.Spec.Template.ObjectMeta.Labels["app"])
// Verify annotations if expected
if tc.expectAnnotations != nil {
for k, v := range tc.expectAnnotations {
assert.Equal(s.T(), v, job.ObjectMeta.Annotations[k])
assert.Equal(s.T(), v, job.Spec.Template.ObjectMeta.Annotations[k])
}
}
// Verify pod template
podSpec := job.Spec.Template.Spec
require.Len(s.T(), podSpec.Containers, 1, "Should have exactly one container")
container := podSpec.Containers[0]
assert.Equal(s.T(), "exporter", container.Name)
assert.Equal(s.T(), tc.expectImage, container.Image)
assert.True(s.T(), container.TTY)
// Verify restart policy
assert.Equal(s.T(), corev1.RestartPolicyOnFailure, podSpec.RestartPolicy)
// Verify secrets
assert.Len(s.T(), podSpec.ImagePullSecrets, tc.expectSecrets)
assert.Len(s.T(), podSpec.Volumes, tc.expectSecrets)
assert.Len(s.T(), container.VolumeMounts, tc.expectSecrets)
// Verify environment variables
assert.Len(s.T(), container.Env, tc.expectEnvVars)
// Verify security context (privileged for podman)
require.NotNil(s.T(), container.SecurityContext)
require.NotNil(s.T(), container.SecurityContext.Privileged)
assert.True(s.T(), *container.SecurityContext.Privileged)
})
}
}
// TestCreateJobWithOwnerReferences tests owner reference handling
func (s *JobsTestSuite) TestCreateJobWithOwnerReferences() {
ownerRefs := []metav1.OwnerReference{
{
APIVersion: "raczylo.com/v1",
Kind: "ClusterImage",
Name: "test-image",
UID: "test-uid-12345",
},
}
params := JobParams{
Name: "owned-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo hello"},
OwnerReferences: ownerRefs,
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
require.Len(s.T(), job.ObjectMeta.OwnerReferences, 1)
assert.Equal(s.T(), "ClusterImage", job.ObjectMeta.OwnerReferences[0].Kind)
assert.Equal(s.T(), "test-image", job.ObjectMeta.OwnerReferences[0].Name)
}
// TestCreateJobCommands tests command concatenation
func (s *JobsTestSuite) TestCreateJobCommands() {
testCases := []struct {
name string
commands []string
expectedArgsLen int
expectedJoined string
}{
{
name: "single command",
commands: []string{"echo hello"},
expectedArgsLen: 3, // /bin/bash, -c, "command"
expectedJoined: "echo hello",
},
{
name: "multiple commands",
commands: []string{"echo hello", "echo world"},
expectedArgsLen: 3,
expectedJoined: "echo hello && echo world",
},
{
name: "complex podman commands",
commands: []string{
"podman pull nginx:latest",
"podman save --quiet -o /tmp/nginx.tar nginx:latest",
"./worker export /tmp/nginx.tar s3://bucket/path",
},
expectedArgsLen: 3,
expectedJoined: "podman pull nginx:latest && podman save --quiet -o /tmp/nginx.tar nginx:latest && ./worker export /tmp/nginx.tar s3://bucket/path",
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
params := JobParams{
Name: "cmd-test",
Namespace: "default",
Image: "worker:latest",
Commands: tc.commands,
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
container := job.Spec.Template.Spec.Containers[0]
assert.Len(s.T(), container.Args, tc.expectedArgsLen)
assert.Equal(s.T(), "/bin/bash", container.Args[0])
assert.Equal(s.T(), "-c", container.Args[1])
assert.Equal(s.T(), tc.expectedJoined, container.Args[2])
})
}
}
// TestCreateJobBackoffAndTTL tests backoff limit and TTL settings
func (s *JobsTestSuite) TestCreateJobBackoffAndTTL() {
backoff := int32(5)
ttl := int32(600)
params := JobParams{
Name: "backoff-ttl-job",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo hello"},
BackoffLimit: &backoff,
TTLSecondsAfterFinished: &ttl,
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
require.NotNil(s.T(), job.Spec.BackoffLimit)
assert.Equal(s.T(), int32(5), *job.Spec.BackoffLimit)
require.NotNil(s.T(), job.Spec.TTLSecondsAfterFinished)
assert.Equal(s.T(), int32(600), *job.Spec.TTLSecondsAfterFinished)
}
// TestSetupS3Params tests S3 parameter generation
func (s *JobsTestSuite) TestSetupS3Params() {
testCases := []struct {
name string
scenario TestScenario
config raczylocomv1.ClusterImageStorageS3
expectContains []string
expectNotIn []string
}{
// Good scenarios
{
name: "basic credentials",
scenario: ScenarioGood,
config: raczylocomv1.ClusterImageStorageS3{
Bucket: "my-bucket",
Region: "us-east-1",
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
expectContains: []string{
"--aws_access_key_id='AKIAIOSFODNN7EXAMPLE'",
"--aws_secret_access_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'",
},
expectNotIn: []string{"--use_role", "--use_current_role"},
},
{
name: "use current role (IRSA)",
scenario: ScenarioGood,
config: raczylocomv1.ClusterImageStorageS3{
Bucket: "my-bucket",
Region: "us-east-1",
UseRole: true,
},
expectContains: []string{"--use_current_role"},
expectNotIn: []string{"--aws_access_key_id", "--aws_secret_access_key"},
},
{
name: "use specific role ARN",
scenario: ScenarioGood,
config: raczylocomv1.ClusterImageStorageS3{
Bucket: "my-bucket",
Region: "us-east-1",
UseRole: true,
RoleARN: "arn:aws:iam::123456789:role/MyRole",
},
expectContains: []string{
"--use_role",
"--role_name='arn:aws:iam::123456789:role/MyRole'",
},
expectNotIn: []string{"--aws_access_key_id", "--use_current_role"},
},
// Not good scenarios
{
name: "with custom endpoint (MinIO)",
scenario: ScenarioNotGood,
config: raczylocomv1.ClusterImageStorageS3{
Bucket: "my-bucket",
Region: "us-east-1",
AccessKey: "minioadmin",
SecretKey: "minioadmin",
Endpoint: "http://minio.local:9000",
},
expectContains: []string{
"--endpoint_url='http://minio.local:9000'",
"--aws_access_key_id='minioadmin'",
},
},
// Really bad scenarios
{
name: "empty credentials with UseRole false",
scenario: ScenarioReallyBad,
config: raczylocomv1.ClusterImageStorageS3{
Bucket: "my-bucket",
Region: "us-east-1",
},
expectContains: []string{
"--aws_access_key_id=''",
"--aws_secret_access_key=''",
},
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
params := SetupS3Params(tc.config)
// Check expected parameters are present
for _, expected := range tc.expectContains {
found := false
for _, param := range params {
if param == expected {
found = true
break
}
}
assert.True(s.T(), found, "Expected parameter not found: %s", expected)
}
// Check unexpected parameters are not present
for _, notExpected := range tc.expectNotIn {
for _, param := range params {
assert.NotContains(s.T(), param, notExpected, "Unexpected parameter found: %s", notExpected)
}
}
})
}
}
// TestJobParamsDefaults tests JobParams default handling
func (s *JobsTestSuite) TestJobParamsDefaults() {
s.Run("nil backoff limit", func() {
params := JobParams{
Name: "test",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo"},
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
assert.Nil(s.T(), job.Spec.BackoffLimit)
})
s.Run("nil TTL", func() {
params := JobParams{
Name: "test",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo"},
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
assert.Nil(s.T(), job.Spec.TTLSecondsAfterFinished)
})
s.Run("empty service account uses env var", func() {
// This test verifies that when ServiceAccount is empty,
// the job will use POD_SERVICE_ACCOUNT env var
params := JobParams{
Name: "test",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo"},
ServiceAccount: "", // Empty
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
// Service account will be set from environment variable POD_SERVICE_ACCOUNT
// In tests, this will be empty, so we just verify the job is created
require.NotNil(s.T(), job)
})
}
// TestSecretVolumeMounting tests that secrets are properly mounted
func (s *JobsTestSuite) TestSecretVolumeMounting() {
secrets := []corev1.LocalObjectReference{
{Name: "docker-registry"},
{Name: "gcr-json-key"},
{Name: "ecr-credentials"},
}
params := JobParams{
Name: "secret-mount-test",
Namespace: "default",
Image: "worker:latest",
Commands: []string{"echo"},
ImagePullSecrets: secrets,
}
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
podSpec := job.Spec.Template.Spec
container := podSpec.Containers[0]
// Verify volumes are created
require.Len(s.T(), podSpec.Volumes, 3)
for i, vol := range podSpec.Volumes {
assert.Equal(s.T(), secrets[i].Name, vol.VolumeSource.Secret.SecretName)
assert.Contains(s.T(), vol.Name, "secret-")
}
// Verify volume mounts
require.Len(s.T(), container.VolumeMounts, 3)
for i, mount := range container.VolumeMounts {
assert.Contains(s.T(), mount.MountPath, ".docker-secret-")
assert.True(s.T(), mount.ReadOnly)
assert.Contains(s.T(), mount.Name, "secret-")
// Verify index is correct
expectedIndex := i
assert.Equal(s.T(), "/home/runner/.docker-secret-"+string(rune('0'+expectedIndex)), mount.MountPath)
}
}
// Helper function
func int32Ptr(i int32) *int32 {
return &i
}
File diff suppressed because it is too large Load Diff