Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions internal/controller/taskspawner_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,13 +559,13 @@ func (r *TaskSpawnerReconciler) updateDeployment(ctx context.Context, ts *kelosv
}

// Compare init containers (token-refresher sidecar)
if !reflect.DeepEqual(deploy.Spec.Template.Spec.InitContainers, desired.Spec.Template.Spec.InitContainers) {
if !initContainersEqual(deploy.Spec.Template.Spec.InitContainers, desired.Spec.Template.Spec.InitContainers) {
deploy.Spec.Template.Spec.InitContainers = desired.Spec.Template.Spec.InitContainers
needsUpdate = true
}

// Compare volumes (shared token emptyDir, github-app-secret)
if !reflect.DeepEqual(deploy.Spec.Template.Spec.Volumes, desired.Spec.Template.Spec.Volumes) {
if !volumesEqual(deploy.Spec.Template.Spec.Volumes, desired.Spec.Template.Spec.Volumes) {
deploy.Spec.Template.Spec.Volumes = desired.Spec.Template.Spec.Volumes
needsUpdate = true
}
Expand Down Expand Up @@ -675,13 +675,13 @@ func (r *TaskSpawnerReconciler) updateCronJob(ctx context.Context, ts *kelosv1al
}

// Update init containers if changed
if !reflect.DeepEqual(currentPodSpec.InitContainers, desiredPodSpec.InitContainers) {
if !initContainersEqual(currentPodSpec.InitContainers, desiredPodSpec.InitContainers) {
currentPodSpec.InitContainers = desiredPodSpec.InitContainers
needsUpdate = true
}

// Update volumes if changed
if !reflect.DeepEqual(currentPodSpec.Volumes, desiredPodSpec.Volumes) {
if !volumesEqual(currentPodSpec.Volumes, desiredPodSpec.Volumes) {
currentPodSpec.Volumes = desiredPodSpec.Volumes
needsUpdate = true
}
Expand Down Expand Up @@ -963,6 +963,75 @@ func (r *TaskSpawnerReconciler) findTaskSpawnersForTask(ctx context.Context, obj
}}
}

// initContainersEqual compares init container slices by checking only the
// fields the builder sets. Kubernetes adds defaulted fields (securityContext,
// terminationMessagePath, terminationMessagePolicy) that would cause
// reflect.DeepEqual to always report false, triggering a reconciliation loop.
func initContainersEqual(a, b []corev1.Container) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Name != b[i].Name ||
a[i].Image != b[i].Image ||
!pullPolicyEqual(a[i].ImagePullPolicy, b[i].ImagePullPolicy) ||
!reflect.DeepEqual(a[i].RestartPolicy, b[i].RestartPolicy) ||
!equalEnvVars(a[i].Env, b[i].Env) ||
!reflect.DeepEqual(a[i].VolumeMounts, b[i].VolumeMounts) ||
!resourceRequirementsEqual(a[i].Resources, b[i].Resources) {
return false
}
}
return true
}

// pullPolicyEqual returns true when both policies are the same, treating an
// empty value as "API-server decides". When either side is empty the comparison
// is skipped because the API server fills in a concrete policy (Always,
// IfNotPresent) that would never match the empty desired value.
func pullPolicyEqual(a, b corev1.PullPolicy) bool {
if a == "" || b == "" {
return true
}
return a == b
}

// volumesEqual compares volume slices by checking only the fields the builder
// sets. Kubernetes defaults fields like Secret.DefaultMode (to 420) that would
// cause reflect.DeepEqual to always report false, triggering a reconciliation loop.
func volumesEqual(a, b []corev1.Volume) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Name != b[i].Name {
return false
}
av := a[i].VolumeSource
bv := b[i].VolumeSource
if (av.EmptyDir == nil) != (bv.EmptyDir == nil) {
return false
}
if (av.Secret == nil) != (bv.Secret == nil) {
return false
}
if av.Secret != nil && bv.Secret != nil {
if av.Secret.SecretName != bv.Secret.SecretName {
return false
}
}
if (av.ConfigMap == nil) != (bv.ConfigMap == nil) {
return false
}
if av.ConfigMap != nil && bv.ConfigMap != nil {
if av.ConfigMap.Name != bv.ConfigMap.Name {
return false
}
}
}
return true
}

