From 32313cfdf09fa40548a0dca79ddae3c2a4eb6e59 Mon Sep 17 00:00:00 2001 From: Todd Short Date: Thu, 24 Sep 2026 15:10:26 -0400 Subject: [PATCH] test: cover cross-namespace migration scenarios Signed-off-by: Todd Short --- .github/workflows/migration-test.yaml | 4 + migration.mk | 8 ++ test/e2e/migration/e2e_test.go | 139 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/.github/workflows/migration-test.yaml b/.github/workflows/migration-test.yaml index 00e6bae..634b8c2 100644 --- a/.github/workflows/migration-test.yaml +++ b/.github/workflows/migration-test.yaml @@ -47,6 +47,8 @@ jobs: run: make migration/e2e-fixture-setup - name: Run fixture migration tests run: make migration/test-e2e-fixture-matrix + - name: Verify cross-namespace fixture migration + run: make migration/test-e2e-cross-namespace - name: Tear down fixture cluster if: always() run: E2E_CLUSTER_NAME=library-olm-fixture-e2e make migration/e2e-teardown @@ -150,6 +152,8 @@ jobs: run: make migration/e2e-setup - name: Run live-operator migration tests run: make migration/test-e2e-live-matrix + - name: Verify acknowledged live source-namespace deletion + run: make migration/test-e2e-live-namespace-delete - name: Tear down live cluster if: always() run: make migration/e2e-teardown diff --git a/migration.mk b/migration.mk index e47c457..776ce76 100644 --- a/migration.mk +++ b/migration.mk @@ -96,10 +96,18 @@ migration/e2e-install-fixture-v0: ## Replay one OLMv0 install snapshot, or all, migration/test-e2e-live-matrix: migration/build ## Install and migrate all three operators from live OLMv0 @set -euo pipefail; while IFS=$$'\t' read -r package channel namespace; do [[ -z "$$package" || "$$package" == \#* ]] && continue; coverage_dir="$(E2E_COVERAGE_DIR)/live/$$package"; artifact_dir="$(E2E_ARTIFACTS)/live/$$package"; mkdir -p "$$coverage_dir"; KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/install-v0.sh "$$package"; KUBECONFIG="$(E2E_KUBECONFIG)" GOCOVERDIR="$$coverage_dir" E2E_ARTIFACTS="$$artifact_dir" E2E_SUITE=real-operator E2E_NAMESPACE="$$namespace" E2E_SUBSCRIPTION="$$package" go test -count=1 -tags=e2e ./test/e2e/migration -timeout "$(E2E_TIMEOUT)"; KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; done < test/e2e/migration/operators.tsv +.PHONY: migration/test-e2e-live-namespace-delete +migration/test-e2e-live-namespace-delete: migration/build ## Verify acknowledged source-namespace deletion with live OLMv0 + @set -euo pipefail; package=ecr-secret-operator; namespace=migration-e2e-ecr-secret; target="$$namespace-target"; coverage_dir="$(E2E_COVERAGE_DIR)/live/cross-namespace-delete"; artifact_dir="$(E2E_ARTIFACTS)/live/cross-namespace-delete"; mkdir -p "$$coverage_dir"; KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; KUBECONFIG="$(E2E_KUBECONFIG)" kubectl delete namespace "$$target" --ignore-not-found --wait=true; KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/install-v0.sh "$$package"; KUBECONFIG="$(E2E_KUBECONFIG)" GOCOVERDIR="$$coverage_dir" E2E_ARTIFACTS="$$artifact_dir" E2E_SUITE=real-operator E2E_LIVE_NAMESPACE_DELETE_TEST=true E2E_NAMESPACE="$$namespace" E2E_SUBSCRIPTION="$$package" go test -count=1 -tags=e2e ./test/e2e/migration -run '^TestLiveCrossNamespaceDeletionMigration$$' -timeout "$(E2E_TIMEOUT)"; KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; KUBECONFIG="$(E2E_KUBECONFIG)" kubectl delete namespace "$$target" --ignore-not-found --wait=true + .PHONY: migration/test-e2e-fixture-matrix migration/test-e2e-fixture-matrix: migration/build ## Replay and migrate all three OLMv0 install snapshots @set -euo pipefail; while IFS=$$'\t' read -r package channel namespace; do [[ -z "$$package" || "$$package" == \#* ]] && continue; coverage_dir="$(E2E_COVERAGE_DIR)/fixture/$$package"; artifact_dir="$(E2E_ARTIFACTS)/fixture/$$package"; mkdir -p "$$coverage_dir"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" ./hack/e2e/migration/install-fixture-v0.sh "$$package"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" GOCOVERDIR="$$coverage_dir" E2E_ARTIFACTS="$$artifact_dir" E2E_SUITE=fixture E2E_NAMESPACE="$$namespace" E2E_SUBSCRIPTION="$$package" go test -count=1 -tags=e2e ./test/e2e/migration -timeout "$(E2E_TIMEOUT)"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; done < test/e2e/migration/operators.tsv +.PHONY: migration/test-e2e-cross-namespace +migration/test-e2e-cross-namespace: migration/build ## Verify fixture migration into a different install namespace + @set -euo pipefail; package=ecr-secret-operator; namespace=migration-e2e-ecr-secret; target="$$namespace-target"; coverage_dir="$(E2E_COVERAGE_DIR)/fixture/cross-namespace"; artifact_dir="$(E2E_ARTIFACTS)/fixture/cross-namespace"; mkdir -p "$$coverage_dir"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" kubectl delete namespace "$$target" --ignore-not-found --wait=true; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" ./hack/e2e/migration/install-fixture-v0.sh "$$package"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" GOCOVERDIR="$$coverage_dir" E2E_ARTIFACTS="$$artifact_dir" E2E_SUITE=fixture E2E_CROSS_NAMESPACE_TEST=true E2E_NAMESPACE="$$namespace" E2E_SUBSCRIPTION="$$package" go test -count=1 -tags=e2e ./test/e2e/migration -run '^TestCrossNamespaceMigration$$' -timeout "$(E2E_TIMEOUT)"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$package"; KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" kubectl delete namespace "$$target" --ignore-not-found --wait=true; : 'The source fixture namespace contains captured OLMv0 finalizers. install-fixture-v0.sh releases them before the next replay; CI tears down the Kind cluster.' + .PHONY: migration/test-e2e-in-cluster-job migration/test-e2e-in-cluster-job: migration/e2e-fixture-setup migration/build-e2e-image ## Run migration CLIs in a fixture-cluster Job (no coverage collection) E2E_OPERATOR=ecr-secret-operator E2E_KUBECONFIG="$(E2E_FIXTURE_KUBECONFIG)" $(MAKE) migration/e2e-delete-v1 diff --git a/test/e2e/migration/e2e_test.go b/test/e2e/migration/e2e_test.go index 0c8c9b7..7f27952 100644 --- a/test/e2e/migration/e2e_test.go +++ b/test/e2e/migration/e2e_test.go @@ -479,6 +479,145 @@ func TestMigration(t *testing.T) { } } +// TestCrossNamespaceMigration proves the explicit install-namespace path using +// a committed OLMv0 fixture. It is a separate invocation because it leaves the +// source namespace in place long enough to verify that only migrated operator +// resources, rather than the whole namespace, were removed. +func TestCrossNamespaceMigration(t *testing.T) { + if os.Getenv("E2E_CROSS_NAMESPACE_TEST") != "true" { + t.Skip("set E2E_CROSS_NAMESPACE_TEST=true to run the cross-namespace migration scenario") + } + if os.Getenv("E2E_SUITE") != "fixture" { + t.Fatal("cross-namespace migration is exercised against the fixture suite") + } + + namespace, subscription := os.Getenv("E2E_NAMESPACE"), os.Getenv("E2E_SUBSCRIPTION") + if namespace == "" || subscription == "" { + t.Fatal("E2E_NAMESPACE and E2E_SUBSCRIPTION are required") + } + targetNamespace := namespace + "-target" + t.Cleanup(func() { + collectArtifacts(t, namespace) + if t.Failed() { + collectArtifacts(t, targetNamespace) + } + }) + + // Use labels which must be copied before the controller renders the target + // bundle. The target namespace is deliberately absent at conversion start. + run(t, "kubectl", "delete", "namespace/"+targetNamespace, "--ignore-not-found", "--wait=true") + // Use audit rather than enforce: the fixture bundle is not restricted-PSA + // compliant, so enforce would correctly prevent its Deployment from being + // created and turn this namespace-label preservation test into an unrelated + // admission test. + run(t, "kubectl", "label", "namespace/"+namespace, + "pod-security.kubernetes.io/audit=restricted", + "security.openshift.io/scc.podSecurityLabelSync=true", "--overwrite") + + sourceDeployments, err := output("kubectl", "get", "deployment", "-n", namespace, "-o", "name") + if err != nil || strings.TrimSpace(sourceDeployments) == "" { + t.Fatalf("list source operator deployments: %v\n%s", err, sourceDeployments) + } + + // The fixture CatalogSource is intentionally present but not reconciled by + // OLMv0; create its ClusterCatalog before conversion to satisfy C7. + run(t, binary(t, "migrate-catalogs-v0-to-v1"), "--kubeconfig", os.Getenv("KUBECONFIG")) + run(t, binary(t, "migrate-operators-v0-to-v1"), "convert", subscription, + "-n", namespace, "--install-namespace", targetNamespace, + "--kubeconfig", os.Getenv("KUBECONFIG")) + + run(t, "kubectl", "wait", "--for=jsonpath={.status.conditions[?(@.type=='Installed')].status}=True", "clusterextension/"+subscription, "--timeout=10m") + gotNamespace, err := output("kubectl", "get", "clusterextension/"+subscription, "-o", "jsonpath={.spec.namespace}") + if err != nil || strings.TrimSpace(gotNamespace) != targetNamespace { + t.Fatalf("ClusterExtension install namespace = %q, err=%v; want %q", gotNamespace, err, targetNamespace) + } + for key, want := range map[string]string{ + "pod-security.kubernetes.io/audit": "restricted", + "security.openshift.io/scc.podSecurityLabelSync": "true", + } { + got, err := output("kubectl", "get", "namespace/"+targetNamespace, "-o", "jsonpath={.metadata.labels."+escapeJSONPathLabel(key)+"}") + if err != nil || strings.TrimSpace(got) != want { + t.Fatalf("target namespace label %s = %q, err=%v; want %q", key, got, err, want) + } + } + + // The catalog-rendered target Deployment proves the new installation is + // present. The source Deployments must be gone: retaining them would leave + // two active operator copies when the source namespace is intentionally kept. + targetDeployments, err := output("kubectl", "get", "deployment", "-n", targetNamespace, "-o", "name") + if err != nil || strings.TrimSpace(targetDeployments) == "" { + t.Fatalf("list target operator deployments: %v\n%s", err, targetDeployments) + } + for _, deployment := range strings.Fields(sourceDeployments) { + if out, err := output("kubectl", "get", deployment, "-n", namespace); err == nil { + t.Fatalf("source operator resource %s remains after cross-namespace migration:\n%s", deployment, out) + } + } + sourceDeletionTimestamp, err := output("kubectl", "get", "namespace/"+namespace, "-o", "jsonpath={.metadata.deletionTimestamp}") + if err != nil || strings.TrimSpace(sourceDeletionTimestamp) != "" { + t.Fatalf("source namespace deletionTimestamp = %q, err=%v; want empty", sourceDeletionTimestamp, err) + } +} + +// TestLiveCrossNamespaceDeletionMigration verifies the destructive namespace +// path against OLMv0 itself. Unlike fixture tests, OLMv0 is present to release +// the CSV cleanup finalizer, so Kubernetes can complete namespace deletion. +func TestLiveCrossNamespaceDeletionMigration(t *testing.T) { + if os.Getenv("E2E_LIVE_NAMESPACE_DELETE_TEST") != "true" { + t.Skip("set E2E_LIVE_NAMESPACE_DELETE_TEST=true to run live namespace deletion") + } + if os.Getenv("E2E_SUITE") != "real-operator" { + t.Fatal("acknowledged namespace deletion is exercised against live OLMv0") + } + + namespace, subscription := os.Getenv("E2E_NAMESPACE"), os.Getenv("E2E_SUBSCRIPTION") + if namespace == "" || subscription == "" { + t.Fatal("E2E_NAMESPACE and E2E_SUBSCRIPTION are required") + } + targetNamespace := namespace + "-target" + t.Cleanup(func() { + collectArtifacts(t, namespace) + if t.Failed() { + collectArtifacts(t, targetNamespace) + } + }) + + run(t, "kubectl", "delete", "namespace/"+targetNamespace, "--ignore-not-found", "--wait=true") + // Audit mode proves PSA labels are transferred without turning this test + // into an admission-policy test for the catalog bundle. + run(t, "kubectl", "label", "namespace/"+namespace, + "pod-security.kubernetes.io/audit=restricted", + "security.openshift.io/scc.podSecurityLabelSync=true", "--overwrite") + + // Migrate catalogs before converting the Subscription so C7 is satisfied. + run(t, binary(t, "migrate-catalogs-v0-to-v1"), "--kubeconfig", os.Getenv("KUBECONFIG")) + run(t, binary(t, "migrate-operators-v0-to-v1"), "convert", subscription, + "-n", namespace, "--install-namespace", targetNamespace, + "--acknowledge-namespace-delete", "--kubeconfig", os.Getenv("KUBECONFIG")) + + run(t, "kubectl", "wait", "--for=jsonpath={.status.conditions[?(@.type=='Installed')].status}=True", "clusterextension/"+subscription, "--timeout=10m") + for key, want := range map[string]string{ + "pod-security.kubernetes.io/audit": "restricted", + "security.openshift.io/scc.podSecurityLabelSync": "true", + } { + got, err := output("kubectl", "get", "namespace/"+targetNamespace, "-o", "jsonpath={.metadata.labels."+escapeJSONPathLabel(key)+"}") + if err != nil || strings.TrimSpace(got) != want { + t.Fatalf("target namespace label %s = %q, err=%v; want %q", key, got, err, want) + } + } + targetDeployments, err := output("kubectl", "get", "deployment", "-n", targetNamespace, "-o", "name") + if err != nil || strings.TrimSpace(targetDeployments) == "" { + t.Fatalf("list target operator deployments: %v\n%s", err, targetDeployments) + } + if out, err := output("kubectl", "wait", "--for=delete", "namespace/"+namespace, "--timeout=10m"); err != nil { + t.Fatalf("wait for acknowledged source namespace deletion: %v\n%s", err, out) + } +} + +func escapeJSONPathLabel(label string) string { + return strings.ReplaceAll(label, ".", `\.`) +} + // restoreSubscriptionForConflict replays the pre-migration Subscription without // its API-assigned state, creating the Conflict state exercised by cleanup. func restoreSubscriptionForConflict(t *testing.T, raw string) {