From e59928bd8dba5d394f0b6c6f6a8d0a043a12113f Mon Sep 17 00:00:00 2001 From: Nandini Chandra Date: Sat, 18 Apr 2026 22:23:39 -0500 Subject: [PATCH 1/5] Remove SCC-injected security contexts for cross-cluster migration Signed-off-by: Nandini Chandra --- cmd.go | 204 +++++++++++++++++++++++++++++++++++++++++ openshift/openshift.go | 85 +++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 cmd.go diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..1bff531 --- /dev/null +++ b/cmd.go @@ -0,0 +1,204 @@ +package main + +import ( + "strconv" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/konveyor/crane-lib/transform" + "github.com/konveyor/crane-lib/transform/cli" + "github.com/konveyor/crane-lib/transform/util" + "github.com/sirupsen/logrus" +) + +var ( + logger logrus.FieldLogger + authorizationGroup = "authorization.openshift.io" +) + +const Version = "v0.0.4" + +const ( + // flags + StripDefaultRBACFlag = "strip-default-rbac" + StripDefaultCABundleFlag = "strip-default-cabundle" + StripDefaultPullSecretsFlag = "strip-default-pull-secrets" + PullSecretReplacementFlag = "pull-secret-replacement" + PVCRenameMap = "pvc-rename-map" + RegistryReplacementflag = "registry-replacement" +) + +func main() { + logger = logrus.New() + // TODO: add plumbing for logger in the cli-library and instantiate here + fields := []transform.OptionalFields{ + { + FlagName: StripDefaultRBACFlag, + Help: "Whether to strip default RBAC including builder and deployers serviceAccounts, roleBindings for admin, builders, and deployers (default: true)", + Example: "true", + }, + { + FlagName: StripDefaultCABundleFlag, + Help: "Whether to strip default CA Bundle (default: true)", + Example: "true", + }, + { + FlagName: StripDefaultPullSecretsFlag, + Help: "Whether to strip Pod and BuildConfig default pull secrets (beginning with builder/default/deployer-dockercfg-) that aren't replaced by the map param " + PullSecretReplacementFlag + " (default: true)", + Example: "true", + }, + { + FlagName: PullSecretReplacementFlag, + Help: "Map of pull secrets to replace in Pods and BuildConfigs while transforming in format secret1=destsecret1,secret2=destsecret2[...]", + Example: "default-dockercfg-h4n7g=default-dockercfg-12345,builder-dockercfg-abcde=builder-dockercfg-12345", + }, + { + FlagName: RegistryReplacementflag, + Help: "Map of image registry paths to swap on transform, in the format original-registry1=target-registry1,original-registry2=target-registry2...", + Example: "docker-registry.default.svc:5000=image-registry.openshift-image-registry.svc:5000,docker.io/foo=quay.io/bar", + }, + { + FlagName: PVCRenameMap, + Help: "A comma-separated list of colon separated pvc renames.", + Example: "old-pvc1-name:new-pvc1-name,old-pvc2-name:new-pvc2-name", + }, + } + cli.RunAndExit(cli.NewCustomPlugin("OpenShiftPlugin", Version, fields, Run)) +} + +type openshiftOptionalFields struct { + StripDefaultRBAC bool + StripDefaultCABundle bool + StripDefaultPullSecrets bool + PullSecretReplacement map[string]string + PVCRenameMap map[string]string + RegistryReplacement map[string]string +} + +func getOptionalFields(extras map[string]string) (openshiftOptionalFields, error) { + fields := openshiftOptionalFields{ + StripDefaultRBAC: true, + StripDefaultCABundle: true, + StripDefaultPullSecrets: true, + } + var err error + if len(extras[StripDefaultRBACFlag]) > 0 { + fields.StripDefaultRBAC, err = strconv.ParseBool(extras[StripDefaultRBACFlag]) + if err != nil { + return fields, err + } + } + if len(extras[StripDefaultCABundleFlag]) > 0 { + fields.StripDefaultCABundle, err = strconv.ParseBool(extras[StripDefaultCABundleFlag]) + if err != nil { + return fields, err + } + } + if len(extras[StripDefaultPullSecretsFlag]) > 0 { + fields.StripDefaultPullSecrets, err = strconv.ParseBool(extras[StripDefaultPullSecretsFlag]) + if err != nil { + return fields, err + } + } + if len(extras[PullSecretReplacementFlag]) > 0 { + fields.PullSecretReplacement = transform.ParseOptionalFieldMapVal(extras[PullSecretReplacementFlag]) + } + if len(extras[RegistryReplacementflag]) > 0 { + fields.RegistryReplacement = transform.ParseOptionalFieldMapVal(extras[RegistryReplacementflag]) + } + if len(extras[PVCRenameMap]) > 0 { + pvcMap, err := util.ProcessPVCMap(extras[PVCRenameMap]) + if err != nil { + return fields, err + } + fields.PVCRenameMap = pvcMap + } + return fields, nil +} + +func Run(request transform.PluginRequest) (transform.PluginResponse, error) { + u := request.Unstructured + var patch jsonpatch.Patch + whiteOut := false + inputFields, err := getOptionalFields(request.Extras) + if err != nil { + return transform.PluginResponse{}, err + } + + if authorizationGroup == u.GetObjectKind().GroupVersionKind().GroupKind().Group { + return transform.PluginResponse{ + Version: string(transform.V1), + IsWhiteOut: true, + Patches: patch, + }, nil + } + + switch u.GetKind() { + case "Build": + logger.Info("found build, adding to whiteout") + whiteOut = true + case "BuildConfig": + logger.Info("found build config, processing") + patch, err = UpdateBuildConfig(u, inputFields) + case "DeploymentConfig": + logger.Info("found deployment config, processing") + patch, err = UpdateDeploymentConfig(u, inputFields) + case "Pod": + logger.Info("found pod, processing update default pull secret") + patch, err = UpdateDefaultPullSecrets(u, inputFields) + if err == nil { + securityPatch, secErr := stripSecurityContext(u) + if secErr != nil { + err = secErr + } else { + patch = append(patch, securityPatch...) + } + } + case "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController": + logger.Infof("found %s, stripping security context", u.GetKind()) + patch, err = stripSecurityContext(u) + case "Route": + logger.Info("found route, processing") + patch, err = UpdateRoute(u) + case "ServiceAccount": + if inputFields.StripDefaultRBAC && (u.GetName() == "builder" || u.GetName() == "deployer") { + whiteOut = true + } else { + logger.Info("found service account, processing") + patch, err = UpdateServiceAccount(u) + } + case "Secret": + if inputFields.StripDefaultRBAC { + if sa, ok := u.GetAnnotations()["kubernetes.io/service-account.name"]; ok && (sa == "builder" || sa == "deployer" || sa == "pipeline") { + whiteOut = true + } + } + case "RoleBinding": + logger.Info("found role binding, processing") + if inputFields.StripDefaultRBAC && (u.GetName() == "admin" || + u.GetName() == "system:deployers" || + u.GetName() == "system:image-builders" || + u.GetName() == "system:image-pullers") { + whiteOut = true + } else { + patch, err = UpdateRoleBinding(u) + } + case "ConfigMap": + if inputFields.StripDefaultCABundle && u.GetName() == "openshift-service-ca.crt" { + whiteOut = true + } + case "ClusterServiceVersion": + if _, ok := u.GetLabels()["olm.copiedFrom"]; ok { + logger.Info("found copied ClusterServiceVersion, adding to whiteout") + whiteOut = true + } + } + + if err != nil { + return transform.PluginResponse{}, err + } + return transform.PluginResponse{ + Version: string(transform.V1), + IsWhiteOut: whiteOut, + Patches: patch, + }, nil +} diff --git a/openshift/openshift.go b/openshift/openshift.go index 13311a0..040ffbf 100644 --- a/openshift/openshift.go +++ b/openshift/openshift.go @@ -417,3 +417,88 @@ func getSecretReferencesServiceAccount(u unstructured.Unstructured) []v1.ObjectR return sa.Secrets } + +// stripSecurityContext removes cluster-specific runtime security context values +// that are injected by the SCC admission controller. This prevents SCC validation +// failures when migrating between OpenShift clusters with different namespace UID ranges. +func stripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) { + kind := u.GetKind() + + // Only process workload resources + if kind != "Pod" && kind != "Deployment" && kind != "StatefulSet" && + kind != "DaemonSet" && kind != "Job" && kind != "CronJob" && + kind != "ReplicaSet" && kind != "ReplicationController" { + return jsonpatch.Patch{}, nil + } + + // Create a copy to modify + modified := u.DeepCopy() + + // Remove pod-level spec.securityContext + unstructured.RemoveNestedField(modified.Object, "spec", "securityContext") + + // For workload controllers, remove spec.template.spec.securityContext + if kind != "Pod" { + if kind == "CronJob" { + // CronJob has spec.jobTemplate.spec.template.spec + unstructured.RemoveNestedField(modified.Object, "spec", "jobTemplate", "spec", "template", "spec", "securityContext") + } else { + // Deployment/StatefulSet/DaemonSet/Job/ReplicaSet/ReplicationController have spec.template.spec + unstructured.RemoveNestedField(modified.Object, "spec", "template", "spec", "securityContext") + } + } + + // Helper function to strip container securityContext + stripContainerSecurityContext := func(containersPath ...string) { + containers, found, _ := unstructured.NestedSlice(modified.Object, containersPath...) + if found { + for i, c := range containers { + if container, ok := c.(map[string]interface{}); ok { + delete(container, "securityContext") + containers[i] = container + } + } + unstructured.SetNestedSlice(modified.Object, containers, containersPath...) + } + } + + // Determine base path for containers + var basePath []string + if kind == "Pod" { + basePath = []string{"spec"} + } else if kind == "CronJob" { + basePath = []string{"spec", "jobTemplate", "spec", "template", "spec"} + } else { + basePath = []string{"spec", "template", "spec"} + } + + // Remove container-level securityContext + containersPath := append(basePath, "containers") + stripContainerSecurityContext(containersPath...) + + // Remove initContainers securityContext + initContainersPath := append(basePath, "initContainers") + stripContainerSecurityContext(initContainersPath...) + + // Remove ephemeralContainers securityContext (if present) + ephemeralContainersPath := append(basePath, "ephemeralContainers") + stripContainerSecurityContext(ephemeralContainersPath...) + + // Generate patch between original and modified + originalJSON, err := u.MarshalJSON() + if err != nil { + return nil, err + } + + modifiedJSON, err := modified.MarshalJSON() + if err != nil { + return nil, err + } + + patch, err := jsonpatch.CreatePatch(originalJSON, modifiedJSON) + if err != nil { + return nil, err + } + + return patch, nil +} From 171a9d7368c5cfddb8158ef4119049c87f22c722 Mon Sep 17 00:00:00 2001 From: Nandini Chandra Date: Mon, 22 Jun 2026 18:30:03 -0500 Subject: [PATCH 2/5] Remove SCC-injected security contexts for cross-cluster migration Signed-off-by: Nandini Chandra --- cmd.go | 204 -------------- openshift/openshift.go | 301 ++++++++++++++++---- openshift/plugin.go | 23 +- openshift/plugin_test.go | 588 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 857 insertions(+), 259 deletions(-) delete mode 100644 cmd.go diff --git a/cmd.go b/cmd.go deleted file mode 100644 index 1bff531..0000000 --- a/cmd.go +++ /dev/null @@ -1,204 +0,0 @@ -package main - -import ( - "strconv" - - jsonpatch "github.com/evanphx/json-patch" - "github.com/konveyor/crane-lib/transform" - "github.com/konveyor/crane-lib/transform/cli" - "github.com/konveyor/crane-lib/transform/util" - "github.com/sirupsen/logrus" -) - -var ( - logger logrus.FieldLogger - authorizationGroup = "authorization.openshift.io" -) - -const Version = "v0.0.4" - -const ( - // flags - StripDefaultRBACFlag = "strip-default-rbac" - StripDefaultCABundleFlag = "strip-default-cabundle" - StripDefaultPullSecretsFlag = "strip-default-pull-secrets" - PullSecretReplacementFlag = "pull-secret-replacement" - PVCRenameMap = "pvc-rename-map" - RegistryReplacementflag = "registry-replacement" -) - -func main() { - logger = logrus.New() - // TODO: add plumbing for logger in the cli-library and instantiate here - fields := []transform.OptionalFields{ - { - FlagName: StripDefaultRBACFlag, - Help: "Whether to strip default RBAC including builder and deployers serviceAccounts, roleBindings for admin, builders, and deployers (default: true)", - Example: "true", - }, - { - FlagName: StripDefaultCABundleFlag, - Help: "Whether to strip default CA Bundle (default: true)", - Example: "true", - }, - { - FlagName: StripDefaultPullSecretsFlag, - Help: "Whether to strip Pod and BuildConfig default pull secrets (beginning with builder/default/deployer-dockercfg-) that aren't replaced by the map param " + PullSecretReplacementFlag + " (default: true)", - Example: "true", - }, - { - FlagName: PullSecretReplacementFlag, - Help: "Map of pull secrets to replace in Pods and BuildConfigs while transforming in format secret1=destsecret1,secret2=destsecret2[...]", - Example: "default-dockercfg-h4n7g=default-dockercfg-12345,builder-dockercfg-abcde=builder-dockercfg-12345", - }, - { - FlagName: RegistryReplacementflag, - Help: "Map of image registry paths to swap on transform, in the format original-registry1=target-registry1,original-registry2=target-registry2...", - Example: "docker-registry.default.svc:5000=image-registry.openshift-image-registry.svc:5000,docker.io/foo=quay.io/bar", - }, - { - FlagName: PVCRenameMap, - Help: "A comma-separated list of colon separated pvc renames.", - Example: "old-pvc1-name:new-pvc1-name,old-pvc2-name:new-pvc2-name", - }, - } - cli.RunAndExit(cli.NewCustomPlugin("OpenShiftPlugin", Version, fields, Run)) -} - -type openshiftOptionalFields struct { - StripDefaultRBAC bool - StripDefaultCABundle bool - StripDefaultPullSecrets bool - PullSecretReplacement map[string]string - PVCRenameMap map[string]string - RegistryReplacement map[string]string -} - -func getOptionalFields(extras map[string]string) (openshiftOptionalFields, error) { - fields := openshiftOptionalFields{ - StripDefaultRBAC: true, - StripDefaultCABundle: true, - StripDefaultPullSecrets: true, - } - var err error - if len(extras[StripDefaultRBACFlag]) > 0 { - fields.StripDefaultRBAC, err = strconv.ParseBool(extras[StripDefaultRBACFlag]) - if err != nil { - return fields, err - } - } - if len(extras[StripDefaultCABundleFlag]) > 0 { - fields.StripDefaultCABundle, err = strconv.ParseBool(extras[StripDefaultCABundleFlag]) - if err != nil { - return fields, err - } - } - if len(extras[StripDefaultPullSecretsFlag]) > 0 { - fields.StripDefaultPullSecrets, err = strconv.ParseBool(extras[StripDefaultPullSecretsFlag]) - if err != nil { - return fields, err - } - } - if len(extras[PullSecretReplacementFlag]) > 0 { - fields.PullSecretReplacement = transform.ParseOptionalFieldMapVal(extras[PullSecretReplacementFlag]) - } - if len(extras[RegistryReplacementflag]) > 0 { - fields.RegistryReplacement = transform.ParseOptionalFieldMapVal(extras[RegistryReplacementflag]) - } - if len(extras[PVCRenameMap]) > 0 { - pvcMap, err := util.ProcessPVCMap(extras[PVCRenameMap]) - if err != nil { - return fields, err - } - fields.PVCRenameMap = pvcMap - } - return fields, nil -} - -func Run(request transform.PluginRequest) (transform.PluginResponse, error) { - u := request.Unstructured - var patch jsonpatch.Patch - whiteOut := false - inputFields, err := getOptionalFields(request.Extras) - if err != nil { - return transform.PluginResponse{}, err - } - - if authorizationGroup == u.GetObjectKind().GroupVersionKind().GroupKind().Group { - return transform.PluginResponse{ - Version: string(transform.V1), - IsWhiteOut: true, - Patches: patch, - }, nil - } - - switch u.GetKind() { - case "Build": - logger.Info("found build, adding to whiteout") - whiteOut = true - case "BuildConfig": - logger.Info("found build config, processing") - patch, err = UpdateBuildConfig(u, inputFields) - case "DeploymentConfig": - logger.Info("found deployment config, processing") - patch, err = UpdateDeploymentConfig(u, inputFields) - case "Pod": - logger.Info("found pod, processing update default pull secret") - patch, err = UpdateDefaultPullSecrets(u, inputFields) - if err == nil { - securityPatch, secErr := stripSecurityContext(u) - if secErr != nil { - err = secErr - } else { - patch = append(patch, securityPatch...) - } - } - case "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController": - logger.Infof("found %s, stripping security context", u.GetKind()) - patch, err = stripSecurityContext(u) - case "Route": - logger.Info("found route, processing") - patch, err = UpdateRoute(u) - case "ServiceAccount": - if inputFields.StripDefaultRBAC && (u.GetName() == "builder" || u.GetName() == "deployer") { - whiteOut = true - } else { - logger.Info("found service account, processing") - patch, err = UpdateServiceAccount(u) - } - case "Secret": - if inputFields.StripDefaultRBAC { - if sa, ok := u.GetAnnotations()["kubernetes.io/service-account.name"]; ok && (sa == "builder" || sa == "deployer" || sa == "pipeline") { - whiteOut = true - } - } - case "RoleBinding": - logger.Info("found role binding, processing") - if inputFields.StripDefaultRBAC && (u.GetName() == "admin" || - u.GetName() == "system:deployers" || - u.GetName() == "system:image-builders" || - u.GetName() == "system:image-pullers") { - whiteOut = true - } else { - patch, err = UpdateRoleBinding(u) - } - case "ConfigMap": - if inputFields.StripDefaultCABundle && u.GetName() == "openshift-service-ca.crt" { - whiteOut = true - } - case "ClusterServiceVersion": - if _, ok := u.GetLabels()["olm.copiedFrom"]; ok { - logger.Info("found copied ClusterServiceVersion, adding to whiteout") - whiteOut = true - } - } - - if err != nil { - return transform.PluginResponse{}, err - } - return transform.PluginResponse{ - Version: string(transform.V1), - IsWhiteOut: whiteOut, - Patches: patch, - }, nil -} diff --git a/openshift/openshift.go b/openshift/openshift.go index 040ffbf..98da22f 100644 --- a/openshift/openshift.go +++ b/openshift/openshift.go @@ -40,6 +40,10 @@ const ( buildConfigSourceImagesPullSecret = "/spec/source/images/%v/pullSecret" buildConfigSourceImagesFrom = "/spec/source/images/%v/from/name" roleBindingSubject = "/subjects/%d/namespace" + + // SCCNamespaceUIDMin is the minimum UID value for OpenShift SCC-injected namespace UID ranges. + // UIDs >= this value are considered SCC-injected and should be stripped during migration. + SCCNamespaceUIDMin int64 = 1000000000 ) var defaultPullSecrets = []string{"builder-dockercfg-", "default-dockercfg-", "deployer-dockercfg-"} @@ -47,7 +51,7 @@ var defaultPullSecrets = []string{"builder-dockercfg-", "default-dockercfg-", "d func updateBuildConfigImageReference( imgRef v1.ObjectReference, imgPath string, - fields openshiftOptionalFields, + fields OpenshiftOptionalFields, ) (jsonpatch.Patch, error) { patch := jsonpatch.Patch{} var err error @@ -67,7 +71,7 @@ func updateBuildConfigImageReference( return patch, nil } -func UpdateDefaultPullSecrets(u unstructured.Unstructured, fields openshiftOptionalFields) (jsonpatch.Patch, error) { +func UpdateDefaultPullSecrets(u unstructured.Unstructured, fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { return updateSecretsForSlice(getPullSecrets(u), podReplaceImagePullSecret, podRemoveImagePullSecret, fields) } @@ -75,7 +79,7 @@ func updateSecretsForSlice( pullSecrets []v1.LocalObjectReference, replaceOp string, removeOp string, - fields openshiftOptionalFields) (jsonpatch.Patch, error) { + fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { var err error replacePatch := jsonpatch.Patch{} @@ -155,7 +159,7 @@ func UpdateRoleBinding(u unstructured.Unstructured) (jsonpatch.Patch, error) { func updateSecret( pullSecret *v1.LocalObjectReference, secretPath string, - fields openshiftOptionalFields) (jsonpatch.Patch, error) { + fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { var err error patch := jsonpatch.Patch{} @@ -271,7 +275,7 @@ func isDefault(name string) bool { return false } -func UpdateBuildConfig(u unstructured.Unstructured, fields openshiftOptionalFields) (jsonpatch.Patch, error) { +func UpdateBuildConfig(u unstructured.Unstructured, fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { jsonPatch := jsonpatch.Patch{} js, err := u.MarshalJSON() if err != nil { @@ -355,7 +359,7 @@ func UpdateBuildConfig(u unstructured.Unstructured, fields openshiftOptionalFiel return jsonPatch, nil } -func UpdateDeploymentConfig(u unstructured.Unstructured, fields openshiftOptionalFields) (jsonpatch.Patch, error) { +func UpdateDeploymentConfig(u unstructured.Unstructured, fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { js, err := u.MarshalJSON() if err != nil { return nil, err @@ -418,10 +422,34 @@ func getSecretReferencesServiceAccount(u unstructured.Unstructured) []v1.ObjectR return sa.Secrets } -// stripSecurityContext removes cluster-specific runtime security context values -// that are injected by the SCC admission controller. This prevents SCC validation -// failures when migrating between OpenShift clusters with different namespace UID ranges. -func stripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) { +// StripSecurityContext removes SCC-injected security context values while preserving +// user-configured values. This prevents SCC validation failures when migrating between +// OpenShift clusters with different namespace UID ranges. +// +// Only strips: +// - runAsUser when >= SCCNamespaceUIDMin (SCC-injected namespace UID range) +// - fsGroup when >= SCCNamespaceUIDMin (SCC-injected namespace UID range) +// - seLinuxOptions.level (always SCC-injected) +// +// Preserves all other security context values (capabilities, readOnlyRootFilesystem, etc.) +// +// Example: +// +// Before: +// securityContext: +// runAsUser: 1000560000 # SCC-injected (>= SCCNamespaceUIDMin) +// fsGroup: 1000560000 # SCC-injected +// runAsNonRoot: true # User-configured +// seLinuxOptions: +// level: s0:c26,c5 # SCC-injected +// type: spc_t # User-configured +// +// After: +// securityContext: +// runAsNonRoot: true # Preserved +// seLinuxOptions: +// type: spc_t # Preserved +func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) { kind := u.GetKind() // Only process workload resources @@ -434,68 +462,241 @@ func stripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) // Create a copy to modify modified := u.DeepCopy() - // Remove pod-level spec.securityContext - unstructured.RemoveNestedField(modified.Object, "spec", "securityContext") + // Determine base path for pod spec + var basePath []string + if kind == "Pod" { + basePath = []string{"spec"} + } else if kind == "CronJob" { + basePath = []string{"spec", "jobTemplate", "spec", "template", "spec"} + } else { + basePath = []string{"spec", "template", "spec"} + } + + // Strip SCC-injected values from pod-level securityContext + stripPodSecurityContext := func(scPath ...string) error { + sc, found, _ := unstructured.NestedMap(modified.Object, scPath...) + if !found || sc == nil { + return nil + } + + // Strip runAsUser if >= 1000000000 + if runAsUser, ok := sc["runAsUser"].(int64); ok && runAsUser >= SCCNamespaceUIDMin { + delete(sc, "runAsUser") + } + + // Strip fsGroup if >= 1000000000 + if fsGroup, ok := sc["fsGroup"].(int64); ok && fsGroup >= SCCNamespaceUIDMin { + delete(sc, "fsGroup") + } - // For workload controllers, remove spec.template.spec.securityContext - if kind != "Pod" { - if kind == "CronJob" { - // CronJob has spec.jobTemplate.spec.template.spec - unstructured.RemoveNestedField(modified.Object, "spec", "jobTemplate", "spec", "template", "spec", "securityContext") + // Strip seLinuxOptions.level (always SCC-injected) + if seLinuxOpts, ok := sc["seLinuxOptions"].(map[string]interface{}); ok { + delete(seLinuxOpts, "level") + // If seLinuxOptions is now empty, remove it entirely + if len(seLinuxOpts) == 0 { + delete(sc, "seLinuxOptions") + } else { + sc["seLinuxOptions"] = seLinuxOpts + } + } + + // If securityContext is now empty, remove it entirely + if len(sc) == 0 { + unstructured.RemoveNestedField(modified.Object, scPath...) } else { - // Deployment/StatefulSet/DaemonSet/Job/ReplicaSet/ReplicationController have spec.template.spec - unstructured.RemoveNestedField(modified.Object, "spec", "template", "spec", "securityContext") + if err := unstructured.SetNestedMap(modified.Object, sc, scPath...); err != nil { + return err + } } + return nil + } + + // Strip pod-level security context + podSecurityContextPath := append(basePath, "securityContext") + if err := stripPodSecurityContext(podSecurityContextPath...); err != nil { + return nil, err } // Helper function to strip container securityContext - stripContainerSecurityContext := func(containersPath ...string) { + stripContainerSecurityContext := func(containersPath ...string) error { containers, found, _ := unstructured.NestedSlice(modified.Object, containersPath...) - if found { - for i, c := range containers { - if container, ok := c.(map[string]interface{}); ok { - delete(container, "securityContext") - containers[i] = container + if !found { + return nil + } + + for i, c := range containers { + container, ok := c.(map[string]interface{}) + if !ok { + continue + } + + sc, ok := container["securityContext"].(map[string]interface{}) + if !ok || sc == nil { + continue + } + + // Strip runAsUser if >= 1000000000 + if runAsUser, ok := sc["runAsUser"].(int64); ok && runAsUser >= SCCNamespaceUIDMin { + delete(sc, "runAsUser") + } + + // Strip fsGroup if >= 1000000000 + if fsGroup, ok := sc["fsGroup"].(int64); ok && fsGroup >= SCCNamespaceUIDMin { + delete(sc, "fsGroup") + } + + // Strip seLinuxOptions.level (always SCC-injected) + if seLinuxOpts, ok := sc["seLinuxOptions"].(map[string]interface{}); ok { + delete(seLinuxOpts, "level") + // If seLinuxOptions is now empty, remove it entirely + if len(seLinuxOpts) == 0 { + delete(sc, "seLinuxOptions") + } else { + sc["seLinuxOptions"] = seLinuxOpts } } - unstructured.SetNestedSlice(modified.Object, containers, containersPath...) - } - } - // Determine base path for containers - var basePath []string - if kind == "Pod" { - basePath = []string{"spec"} - } else if kind == "CronJob" { - basePath = []string{"spec", "jobTemplate", "spec", "template", "spec"} - } else { - basePath = []string{"spec", "template", "spec"} + // If securityContext is now empty, remove it entirely + if len(sc) == 0 { + delete(container, "securityContext") + } else { + container["securityContext"] = sc + } + + containers[i] = container + } + if err := unstructured.SetNestedSlice(modified.Object, containers, containersPath...); err != nil { + return err + } + return nil } - // Remove container-level securityContext + // Strip container-level securityContext containersPath := append(basePath, "containers") - stripContainerSecurityContext(containersPath...) + if err := stripContainerSecurityContext(containersPath...); err != nil { + return nil, err + } - // Remove initContainers securityContext + // Strip initContainers securityContext initContainersPath := append(basePath, "initContainers") - stripContainerSecurityContext(initContainersPath...) + if err := stripContainerSecurityContext(initContainersPath...); err != nil { + return nil, err + } - // Remove ephemeralContainers securityContext (if present) + // Strip ephemeralContainers securityContext (if present) ephemeralContainersPath := append(basePath, "ephemeralContainers") - stripContainerSecurityContext(ephemeralContainersPath...) - - // Generate patch between original and modified - originalJSON, err := u.MarshalJSON() - if err != nil { + if err := stripContainerSecurityContext(ephemeralContainersPath...); err != nil { return nil, err } - modifiedJSON, err := modified.MarshalJSON() - if err != nil { - return nil, err + // Generate patch by comparing original and modified + // Build JSON patch operations for fields that were removed + var patchOps []string + + // Helper to build path string + buildPath := func(parts ...string) string { + result := "" + for _, part := range parts { + if part != "" { + result += "/" + part + } + } + return result + } + + // Check what changed in pod-level securityContext + origSC, _, _ := unstructured.NestedMap(u.Object, append(basePath, "securityContext")...) + modSC, _, _ := unstructured.NestedMap(modified.Object, append(basePath, "securityContext")...) + + if origSC != nil && modSC != nil { + scPath := buildPath(basePath...) + "/securityContext" + // Check each field + if _, origHas := origSC["runAsUser"]; origHas { + if _, modHas := modSC["runAsUser"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/runAsUser"}`, scPath)) + } + } + if _, origHas := origSC["fsGroup"]; origHas { + if _, modHas := modSC["fsGroup"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/fsGroup"}`, scPath)) + } + } + if origOpts, ok := origSC["seLinuxOptions"].(map[string]interface{}); ok { + if modOpts, ok := modSC["seLinuxOptions"].(map[string]interface{}); ok { + if _, origHas := origOpts["level"]; origHas { + if _, modHas := modOpts["level"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions/level"}`, scPath)) + } + } + } else if len(origOpts) > 0 { + // seLinuxOptions was removed entirely + if _, hasLevel := origOpts["level"]; hasLevel && len(origOpts) == 1 { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions"}`, scPath)) + } + } + } + } else if origSC != nil && modSC == nil { + // Entire securityContext was removed + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s"}`, buildPath(basePath...)+"/securityContext")) + } + + // Check container changes + checkContainerChanges := func(containerType string) { + origContainers, _, _ := unstructured.NestedSlice(u.Object, append(basePath, containerType)...) + modContainers, _, _ := unstructured.NestedSlice(modified.Object, append(basePath, containerType)...) + + for i := 0; i < len(origContainers) && i < len(modContainers); i++ { + origC, ok1 := origContainers[i].(map[string]interface{}) + modC, ok2 := modContainers[i].(map[string]interface{}) + if !ok1 || !ok2 { + continue + } + + origSC, _ := origC["securityContext"].(map[string]interface{}) + modSC, ok2 := modC["securityContext"].(map[string]interface{}) + + containerPath := buildPath(basePath...) + "/" + containerType + "/" + fmt.Sprintf("%d", i) + "/securityContext" + + if origSC != nil && modSC != nil { + if _, origHas := origSC["runAsUser"]; origHas { + if _, modHas := modSC["runAsUser"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/runAsUser"}`, containerPath)) + } + } + if _, origHas := origSC["fsGroup"]; origHas { + if _, modHas := modSC["fsGroup"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/fsGroup"}`, containerPath)) + } + } + if origOpts, ok := origSC["seLinuxOptions"].(map[string]interface{}); ok { + if modOpts, ok := modSC["seLinuxOptions"].(map[string]interface{}); ok { + if _, origHas := origOpts["level"]; origHas { + if _, modHas := modOpts["level"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions/level"}`, containerPath)) + } + } + } else if len(origOpts) > 0 { + if _, hasLevel := origOpts["level"]; hasLevel && len(origOpts) == 1 { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions"}`, containerPath)) + } + } + } + } else if origSC != nil && !ok2 { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s"}`, containerPath)) + } + } + } + + checkContainerChanges("containers") + checkContainerChanges("initContainers") + checkContainerChanges("ephemeralContainers") + + if len(patchOps) == 0 { + return jsonpatch.Patch{}, nil } - patch, err := jsonpatch.CreatePatch(originalJSON, modifiedJSON) + patchJSON := "[" + strings.Join(patchOps, ",") + "]" + patch, err := jsonpatch.DecodePatch([]byte(patchJSON)) if err != nil { return nil, err } diff --git a/openshift/plugin.go b/openshift/plugin.go index d1b29a7..97a64f4 100644 --- a/openshift/plugin.go +++ b/openshift/plugin.go @@ -72,7 +72,7 @@ func (o *OpenShiftTransformPlugin) Run(request transform.PluginRequest) (transfo u := request.Unstructured var patch jsonpatch.Patch whiteOut := false - inputFields, err := parseOptionalFields(request.Extras) + inputFields, err := ParseOptionalFields(request.Extras) if err != nil { return transform.PluginResponse{}, err } @@ -110,7 +110,18 @@ func (o *OpenShiftTransformPlugin) Run(request transform.PluginRequest) (transfo patch, err = UpdateDeploymentConfig(u, inputFields) case "Pod": o.log().Info("found pod, processing update default pull secret") - patch, err = UpdateDefaultPullSecrets(u, inputFields) + pullSecretPatch, err := UpdateDefaultPullSecrets(u, inputFields) + if err != nil { + break + } + securityContextPatch, err := StripSecurityContext(u) + if err != nil { + break + } + patch = append(pullSecretPatch, securityContextPatch...) + case "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController": + o.log().Infof("found %s, stripping SCC-injected security context", u.GetKind()) + patch, err = StripSecurityContext(u) case "Route": o.log().Info("found route, processing") patch, err = UpdateRoute(u) @@ -165,7 +176,8 @@ func (o *OpenShiftTransformPlugin) log() logrus.FieldLogger { return logrus.New() } -type openshiftOptionalFields struct { +// OpenshiftOptionalFields contains the optional configuration fields for OpenShift transformations +type OpenshiftOptionalFields struct { StripDefaultRBAC bool StripDefaultCABundle bool StripDefaultPullSecrets bool @@ -174,8 +186,9 @@ type openshiftOptionalFields struct { RegistryReplacement map[string]string } -func parseOptionalFields(extras map[string]string) (openshiftOptionalFields, error) { - fields := openshiftOptionalFields{ +// ParseOptionalFields parses the extras map into OpenshiftOptionalFields +func ParseOptionalFields(extras map[string]string) (OpenshiftOptionalFields, error) { + fields := OpenshiftOptionalFields{ StripDefaultRBAC: true, StripDefaultCABundle: true, StripDefaultPullSecrets: true, diff --git a/openshift/plugin_test.go b/openshift/plugin_test.go index d0f41a7..0e06988 100644 --- a/openshift/plugin_test.go +++ b/openshift/plugin_test.go @@ -2,6 +2,8 @@ package openshift import ( "bytes" + "encoding/json" + "fmt" "testing" "github.com/konveyor/crane-lib/transform" @@ -580,3 +582,589 @@ func findSubstring(s, substr string) bool { } return false } + +func TestStripSecurityContext(t *testing.T) { + tests := []struct { + name string + resource *unstructured.Unstructured + expectPatch bool + description string + }{ + { + name: "Strip SCC-injected runAsUser (>= 1000000000) from Pod", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "fsGroup": int64(1000560000), + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", + }, + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip SCC-injected values (runAsUser, fsGroup >= 1000000000, seLinuxOptions.level)", + }, + { + name: "Keep user-configured runAsUser (< 1000000000) in Pod", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1001), + "fsGroup": int64(2000), + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1001), + "capabilities": map[string]interface{}{ + "drop": []interface{}{"ALL"}, + }, + }, + }, + }, + }, + }, + }, + expectPatch: false, + description: "Should keep user-configured runAsUser < 1000000000 and other security settings", + }, + { + name: "Keep readOnlyRootFilesystem and other user settings in Deployment", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "fsGroup": int64(2000), + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "readOnlyRootFilesystem": true, + "allowPrivilegeEscalation": false, + "capabilities": map[string]interface{}{ + "drop": []interface{}{"ALL"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip SCC-injected runAsUser but keep readOnlyRootFilesystem and other user settings", + }, + { + name: "Strip seLinuxOptions.level but keep other seLinuxOptions", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", + "type": "spc_t", + "user": "system_u", + "role": "system_r", + }, + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip seLinuxOptions.level but keep user-configured type, user, role", + }, + { + name: "Handle mixed SCC and user values in StatefulSet", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": map[string]interface{}{ + "name": "test-statefulset", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "fsGroup": int64(1000560000), // SCC-injected + "runAsNonRoot": true, // user-configured + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "readOnlyRootFilesystem": true, // user-configured + "allowPrivilegeEscalation": false, // user-configured + }, + }, + }, + "initContainers": []interface{}{ + map[string]interface{}{ + "name": "init-container", + "image": "init:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", // SCC-injected + "type": "init_t", // user-configured + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should handle mixed SCC-injected and user-configured values across containers and initContainers", + }, + { + name: "Handle CronJob with nested template", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "CronJob", + "metadata": map[string]interface{}{ + "name": "test-cronjob", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "jobTemplate": map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "fsGroup": int64(1000560000), + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should handle CronJob's nested jobTemplate structure", + }, + { + name: "Strip SCC-injected values from ephemeralContainers", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod-with-ephemeral", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "main-container", + "image": "main:latest", + }, + }, + "ephemeralContainers": []interface{}{ + map[string]interface{}{ + "name": "debug-container", + "image": "debug:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "fsGroup": int64(1000560000), // SCC-injected + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", // SCC-injected + "type": "spc_t", // user-configured + }, + "capabilities": map[string]interface{}{ // user-configured + "add": []interface{}{"SYS_PTRACE"}, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip SCC-injected values from ephemeralContainers while preserving user-configured fields", + }, + { + name: "Non-workload resource should not be processed", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-service", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "ports": []interface{}{ + map[string]interface{}{ + "port": 80, + }, + }, + }, + }, + }, + expectPatch: false, + description: "Should skip non-workload resources", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &OpenShiftTransformPlugin{ + Log: logrus.New(), + } + + request := transform.PluginRequest{ + Unstructured: *tt.resource, + Extras: map[string]string{ + StripDefaultPullSecretsFlag: "false", + }, + } + + response, err := plugin.Run(request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hasPatch := len(response.Patches) > 0 + if hasPatch != tt.expectPatch { + t.Errorf("%s: expected patch=%v, got %v (patches: %+v)", tt.description, tt.expectPatch, hasPatch, response.Patches) + } + + // Apply patches and verify the result + if tt.expectPatch && len(response.Patches) > 0 { + originalJSON, err := tt.resource.MarshalJSON() + if err != nil { + t.Fatalf("failed to marshal original: %v", err) + } + + modifiedJSON, err := response.Patches.Apply(originalJSON) + if err != nil { + t.Fatalf("failed to apply patches: %v", err) + } + + // Unmarshal to verify structure + var modified map[string]interface{} + err = json.Unmarshal(modifiedJSON, &modified) + if err != nil { + t.Fatalf("failed to unmarshal modified: %v", err) + } + + // Verify SCC-injected values were removed + verifySecurityContext := func(sc interface{}, path string) { + if sc == nil { + return + } + scMap, ok := sc.(map[string]interface{}) + if !ok { + return + } + + // Check runAsUser + if runAsUser, ok := scMap["runAsUser"].(float64); ok { + if int64(runAsUser) >= SCCNamespaceUIDMin { + t.Errorf("%s at %s: SCC-injected runAsUser %d should have been stripped", tt.description, path, int64(runAsUser)) + } + } + + // Check fsGroup + if fsGroup, ok := scMap["fsGroup"].(float64); ok { + if int64(fsGroup) >= SCCNamespaceUIDMin { + t.Errorf("%s at %s: SCC-injected fsGroup %d should have been stripped", tt.description, path, int64(fsGroup)) + } + } + + // Check seLinuxOptions.level + if seLinuxOpts, ok := scMap["seLinuxOptions"].(map[string]interface{}); ok { + if _, hasLevel := seLinuxOpts["level"]; hasLevel { + t.Errorf("%s at %s: seLinuxOptions.level should have been stripped", tt.description, path) + } + } + } + + // Get the appropriate spec path + var spec map[string]interface{} + var expectedPath string + if tt.resource.GetKind() == "Pod" { + spec, _ = modified["spec"].(map[string]interface{}) + expectedPath = "spec" + } else if tt.resource.GetKind() == "CronJob" { + jobTemplate, _ := modified["spec"].(map[string]interface{}) + jobSpec, _ := jobTemplate["jobTemplate"].(map[string]interface{}) + templateSpec, _ := jobSpec["spec"].(map[string]interface{}) + template, _ := templateSpec["template"].(map[string]interface{}) + spec, _ = template["spec"].(map[string]interface{}) + expectedPath = "spec.jobTemplate.spec.template.spec" + } else { + templateObj, _ := modified["spec"].(map[string]interface{}) + template, _ := templateObj["template"].(map[string]interface{}) + spec, _ = template["spec"].(map[string]interface{}) + expectedPath = "spec.template.spec" + } + + // Validate that spec was found when patches are expected + if spec == nil && tt.expectPatch { + t.Fatalf("%s: expected to find pod spec at %s for kind %s after patch application, but spec is nil - patch may have failed or test data is incorrect", + tt.description, expectedPath, tt.resource.GetKind()) + } + + if spec != nil { + // Verify pod-level security context + if podSC, ok := spec["securityContext"]; ok { + verifySecurityContext(podSC, "spec.securityContext") + } + + // Verify container security contexts + if containers, ok := spec["containers"].([]interface{}); ok { + for i, c := range containers { + container, _ := c.(map[string]interface{}) + if containerSC, ok := container["securityContext"]; ok { + verifySecurityContext(containerSC, fmt.Sprintf("spec.containers[%d].securityContext", i)) + } + } + } + + // Verify initContainer security contexts + if initContainers, ok := spec["initContainers"].([]interface{}); ok { + for i, c := range initContainers { + container, _ := c.(map[string]interface{}) + if containerSC, ok := container["securityContext"]; ok { + verifySecurityContext(containerSC, fmt.Sprintf("spec.initContainers[%d].securityContext", i)) + } + } + } + + // Verify ephemeralContainer security contexts + if ephemeralContainers, ok := spec["ephemeralContainers"].([]interface{}); ok { + for i, c := range ephemeralContainers { + container, _ := c.(map[string]interface{}) + if containerSC, ok := container["securityContext"]; ok { + verifySecurityContext(containerSC, fmt.Sprintf("spec.ephemeralContainers[%d].securityContext", i)) + } + } + } + + // Verify user-configured fields are preserved (positive assertions) + // Check that fields which existed in the original and are not SCC-injected are still present + verifyPreservedFields := func(origSpec, modSpec map[string]interface{}, pathPrefix string) { + // Helper to check if a security context field is preserved + checkPreserved := func(origSC, modSC map[string]interface{}, field, path string) { + if origVal, hasOrig := origSC[field]; hasOrig { + modVal, hasMod := modSC[field] + if !hasMod { + // Field was removed - check if it should have been preserved + // Skip fields that might be legitimately removed (SCC-injected UIDs, etc.) + switch field { + case "runAsUser", "fsGroup": + // Only complain if the value was < 1000000000 (user-configured) + if val, ok := origVal.(int64); ok && val < SCCNamespaceUIDMin { + t.Errorf("%s at %s: user-configured %s=%v was removed but should be preserved", + tt.description, path, field, origVal) + } + case "seLinuxOptions": + // Check individual seLinuxOptions fields + if origOpts, ok := origVal.(map[string]interface{}); ok { + for optField, optVal := range origOpts { + if optField != "level" { // level is SCC-injected, always removed + t.Errorf("%s at %s: user-configured seLinuxOptions.%s=%v was removed but should be preserved", + tt.description, path, optField, optVal) + } + } + } + default: + // All other fields should be preserved + t.Errorf("%s at %s: user-configured %s=%v was removed but should be preserved", + tt.description, path, field, origVal) + } + return + } + + // Field exists; for seLinuxOptions validate nested preserved keys too. + if field == "seLinuxOptions" { + origOpts, ok1 := origVal.(map[string]interface{}) + modOpts, ok2 := modVal.(map[string]interface{}) + if ok1 && ok2 { + for optField, optVal := range origOpts { + if optField != "level" { + if _, hasModOpt := modOpts[optField]; !hasModOpt { + t.Errorf("%s at %s: user-configured seLinuxOptions.%s=%v was removed but should be preserved", + tt.description, path, optField, optVal) + } + } + } + } + } + } + } + + // Check pod-level security context + if origPodSC, ok := origSpec["securityContext"].(map[string]interface{}); ok { + if modPodSC, ok := modSpec["securityContext"].(map[string]interface{}); ok { + for field := range origPodSC { + checkPreserved(origPodSC, modPodSC, field, pathPrefix+".securityContext") + } + } else { + // modPodSC is missing entirely - check if origPodSC had user-configured fields + for field := range origPodSC { + checkPreserved(origPodSC, map[string]interface{}{}, field, pathPrefix+".securityContext") + } + } + } + + // Check container security contexts + origContainers, _ := origSpec["containers"].([]interface{}) + modContainers, _ := modSpec["containers"].([]interface{}) + for i := 0; i < len(origContainers) && i < len(modContainers); i++ { + origCont, _ := origContainers[i].(map[string]interface{}) + modCont, _ := modContainers[i].(map[string]interface{}) + if origSC, ok := origCont["securityContext"].(map[string]interface{}); ok { + if modSC, ok := modCont["securityContext"].(map[string]interface{}); ok { + for field := range origSC { + checkPreserved(origSC, modSC, field, fmt.Sprintf("%s.containers[%d].securityContext", pathPrefix, i)) + } + } else { + // modSC is missing entirely - check if origSC had user-configured fields + for field := range origSC { + checkPreserved(origSC, map[string]interface{}{}, field, fmt.Sprintf("%s.containers[%d].securityContext", pathPrefix, i)) + } + } + } + } + + // Check initContainer security contexts + origInitContainers, _ := origSpec["initContainers"].([]interface{}) + modInitContainers, _ := modSpec["initContainers"].([]interface{}) + for i := 0; i < len(origInitContainers) && i < len(modInitContainers); i++ { + origCont, _ := origInitContainers[i].(map[string]interface{}) + modCont, _ := modInitContainers[i].(map[string]interface{}) + if origSC, ok := origCont["securityContext"].(map[string]interface{}); ok { + if modSC, ok := modCont["securityContext"].(map[string]interface{}); ok { + for field := range origSC { + checkPreserved(origSC, modSC, field, fmt.Sprintf("%s.initContainers[%d].securityContext", pathPrefix, i)) + } + } else { + // modSC is missing entirely - check if origSC had user-configured fields + for field := range origSC { + checkPreserved(origSC, map[string]interface{}{}, field, fmt.Sprintf("%s.initContainers[%d].securityContext", pathPrefix, i)) + } + } + } + } + + // Check ephemeralContainer security contexts + origEphemeralContainers, _ := origSpec["ephemeralContainers"].([]interface{}) + modEphemeralContainers, _ := modSpec["ephemeralContainers"].([]interface{}) + for i := 0; i < len(origEphemeralContainers) && i < len(modEphemeralContainers); i++ { + origCont, _ := origEphemeralContainers[i].(map[string]interface{}) + modCont, _ := modEphemeralContainers[i].(map[string]interface{}) + if origSC, ok := origCont["securityContext"].(map[string]interface{}); ok { + if modSC, ok := modCont["securityContext"].(map[string]interface{}); ok { + for field := range origSC { + checkPreserved(origSC, modSC, field, fmt.Sprintf("%s.ephemeralContainers[%d].securityContext", pathPrefix, i)) + } + } else { + // modSC is missing entirely - check if origSC had user-configured fields + for field := range origSC { + checkPreserved(origSC, map[string]interface{}{}, field, fmt.Sprintf("%s.ephemeralContainers[%d].securityContext", pathPrefix, i)) + } + } + } + } + } + + // Get original spec for comparison + var origSpec map[string]interface{} + if tt.resource.GetKind() == "Pod" { + origSpec, _ = tt.resource.Object["spec"].(map[string]interface{}) + verifyPreservedFields(origSpec, spec, "spec") + } else if tt.resource.GetKind() == "CronJob" { + jobTemplate, _ := tt.resource.Object["spec"].(map[string]interface{}) + jobSpec, _ := jobTemplate["jobTemplate"].(map[string]interface{}) + templateSpec, _ := jobSpec["spec"].(map[string]interface{}) + template, _ := templateSpec["template"].(map[string]interface{}) + origSpec, _ = template["spec"].(map[string]interface{}) + verifyPreservedFields(origSpec, spec, "spec.jobTemplate.spec.template.spec") + } else { + templateObj, _ := tt.resource.Object["spec"].(map[string]interface{}) + template, _ := templateObj["template"].(map[string]interface{}) + origSpec, _ = template["spec"].(map[string]interface{}) + verifyPreservedFields(origSpec, spec, "spec.template.spec") + } + } + } + }) + } +} From 88c708ec25ecaae2fa6e95dabea56514eed39f75 Mon Sep 17 00:00:00 2001 From: Nandini Chandra Date: Mon, 22 Jun 2026 23:52:31 -0500 Subject: [PATCH 3/5] Remove SCC-injected security contexts for cross-cluster migration Signed-off-by: Nandini Chandra --- openshift/plugin_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openshift/plugin_test.go b/openshift/plugin_test.go index 0e06988..b4585b2 100644 --- a/openshift/plugin_test.go +++ b/openshift/plugin_test.go @@ -1020,7 +1020,9 @@ func TestStripSecurityContext(t *testing.T) { // Verify user-configured fields are preserved (positive assertions) // Check that fields which existed in the original and are not SCC-injected are still present verifyPreservedFields := func(origSpec, modSpec map[string]interface{}, pathPrefix string) { - // Helper to check if a security context field is preserved + // Helper to check if a security context field is preserved. + // modSC can be empty map (when entire securityContext is missing) - this triggers + // proper validation errors for removed user-configured fields. checkPreserved := func(origSC, modSC map[string]interface{}, field, path string) { if origVal, hasOrig := origSC[field]; hasOrig { modVal, hasMod := modSC[field] From ca5c84ca681d5579be1bffcf7a3fce6fd26289c8 Mon Sep 17 00:00:00 2001 From: Nandini Chandra Date: Tue, 23 Jun 2026 00:15:59 -0500 Subject: [PATCH 4/5] Remove SCC-injected security contexts for cross-cluster migration Signed-off-by: Nandini Chandra --- openshift/openshift.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/openshift/openshift.go b/openshift/openshift.go index 98da22f..2ce53a4 100644 --- a/openshift/openshift.go +++ b/openshift/openshift.go @@ -422,6 +422,21 @@ func getSecretReferencesServiceAccount(u unstructured.Unstructured) []v1.ObjectR return sa.Secrets } +// sccUID safely extracts a UID value from an interface{}, handling both int64 and float64 +// types. JSON unmarshaling of unstructured.Unstructured produces float64 for numeric values, +// so this helper ensures we can check SCC UID ranges regardless of the source type. +// Returns the int64 value and true if successful, or 0 and false if the type is neither. +func sccUID(v interface{}) (int64, bool) { + switch val := v.(type) { + case int64: + return val, true + case float64: + return int64(val), true + default: + return 0, false + } +} + // StripSecurityContext removes SCC-injected security context values while preserving // user-configured values. This prevents SCC validation failures when migrating between // OpenShift clusters with different namespace UID ranges. @@ -480,12 +495,12 @@ func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) } // Strip runAsUser if >= 1000000000 - if runAsUser, ok := sc["runAsUser"].(int64); ok && runAsUser >= SCCNamespaceUIDMin { + if runAsUser, ok := sccUID(sc["runAsUser"]); ok && runAsUser >= SCCNamespaceUIDMin { delete(sc, "runAsUser") } // Strip fsGroup if >= 1000000000 - if fsGroup, ok := sc["fsGroup"].(int64); ok && fsGroup >= SCCNamespaceUIDMin { + if fsGroup, ok := sccUID(sc["fsGroup"]); ok && fsGroup >= SCCNamespaceUIDMin { delete(sc, "fsGroup") } @@ -536,12 +551,12 @@ func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) } // Strip runAsUser if >= 1000000000 - if runAsUser, ok := sc["runAsUser"].(int64); ok && runAsUser >= SCCNamespaceUIDMin { + if runAsUser, ok := sccUID(sc["runAsUser"]); ok && runAsUser >= SCCNamespaceUIDMin { delete(sc, "runAsUser") } // Strip fsGroup if >= 1000000000 - if fsGroup, ok := sc["fsGroup"].(int64); ok && fsGroup >= SCCNamespaceUIDMin { + if fsGroup, ok := sccUID(sc["fsGroup"]); ok && fsGroup >= SCCNamespaceUIDMin { delete(sc, "fsGroup") } From e41fdcf38c736486f877622d73f51f9374ede3ac Mon Sep 17 00:00:00 2001 From: Nandini Chandra Date: Thu, 25 Jun 2026 22:54:36 -0500 Subject: [PATCH 5/5] Remove SCC-injected security contexts for cross-cluster migration Signed-off-by: Nandini Chandra --- openshift/openshift.go | 32 ++------------------------------ openshift/plugin_test.go | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 40 deletions(-) diff --git a/openshift/openshift.go b/openshift/openshift.go index 2ce53a4..e93ddd9 100644 --- a/openshift/openshift.go +++ b/openshift/openshift.go @@ -438,32 +438,14 @@ func sccUID(v interface{}) (int64, bool) { } // StripSecurityContext removes SCC-injected security context values while preserving -// user-configured values. This prevents SCC validation failures when migrating between -// OpenShift clusters with different namespace UID ranges. +// user-configured values. // // Only strips: // - runAsUser when >= SCCNamespaceUIDMin (SCC-injected namespace UID range) // - fsGroup when >= SCCNamespaceUIDMin (SCC-injected namespace UID range) // - seLinuxOptions.level (always SCC-injected) // -// Preserves all other security context values (capabilities, readOnlyRootFilesystem, etc.) -// -// Example: -// -// Before: -// securityContext: -// runAsUser: 1000560000 # SCC-injected (>= SCCNamespaceUIDMin) -// fsGroup: 1000560000 # SCC-injected -// runAsNonRoot: true # User-configured -// seLinuxOptions: -// level: s0:c26,c5 # SCC-injected -// type: spc_t # User-configured -// -// After: -// securityContext: -// runAsNonRoot: true # Preserved -// seLinuxOptions: -// type: spc_t # Preserved +// Preserves all other security context values (capabilities, readOnlyRootFilesystem, etc.) func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) { kind := u.GetKind() @@ -555,11 +537,6 @@ func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) delete(sc, "runAsUser") } - // Strip fsGroup if >= 1000000000 - if fsGroup, ok := sccUID(sc["fsGroup"]); ok && fsGroup >= SCCNamespaceUIDMin { - delete(sc, "fsGroup") - } - // Strip seLinuxOptions.level (always SCC-injected) if seLinuxOpts, ok := sc["seLinuxOptions"].(map[string]interface{}); ok { delete(seLinuxOpts, "level") @@ -678,11 +655,6 @@ func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/runAsUser"}`, containerPath)) } } - if _, origHas := origSC["fsGroup"]; origHas { - if _, modHas := modSC["fsGroup"]; !modHas { - patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/fsGroup"}`, containerPath)) - } - } if origOpts, ok := origSC["seLinuxOptions"].(map[string]interface{}); ok { if modOpts, ok := modSC["seLinuxOptions"].(map[string]interface{}); ok { if _, origHas := origOpts["level"]; origHas { diff --git a/openshift/plugin_test.go b/openshift/plugin_test.go index b4585b2..defc7be 100644 --- a/openshift/plugin_test.go +++ b/openshift/plugin_test.go @@ -840,7 +840,6 @@ func TestStripSecurityContext(t *testing.T) { "image": "debug:latest", "securityContext": map[string]interface{}{ "runAsUser": int64(1000560000), // SCC-injected - "fsGroup": int64(1000560000), // SCC-injected "seLinuxOptions": map[string]interface{}{ "level": "s0:c26,c5", // SCC-injected "type": "spc_t", // user-configured @@ -924,7 +923,7 @@ func TestStripSecurityContext(t *testing.T) { } // Verify SCC-injected values were removed - verifySecurityContext := func(sc interface{}, path string) { + verifySecurityContext := func(sc interface{}, path string, isPodLevel bool) { if sc == nil { return } @@ -940,10 +939,12 @@ func TestStripSecurityContext(t *testing.T) { } } - // Check fsGroup - if fsGroup, ok := scMap["fsGroup"].(float64); ok { - if int64(fsGroup) >= SCCNamespaceUIDMin { - t.Errorf("%s at %s: SCC-injected fsGroup %d should have been stripped", tt.description, path, int64(fsGroup)) + // Check fsGroup (only valid at pod level) + if isPodLevel { + if fsGroup, ok := scMap["fsGroup"].(float64); ok { + if int64(fsGroup) >= SCCNamespaceUIDMin { + t.Errorf("%s at %s: SCC-injected fsGroup %d should have been stripped", tt.description, path, int64(fsGroup)) + } } } @@ -984,7 +985,7 @@ func TestStripSecurityContext(t *testing.T) { if spec != nil { // Verify pod-level security context if podSC, ok := spec["securityContext"]; ok { - verifySecurityContext(podSC, "spec.securityContext") + verifySecurityContext(podSC, "spec.securityContext", true) } // Verify container security contexts @@ -992,7 +993,7 @@ func TestStripSecurityContext(t *testing.T) { for i, c := range containers { container, _ := c.(map[string]interface{}) if containerSC, ok := container["securityContext"]; ok { - verifySecurityContext(containerSC, fmt.Sprintf("spec.containers[%d].securityContext", i)) + verifySecurityContext(containerSC, fmt.Sprintf("spec.containers[%d].securityContext", i), false) } } } @@ -1002,7 +1003,7 @@ func TestStripSecurityContext(t *testing.T) { for i, c := range initContainers { container, _ := c.(map[string]interface{}) if containerSC, ok := container["securityContext"]; ok { - verifySecurityContext(containerSC, fmt.Sprintf("spec.initContainers[%d].securityContext", i)) + verifySecurityContext(containerSC, fmt.Sprintf("spec.initContainers[%d].securityContext", i), false) } } } @@ -1012,7 +1013,7 @@ func TestStripSecurityContext(t *testing.T) { for i, c := range ephemeralContainers { container, _ := c.(map[string]interface{}) if containerSC, ok := container["securityContext"]; ok { - verifySecurityContext(containerSC, fmt.Sprintf("spec.ephemeralContainers[%d].securityContext", i)) + verifySecurityContext(containerSC, fmt.Sprintf("spec.ephemeralContainers[%d].securityContext", i), false) } } }