Skip to content

Commit 2776f87

Browse files
committed
Fix: CA bundle volume conflict during operator upgrade
When upgrading a version with legacy CA bundle volumes, deployments fail with volume validation errors if the odh-trusted-ca-bundle ConfigMap exists. The old operator used an emptyDir volume ("ca-bundle") with an init container to copy certificates, while the new operator uses a managed ConfigMap volume. Server-Side Apply creates field conflicts when trying to change the volume type from emptyDir to ConfigMap, resulting in a volume with both properties defined, which violates Kubernetes validation. This fix detects deployments with legacy CA bundle volumes and uses a full Update (replacement) instead of SSA Patch. This ensures clean migration by removing all legacy artifacts (emptyDir volume, source ConfigMap volume, and init container) in a single operation. Assisted-by: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Derek Higgins <derekh@redhat.com>
1 parent aec113e commit 2776f87

2 files changed

Lines changed: 334 additions & 2 deletions

File tree

pkg/deploy/kustomizer.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,25 @@ func patchResource(ctx context.Context, cli client.Client, desired, existing *un
177177
return nil
178178
}
179179

180-
if existing.GetKind() == "PersistentVolumeClaim" {
180+
switch existing.GetKind() {
181+
case "PersistentVolumeClaim":
181182
logger.V(1).Info("Skipping PVC patch - PVCs are immutable after creation",
182183
"name", existing.GetName(),
183184
"namespace", existing.GetNamespace())
184185
return nil
185-
} else if existing.GetKind() == "Service" {
186+
case "Service":
186187
if err := compare.CheckAndLogServiceChanges(ctx, cli, desired); err != nil {
187188
return fmt.Errorf("failed to validate resource mutations while patching: %w", err)
188189
}
190+
case deploymentKind:
191+
// Check for legacy CA bundle volumes and use full replacement to avoid SSA conflicts
192+
if hasLegacyCABundleVolumes(ctx, existing) {
193+
logger.Info("Detected legacy CA bundle volumes, using full replacement instead of SSA",
194+
"deployment", existing.GetName(),
195+
"namespace", existing.GetNamespace())
196+
desired.SetResourceVersion(existing.GetResourceVersion())
197+
return cli.Update(ctx, desired)
198+
}
189199
}
190200

191201
data, err := json.Marshal(desired)
@@ -661,6 +671,46 @@ func FilterExcludeKinds(resMap *resmap.ResMap, kindsToExclude []string) (*resmap
661671
return &filteredResMap, nil
662672
}
663673

674+
// hasLegacyCABundleVolumes detects if a deployment has legacy CA bundle volumes
675+
// from the old operator that used emptyDir + ConfigMap pattern.
676+
func hasLegacyCABundleVolumes(ctx context.Context, deployment *unstructured.Unstructured) bool {
677+
logger := log.FromContext(ctx)
678+
679+
volumes, found, err := unstructured.NestedSlice(deployment.Object, "spec", "template", "spec", "volumes")
680+
if err != nil || !found {
681+
return false
682+
}
683+
684+
for _, vol := range volumes {
685+
volumeMap, ok := vol.(map[string]interface{})
686+
if !ok {
687+
continue
688+
}
689+
690+
volumeName, _, _ := unstructured.NestedString(volumeMap, "name")
691+
692+
// Legacy pattern: volume named "ca-bundle" with emptyDir
693+
if volumeName == "ca-bundle" {
694+
if _, hasEmptyDir := volumeMap["emptyDir"]; hasEmptyDir {
695+
logger.V(1).Info("Found legacy ca-bundle emptyDir volume",
696+
"deployment", deployment.GetName(),
697+
"namespace", deployment.GetNamespace())
698+
return true
699+
}
700+
}
701+
702+
// Legacy pattern: volume named "ca-bundle-source" (ConfigMap source)
703+
if volumeName == "ca-bundle-source" {
704+
logger.V(1).Info("Found legacy ca-bundle-source volume",
705+
"deployment", deployment.GetName(),
706+
"namespace", deployment.GetNamespace())
707+
return true
708+
}
709+
}
710+
711+
return false
712+
}
713+
664714
// CheckClusterRoleExists checks if a RoleBinding should be skipped due to missing SCC ClusterRole.
665715
func CheckClusterRoleExists(ctx context.Context, cli client.Client, crb *unstructured.Unstructured) (bool, error) {
666716
roleRef, found, _ := unstructured.NestedMap(crb.Object, "roleRef")

pkg/deploy/kustomizer_test.go

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import (
1818
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1919
"k8s.io/apimachinery/pkg/types"
2020
"k8s.io/apimachinery/pkg/util/intstr"
21+
"k8s.io/apimachinery/pkg/util/yaml"
2122
"k8s.io/client-go/kubernetes/scheme"
2223
"sigs.k8s.io/controller-runtime/pkg/client"
2324
"sigs.k8s.io/kustomize/api/resmap"
25+
kresource "sigs.k8s.io/kustomize/api/resource"
2426
"sigs.k8s.io/kustomize/kyaml/filesys"
2527
)
2628

@@ -628,3 +630,283 @@ func TestRemoveDeploymentReplicas(t *testing.T) {
628630

629631
require.False(t, hasReplicas, "replicas should be removed from all deployments when autoscaling is enabled")
630632
}
633+
634+
// TestHasLegacyCABundleVolumes tests the detection of legacy CA bundle volumes.
635+
func TestHasLegacyCABundleVolumes(t *testing.T) {
636+
ctx := context.Background()
637+
638+
t.Run("detects legacy emptyDir ca-bundle volume", func(t *testing.T) {
639+
deployment := newTestResource(t, "apps/v1", "Deployment", "test-deploy", "test-ns", map[string]any{
640+
"template": map[string]any{
641+
"spec": map[string]any{
642+
"volumes": []any{
643+
map[string]any{
644+
"name": "ca-bundle",
645+
"emptyDir": map[string]any{},
646+
},
647+
},
648+
},
649+
},
650+
})
651+
652+
u, err := resourceToUnstructured(t, deployment)
653+
require.NoError(t, err)
654+
655+
result := hasLegacyCABundleVolumes(ctx, u)
656+
require.True(t, result, "should detect legacy ca-bundle emptyDir volume")
657+
})
658+
659+
t.Run("detects legacy ca-bundle-source volume", func(t *testing.T) {
660+
deployment := newTestResource(t, "apps/v1", "Deployment", "test-deploy", "test-ns", map[string]any{
661+
"template": map[string]any{
662+
"spec": map[string]any{
663+
"volumes": []any{
664+
map[string]any{
665+
"name": "ca-bundle-source",
666+
"configMap": map[string]any{
667+
"name": "odh-trusted-ca-bundle",
668+
},
669+
},
670+
},
671+
},
672+
},
673+
})
674+
675+
u, err := resourceToUnstructured(t, deployment)
676+
require.NoError(t, err)
677+
678+
result := hasLegacyCABundleVolumes(ctx, u)
679+
require.True(t, result, "should detect legacy ca-bundle-source volume")
680+
})
681+
682+
t.Run("does not detect new-style ca-bundle configMap", func(t *testing.T) {
683+
deployment := newTestResource(t, "apps/v1", "Deployment", "test-deploy", "test-ns", map[string]any{
684+
"template": map[string]any{
685+
"spec": map[string]any{
686+
"volumes": []any{
687+
map[string]any{
688+
"name": "ca-bundle",
689+
"configMap": map[string]any{
690+
"name": "managed-ca-bundle",
691+
},
692+
},
693+
},
694+
},
695+
},
696+
})
697+
698+
u, err := resourceToUnstructured(t, deployment)
699+
require.NoError(t, err)
700+
701+
result := hasLegacyCABundleVolumes(ctx, u)
702+
require.False(t, result, "should not detect new-style ca-bundle ConfigMap as legacy")
703+
})
704+
705+
t.Run("does not detect unrelated volumes", func(t *testing.T) {
706+
deployment := newTestResource(t, "apps/v1", "Deployment", "test-deploy", "test-ns", map[string]any{
707+
"template": map[string]any{
708+
"spec": map[string]any{
709+
"volumes": []any{
710+
map[string]any{
711+
"name": "data",
712+
"emptyDir": map[string]any{},
713+
},
714+
map[string]any{
715+
"name": "config",
716+
"configMap": map[string]any{
717+
"name": "app-config",
718+
},
719+
},
720+
},
721+
},
722+
},
723+
})
724+
725+
u, err := resourceToUnstructured(t, deployment)
726+
require.NoError(t, err)
727+
728+
result := hasLegacyCABundleVolumes(ctx, u)
729+
require.False(t, result, "should not detect unrelated volumes as legacy")
730+
})
731+
732+
t.Run("returns false when no volumes present", func(t *testing.T) {
733+
deployment := newTestResource(t, "apps/v1", "Deployment", "test-deploy", "test-ns", map[string]any{})
734+
735+
u, err := resourceToUnstructured(t, deployment)
736+
require.NoError(t, err)
737+
738+
result := hasLegacyCABundleVolumes(ctx, u)
739+
require.False(t, result, "should return false when no volumes present")
740+
})
741+
}
742+
743+
// TestLegacyCABundleUpgrade tests that deployments with legacy CA bundle volumes
744+
// are replaced instead of patched to avoid SSA conflicts.
745+
func TestLegacyCABundleUpgrade(t *testing.T) {
746+
ctx, testNs, owner := setupApplyResourcesTest(t, "legacy-ca-upgrade")
747+
748+
// Create an existing deployment with legacy CA bundle volumes (old operator pattern)
749+
existingDeployment := &appsv1.Deployment{
750+
ObjectMeta: metav1.ObjectMeta{
751+
Name: "test-deployment",
752+
Namespace: testNs,
753+
Labels: map[string]string{"version": "old"},
754+
OwnerReferences: []metav1.OwnerReference{
755+
*metav1.NewControllerRef(owner, owner.GroupVersionKind()),
756+
},
757+
},
758+
Spec: appsv1.DeploymentSpec{
759+
Replicas: ptr(int32(1)),
760+
Selector: &metav1.LabelSelector{
761+
MatchLabels: map[string]string{"app": "test"},
762+
},
763+
Template: corev1.PodTemplateSpec{
764+
ObjectMeta: metav1.ObjectMeta{
765+
Labels: map[string]string{"app": "test"},
766+
},
767+
Spec: corev1.PodSpec{
768+
Containers: []corev1.Container{
769+
{
770+
Name: "main",
771+
Image: "test:v1",
772+
},
773+
},
774+
// Legacy CA bundle volumes (old operator pattern)
775+
Volumes: []corev1.Volume{
776+
{
777+
Name: "data",
778+
VolumeSource: corev1.VolumeSource{
779+
EmptyDir: &corev1.EmptyDirVolumeSource{},
780+
},
781+
},
782+
{
783+
Name: "ca-bundle",
784+
VolumeSource: corev1.VolumeSource{
785+
EmptyDir: &corev1.EmptyDirVolumeSource{},
786+
},
787+
},
788+
{
789+
Name: "ca-bundle-source",
790+
VolumeSource: corev1.VolumeSource{
791+
ConfigMap: &corev1.ConfigMapVolumeSource{
792+
LocalObjectReference: corev1.LocalObjectReference{
793+
Name: "odh-trusted-ca-bundle",
794+
},
795+
},
796+
},
797+
},
798+
},
799+
InitContainers: []corev1.Container{
800+
{
801+
Name: "ca-bundle-init",
802+
Image: "busybox",
803+
},
804+
},
805+
},
806+
},
807+
},
808+
}
809+
require.NoError(t, k8sClient.Create(ctx, existingDeployment))
810+
811+
// Create desired deployment with new CA bundle pattern (new operator pattern)
812+
// Must use same selector as existing deployment (selector is immutable)
813+
desiredDeployment := newTestResource(t, "apps/v1", "Deployment", "test-deployment", testNs, map[string]any{
814+
"replicas": int32(1),
815+
"selector": map[string]any{
816+
"matchLabels": map[string]any{
817+
"app": "test",
818+
},
819+
},
820+
"template": map[string]any{
821+
"metadata": map[string]any{
822+
"labels": map[string]any{
823+
"app": "test",
824+
},
825+
},
826+
"spec": map[string]any{
827+
"containers": []any{
828+
map[string]any{
829+
"name": "main",
830+
"image": "test:v2",
831+
},
832+
},
833+
"volumes": []any{
834+
map[string]any{
835+
"name": "data",
836+
"emptyDir": map[string]any{},
837+
},
838+
// New CA bundle pattern - single ConfigMap volume
839+
map[string]any{
840+
"name": "ca-bundle",
841+
"configMap": map[string]any{
842+
"name": "managed-ca-bundle",
843+
},
844+
},
845+
},
846+
},
847+
},
848+
})
849+
desiredDeployment.SetLabels(map[string]string{"version": "new"})
850+
851+
resMap := resmap.New()
852+
require.NoError(t, resMap.Append(desiredDeployment))
853+
854+
// Apply the resources (should trigger replacement instead of patch)
855+
require.NoError(t, ApplyResources(ctx, k8sClient, scheme.Scheme, owner, &resMap))
856+
857+
// Verify the deployment was updated
858+
updatedDeployment := &appsv1.Deployment{}
859+
deploymentKey := types.NamespacedName{Name: "test-deployment", Namespace: testNs}
860+
require.NoError(t, k8sClient.Get(ctx, deploymentKey, updatedDeployment))
861+
862+
// Verify labels were updated (proves Update was used, not just patch)
863+
require.Equal(t, "new", updatedDeployment.Labels["version"], "deployment labels should be updated")
864+
865+
// Verify container image was updated
866+
require.Equal(t, "test:v2", updatedDeployment.Spec.Template.Spec.Containers[0].Image, "container image should be updated")
867+
868+
// Verify legacy volumes are removed
869+
volumeNames := make([]string, len(updatedDeployment.Spec.Template.Spec.Volumes))
870+
for i, vol := range updatedDeployment.Spec.Template.Spec.Volumes {
871+
volumeNames[i] = vol.Name
872+
}
873+
require.NotContains(t, volumeNames, "ca-bundle-source", "legacy ca-bundle-source volume should be removed")
874+
875+
// Verify new ca-bundle is a ConfigMap (not emptyDir)
876+
var caBundleVolume *corev1.Volume
877+
for i := range updatedDeployment.Spec.Template.Spec.Volumes {
878+
if updatedDeployment.Spec.Template.Spec.Volumes[i].Name == "ca-bundle" {
879+
caBundleVolume = &updatedDeployment.Spec.Template.Spec.Volumes[i]
880+
break
881+
}
882+
}
883+
require.NotNil(t, caBundleVolume, "ca-bundle volume should exist")
884+
require.NotNil(t, caBundleVolume.ConfigMap, "ca-bundle should be a ConfigMap volume")
885+
require.Nil(t, caBundleVolume.EmptyDir, "ca-bundle should not be an emptyDir volume")
886+
require.Equal(t, "managed-ca-bundle", caBundleVolume.ConfigMap.Name, "ca-bundle ConfigMap name should be correct")
887+
888+
// Verify init containers are removed
889+
require.Empty(t, updatedDeployment.Spec.Template.Spec.InitContainers, "legacy init containers should be removed")
890+
}
891+
892+
// resourceToUnstructured converts a kustomize resource to an unstructured object.
893+
func resourceToUnstructured(t *testing.T, res *kresource.Resource) (*unstructured.Unstructured, error) {
894+
t.Helper()
895+
896+
yamlBytes, err := res.AsYAML()
897+
if err != nil {
898+
return nil, err
899+
}
900+
901+
u := &unstructured.Unstructured{}
902+
if err := yaml.Unmarshal(yamlBytes, u); err != nil {
903+
return nil, err
904+
}
905+
906+
return u, nil
907+
}
908+
909+
// ptr is a helper function to get a pointer to a value.
910+
func ptr[T any](v T) *T {
911+
return &v
912+
}

0 commit comments

Comments
 (0)