// resourceRequirementsEqual compares two ResourceRequirements using semantic
// equality for quantities instead of reflect.DeepEqual, which can report false
// negatives when the internal representation of equal quantities differs.
Expand Down
117 changes: 117 additions & 0 deletions internal/controller/taskspawner_deployment_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3035,3 +3035,120 @@ func TestReconcileDeployment_KeepsDeploymentWithNewLabels(t *testing.T) {
t.Errorf("expected kelos.dev/component label in selector")
}
}

func TestInitContainersEqual_IgnoresKubernetesDefaults(t *testing.T) {
restartAlways := corev1.ContainerRestartPolicyAlways

// desired: what the builder produces (no K8s defaults)
desired := []corev1.Container{{
Name: "token-refresher",
Image: "ghcr.io/kelos-dev/kelos-token-refresher:main",
ImagePullPolicy: corev1.PullAlways,
RestartPolicy: &restartAlways,
Env: []corev1.EnvVar{
{Name: "APP_ID", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "creds"}, Key: "appID"}}},
},
VolumeMounts: []corev1.VolumeMount{
{Name: "github-token", MountPath: "/shared/token"},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("50m")},
},
}}

// actual: what K8s returns after normalization (adds securityContext, terminationMessagePath, etc.)
actual := []corev1.Container{{
Name: "token-refresher",
Image: "ghcr.io/kelos-dev/kelos-token-refresher:main",
ImagePullPolicy: corev1.PullAlways,
RestartPolicy: &restartAlways,
TerminationMessagePath: "/dev/termination-log",
TerminationMessagePolicy: corev1.TerminationMessageReadFile,
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"NET_RAW"}},
},
Env: []corev1.EnvVar{
{Name: "APP_ID", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "creds"}, Key: "appID"}}},
},
VolumeMounts: []corev1.VolumeMount{
{Name: "github-token", MountPath: "/shared/token"},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("50m")},
},
}}

if !initContainersEqual(actual, desired) {
t.Fatal("initContainersEqual should ignore K8s-defaulted fields like securityContext and terminationMessagePath")
}
}

func TestInitContainersEqual_IgnoresAPIDefaultedPullPolicy(t *testing.T) {
// desired: builder leaves ImagePullPolicy empty (Helm default)
desired := []corev1.Container{{
Name: "token-refresher",
Image: "ghcr.io/kelos-dev/kelos-token-refresher:main",
}}
// actual: API server filled in "Always"
actual := []corev1.Container{{
Name: "token-refresher",
Image: "ghcr.io/kelos-dev/kelos-token-refresher:main",
ImagePullPolicy: corev1.PullAlways,
}}
if !initContainersEqual(actual, desired) {
t.Fatal("should treat empty desired pull policy as matching any API-defaulted value")
}
}

func TestInitContainersEqual_DetectsRealChanges(t *testing.T) {
a := []corev1.Container{{Name: "refresher", Image: "img:v1"}}
b := []corev1.Container{{Name: "refresher", Image: "img:v2"}}
if initContainersEqual(a, b) {
t.Fatal("should detect image change")
}

if initContainersEqual([]corev1.Container{{Name: "a"}}, nil) {
t.Fatal("should detect added init container")
}
}

func TestVolumesEqual_IgnoresSecretDefaultMode(t *testing.T) {
// desired: what the builder produces (no DefaultMode)
desired := []corev1.Volume{
{Name: "github-token", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
{Name: "github-app-secret", VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: "creds"},
}},
}

// actual: what K8s returns (adds DefaultMode: 420)
defaultMode := int32(420)
actual := []corev1.Volume{
{Name: "github-token", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
{Name: "github-app-secret", VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: "creds", DefaultMode: &defaultMode},
}},
}

if !volumesEqual(actual, desired) {
t.Fatal("volumesEqual should ignore K8s-defaulted Secret.DefaultMode")
}
}

func TestVolumesEqual_DetectsRealChanges(t *testing.T) {
a := []corev1.Volume{{Name: "vol", VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: "secret-a"},
}}}
b := []corev1.Volume{{Name: "vol", VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: "secret-b"},
}}}
if volumesEqual(a, b) {
t.Fatal("should detect secret name change")
}

if volumesEqual(a, nil) {
t.Fatal("should detect removed volume")
}
}
Loading