diff --git a/.github/workflows/migration-test.yaml b/.github/workflows/migration-test.yaml new file mode 100644 index 0000000..d714ef3 --- /dev/null +++ b/.github/workflows/migration-test.yaml @@ -0,0 +1,83 @@ +name: migration-test + +on: + workflow_dispatch: + pull_request: + merge_group: + push: + branches: + - main + +permissions: + contents: read + +jobs: + unit-coverage: + name: migration unit coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Run unit tests with coverage + run: make migration/test-coverage + - name: Upload unit coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: migration-unit-coverage + path: artifacts/coverage + if-no-files-found: ignore + + coverage: + name: migration total coverage + needs: [unit-coverage, live] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Download unit coverage + uses: actions/download-artifact@v8 + with: + name: migration-unit-coverage + path: artifacts/coverage + - name: Download live E2E coverage + uses: actions/download-artifact@v8 + with: + name: migration-live-e2e-artifacts + path: artifacts/e2e + - name: Display combined coverage + run: make migration/report-coverage-all + + live: + name: migration live-operator E2E + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Set up live cluster + run: make migration/e2e-setup + - name: Run live-operator migration tests + run: make migration/test-e2e-live-matrix + - name: Tear down live cluster + if: always() + run: make migration/e2e-teardown + - name: Upload live diagnostics and coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: migration-live-e2e-artifacts + path: artifacts/e2e + if-no-files-found: ignore diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml deleted file mode 100644 index 7f67d86..0000000 --- a/.github/workflows/unit-test.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: unit-test - -on: - workflow_dispatch: - pull_request: - merge_group: - push: - branches: - - main - -jobs: - unit-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - name: Run unit tests - run: make migration/test-unit diff --git a/.gitignore b/.gitignore index b1061d4..51ec81c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,9 @@ go.work.sum # Generated test coverage profiles and reports. artifacts/ + +# Kind-generated kubeconfigs contain cluster credentials. +.kubeconfig/ + +# Locally built E2E CLIs. +bin/ diff --git a/go.mod b/go.mod index 807134c..9aa7957 100644 --- a/go.mod +++ b/go.mod @@ -38,9 +38,11 @@ require ( github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kr/text v0.2.0 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -60,6 +62,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af // indirect + k8s.io/streaming v0.36.4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect diff --git a/go.sum b/go.sum index 60e58d8..2f0b9ea 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -73,6 +75,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -81,6 +85,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -192,6 +198,8 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA= k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/streaming v0.36.4 h1:RS5YlhrdBN2pKGVjgygGntdu6SNdsduyjGWGe3cX0vo= +k8s.io/streaming v0.36.4/go.mod h1:tJ6S2bZa2HxIBauguBbCWSCYyd93Grfz1+z3tcOvlDE= k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= diff --git a/hack/e2e/migration/delete-v1.sh b/hack/e2e/migration/delete-v1.sh new file mode 100755 index 0000000..d491bbf --- /dev/null +++ b/hack/e2e/migration/delete-v1.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/operators.sh" + +operator_fields "${1:?usage: delete-v1.sh }" +# Delete CE first, then any revisions it owned. This is the explicit OLMv1 +# cleanup path; it never deletes shared CRDs or the source CatalogSource. +kubectl delete "clusterextension/$E2E_PACKAGE" --ignore-not-found --wait=true +kubectl delete clusterobjectsets -l "olm.operatorframework.io/owner-name=$E2E_PACKAGE" --ignore-not-found --wait=true +# Ref Secrets live in the OLMv1 system namespace rather than the operator's +# installation namespace and are not garbage-collected by ClusterObjectSet. +# Remove them as part of test cleanup so a repeat migration can reuse its +# deterministic revision and Secret names. +kubectl -n "${E2E_OLMV1_NAMESPACE:-olmv1-system}" delete secret \ + -l "olm.operatorframework.io/owner-name=$E2E_PACKAGE" \ + --ignore-not-found --wait=true diff --git a/hack/e2e/migration/install-v0.sh b/hack/e2e/migration/install-v0.sh new file mode 100755 index 0000000..5f5af0e --- /dev/null +++ b/hack/e2e/migration/install-v0.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/operators.sh" + +operator_fields "${1:?usage: install-v0.sh }" +kubectl delete namespace "$E2E_NAMESPACE" --ignore-not-found --wait=true +kubectl create namespace "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply --validate=false -f - + +# Some bundles require user-supplied configuration before their controller pod +# can be created. These source-controlled inputs are also applied by fixture +# replay, keeping both suites faithful to the same installed operator. +preinstall_fixture="$root_dir/test/e2e/migration/fixtures/preinstall/$E2E_PACKAGE.yaml" +if [[ -f $preinstall_fixture ]]; then + kubectl -n "$E2E_NAMESPACE" apply --validate=false -f "$preinstall_fixture" +fi + +kubectl -n "$E2E_NAMESPACE" apply --validate=false -f - <&2; exit 2; } + IFS=$'\t' read -r E2E_PACKAGE E2E_CHANNEL E2E_NAMESPACE <<<"$row" + export E2E_PACKAGE E2E_CHANNEL E2E_NAMESPACE +} diff --git a/hack/e2e/migration/setup.sh b/hack/e2e/migration/setup.sh new file mode 100755 index 0000000..4649333 --- /dev/null +++ b/hack/e2e/migration/setup.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates a disposable cluster for the migration E2E suites. Manifest locations are +# required inputs: CI supplies immutable, mirrored copies when it cannot access +# the public release URLs. +: "${E2E_KUBECONFIG:?E2E_KUBECONFIG is required}" +: "${OLM_V0_CRDS:?OLM_V0_CRDS is required}" +: "${OLM_V0_MANIFEST:?OLM_V0_MANIFEST is required}" +: "${OLM_V1_INSTALL:?OLM_V1_INSTALL is required}" +: "${OLM_V1_INSTALL_SHA256:?OLM_V1_INSTALL_SHA256 is required}" +: "${KIND:?KIND is required}" + +root_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +cluster_name=${E2E_CLUSTER_NAME:-library-olm-e2e} +kind_config=${E2E_KIND_CONFIG:-"$root_dir/test/e2e/migration/kind-config.yaml"} + +mkdir -p "$(dirname "$E2E_KUBECONFIG")" +if "$KIND" get clusters | grep -Fxq "$cluster_name"; then + # Keep an existing dedicated cluster for iterative fixture development, but + # always regenerate the kubeconfig from Kind rather than accepting an old or + # user-supplied file. + "$KIND" export kubeconfig --name "$cluster_name" --kubeconfig "$E2E_KUBECONFIG" +else + "$KIND" create cluster --name "$cluster_name" --config "$kind_config" --kubeconfig "$E2E_KUBECONFIG" +fi +kubectl --kubeconfig "$E2E_KUBECONFIG" wait --for=condition=Ready nodes --all --timeout=3m + +# The v0.46 CRD bundle exceeds Kubernetes' client-side last-applied annotation +# limit for the CSV CRD. Server-side apply stores managed fields instead. +kubectl --kubeconfig "$E2E_KUBECONFIG" apply --server-side -f "$OLM_V0_CRDS" +if [[ ${E2E_INSTALL_OLMV0:-true} == true ]]; then + kubectl --kubeconfig "$E2E_KUBECONFIG" apply -f "$OLM_V0_MANIFEST" + kubectl --kubeconfig "$E2E_KUBECONFIG" -n olm wait --for=condition=Available deployment/olm-operator --timeout=5m + kubectl --kubeconfig "$E2E_KUBECONFIG" -n olm wait --for=condition=Available deployment/catalog-operator --timeout=5m +fi + +# ClusterObjectSet is an experimental OLMv1 API and is required by the migration +# code, so use operator-controller's experimental installer rather than standard. +# Download a pinned release installer before executing it, restricting redirects +# to HTTPS and verifying its recorded SHA-256. +if ! kubectl --kubeconfig "$E2E_KUBECONFIG" get crd/clusterextensions.olm.operatorframework.io >/dev/null 2>&1; then + installer=$(mktemp) + trap 'rm -f "$installer"' EXIT + curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error "$OLM_V1_INSTALL" --output "$installer" + printf '%s %s\n' "$OLM_V1_INSTALL_SHA256" "$installer" | sha256sum --check --status + KUBECONFIG="$E2E_KUBECONFIG" bash "$installer" +fi +kubectl --kubeconfig "$E2E_KUBECONFIG" wait --for=condition=Established crd/clusterextensions.olm.operatorframework.io crd/clustercatalogs.olm.operatorframework.io --timeout=5m +kubectl --kubeconfig "$E2E_KUBECONFIG" -n olmv1-system wait --for=condition=Available deployment/operator-controller-controller-manager deployment/catalogd-controller-manager --timeout=5m diff --git a/hack/e2e/migration/teardown.sh b/hack/e2e/migration/teardown.sh new file mode 100755 index 0000000..9b663f1 --- /dev/null +++ b/hack/e2e/migration/teardown.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${KIND:?KIND is required}" +"$KIND" delete cluster --name "${E2E_CLUSTER_NAME:-library-olm-e2e}" diff --git a/migration.mk b/migration.mk index 5eb2da0..b757d1a 100644 --- a/migration.mk +++ b/migration.mk @@ -1,12 +1,48 @@ -# Migration-specific unit-test targets. This file is included by the repository -# Makefile so callers consistently use `make migration/`. +# Migration-specific build, test, and E2E targets. This file is included by +# the repository Makefile so callers consistently use `make migration/`. +# Output paths for compiled migration CLI binaries. +MIGRATE_OPERATORS_BIN := $(BIN_DIR)/migrate-operators-v0-to-v1 +MIGRATE_CATALOGS_BIN := $(BIN_DIR)/migrate-catalogs-v0-to-v1 COVERAGE_DIR := $(ROOT_DIR)/artifacts/coverage UNIT_COVERAGE_PROFILE := $(COVERAGE_DIR)/unit.out UNIT_COVERAGE_REPORT := $(COVERAGE_DIR)/unit.txt +E2E_COVERAGE_PROFILE := $(COVERAGE_DIR)/e2e-cli.out +ALL_COVERAGE_PROFILE := $(COVERAGE_DIR)/all.out + +E2E_KUBECONFIG ?= $(ROOT_DIR)/.kubeconfig/library-olm-e2e +E2E_TIMEOUT ?= 30m +E2E_ARTIFACTS ?= $(ROOT_DIR)/artifacts/e2e +E2E_COVERAGE_DIR ?= $(E2E_ARTIFACTS)/coverage +E2E_CLUSTER_NAME ?= library-olm-e2e +# Pin controller releases used by the E2E cluster. Override only to test a +# compatibility candidate; do not use "latest" in CI. +OLM_V0_VERSION ?= v0.46.0 +OLM_V1_VERSION ?= v1.11.0 +OLM_V0_CRDS ?= https://github.com/operator-framework/operator-lifecycle-manager/releases/download/$(OLM_V0_VERSION)/crds.yaml +OLM_V0_MANIFEST ?= https://github.com/operator-framework/operator-lifecycle-manager/releases/download/$(OLM_V0_VERSION)/olm.yaml +OLM_V1_INSTALL ?= https://github.com/operator-framework/operator-controller/releases/download/$(OLM_V1_VERSION)/install-experimental.sh +OLM_V1_INSTALL_SHA256 ?= 0ce2e6f7ff8244c012fb129b6110c93d1863cd8f2b6bce4a01c94f8bd762b4da +E2E_REAL_OPERATOR_MANIFEST ?= $(ROOT_DIR)/test/e2e/migration/real-operator.yaml +E2E_REAL_OPERATOR_NAMESPACE ?= migration-e2e-real +E2E_REAL_OPERATOR_SUBSCRIPTION ?= ecr-secret-operator +E2E_OPERATOR ?= all ##@ Migration +.PHONY: migration/build +migration/build: migration/build-operators migration/build-catalogs ## Build both migration CLI binaries into bin/ + +.PHONY: migration/build-operators +migration/build-operators: ## Build migrate-operators-v0-to-v1 into bin/ + @mkdir -p $(BIN_DIR) + go build -cover -covermode=count -o $(MIGRATE_OPERATORS_BIN) ./migration/examples/cmd/migrate-operators-v0-to-v1 + +.PHONY: migration/build-catalogs +migration/build-catalogs: ## Build migrate-catalogs-v0-to-v1 into bin/ + @mkdir -p $(BIN_DIR) + go build -cover -covermode=count -o $(MIGRATE_CATALOGS_BIN) ./migration/examples/cmd/migrate-catalogs-v0-to-v1 + .PHONY: migration/test-unit migration/test-unit: ## Run migration unit tests go test ./migration/... -count=1 @@ -20,3 +56,42 @@ migration/test-coverage: ## Run migration unit tests and display coverage @mkdir -p $(COVERAGE_DIR) go test ./migration/... -count=1 -covermode=count -coverprofile=$(UNIT_COVERAGE_PROFILE) go tool cover -func=$(UNIT_COVERAGE_PROFILE) | tee $(UNIT_COVERAGE_REPORT) + +.PHONY: migration/e2e-setup +migration/e2e-setup: $(KIND) ## Create kind and install pinned OLMv0 and OLMv1 releases + E2E_KUBECONFIG="$(E2E_KUBECONFIG)" E2E_CLUSTER_NAME="$(E2E_CLUSTER_NAME)" KIND="$(KIND)" OLM_V0_CRDS="$(OLM_V0_CRDS)" OLM_V0_MANIFEST="$(OLM_V0_MANIFEST)" OLM_V1_INSTALL="$(OLM_V1_INSTALL)" OLM_V1_INSTALL_SHA256="$(OLM_V1_INSTALL_SHA256)" ./hack/e2e/migration/setup.sh + +.PHONY: migration/e2e-teardown +migration/e2e-teardown: $(KIND) ## Delete the dedicated migration kind cluster + E2E_CLUSTER_NAME="$(E2E_CLUSTER_NAME)" KIND="$(KIND)" ./hack/e2e/migration/teardown.sh + +.PHONY: migration/e2e-install-v0 +migration/e2e-install-v0: ## Install one migration E2E operator (E2E_OPERATOR=name) or all via OLMv0 + @if [[ "$(E2E_OPERATOR)" != all ]]; then E2E_OPERATOR="$(E2E_OPERATOR)" KUBECONFIG="$(E2E_KUBECONFIG)" bash -c 'source "$$1"; operator_fields "$$E2E_OPERATOR"' -- ./hack/e2e/migration/operators.sh; fi + @for operator in $$(awk -F '\t' -v wanted="$(E2E_OPERATOR)" 'NF==3 && $$1 !~ /^#/ && (wanted=="all" || $$1==wanted) {print $$1}' test/e2e/migration/operators.tsv); do KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/install-v0.sh "$$operator"; done + +.PHONY: migration/test-e2e-live-matrix +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/e2e-delete-v1 +migration/e2e-delete-v1: ## Delete one migration E2E operator as OLMv1, or all + @if [[ "$(E2E_OPERATOR)" != all ]]; then E2E_OPERATOR="$(E2E_OPERATOR)" KUBECONFIG="$(E2E_KUBECONFIG)" bash -c 'source "$$1"; operator_fields "$$E2E_OPERATOR"' -- ./hack/e2e/migration/operators.sh; fi + @for operator in $$(awk -F '\t' -v wanted="$(E2E_OPERATOR)" 'NF==3 && $$1 !~ /^#/ && (wanted=="all" || $$1==wanted) {print $$1}' test/e2e/migration/operators.tsv); do KUBECONFIG="$(E2E_KUBECONFIG)" ./hack/e2e/migration/delete-v1.sh "$$operator"; done + +.PHONY: migration/test-e2e-real-operator +migration/test-e2e-real-operator: migration/build ## Run real-operator migration smoke tests against the live kubeconfig + @mkdir -p "$(E2E_ARTIFACTS)/real-operator" + @mkdir -p "$(E2E_COVERAGE_DIR)/real-operator" + KUBECONFIG="$(E2E_KUBECONFIG)" GOCOVERDIR="$(E2E_COVERAGE_DIR)/real-operator" E2E_SUITE=real-operator E2E_MANIFEST="$(E2E_REAL_OPERATOR_MANIFEST)" E2E_NAMESPACE="$(E2E_REAL_OPERATOR_NAMESPACE)" E2E_SUBSCRIPTION="$(E2E_REAL_OPERATOR_SUBSCRIPTION)" E2E_ARTIFACTS="$(E2E_ARTIFACTS)/real-operator" go test -count=1 -tags=e2e ./test/e2e/migration -timeout "$(E2E_TIMEOUT)" + +.PHONY: migration/report-coverage-all +migration/report-coverage-all: ## Display coverage from existing unit and collected E2E CLI profiles + @coverage_dirs="$$(find "$(E2E_COVERAGE_DIR)" -type f -name 'covmeta.*' -printf '%h\n' 2>/dev/null | sort -u | paste -sd, -)"; test -n "$$coverage_dirs" || { echo "no E2E CLI coverage found; run both E2E matrices before migration/test-coverage-all" >&2; exit 2; }; go tool covdata textfmt -i="$$coverage_dirs" -o="$(E2E_COVERAGE_PROFILE)"; awk 'FNR == 1 { next } { key = $$1 " " $$2; if (!(key in count)) order[++n] = key; count[key] += $$3 } END { print "mode: count"; for (i = 1; i <= n; i++) print order[i] " " count[order[i]] }' "$(UNIT_COVERAGE_PROFILE)" "$(E2E_COVERAGE_PROFILE)" > "$(ALL_COVERAGE_PROFILE)"; go tool cover -func="$(ALL_COVERAGE_PROFILE)" + +.PHONY: migration/test-coverage-all +migration/test-coverage-all: migration/test-coverage migration/report-coverage-all ## Run unit tests and display combined unit and collected E2E CLI coverage + +.PHONY: migration/clean +migration/clean: ## Remove compiled migration CLI binaries from bin/ + rm -f $(MIGRATE_OPERATORS_BIN) $(MIGRATE_CATALOGS_BIN) diff --git a/migration/pkg/migration/catalog.go b/migration/pkg/migration/catalog.go index 47a23e8..8e449e7 100644 --- a/migration/pkg/migration/catalog.go +++ b/migration/pkg/migration/catalog.go @@ -7,12 +7,17 @@ import ( "fmt" "io" "net/http" + "net/url" + "path" + "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/client-go/transport" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" "sigs.k8s.io/controller-runtime/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" @@ -49,32 +54,25 @@ func (m *Migrator) QueryCatalogForPackage(ctx context.Context, catalog *ocv1.Clu return nil, fmt.Errorf("catalog %s has no URLs in status", catalog.Name) } - proxyURL := fmt.Sprintf("%s/api/v1/namespaces/olmv1-system/services/https:catalogd-service:443/proxy/catalogs/%s/api/v1/all", - restConfig.Host, catalog.Name) - - transportConfig, err := restConfig.TransportConfig() + endpoint, stop, inClusterConfig, err := catalogEndpoint(ctx, catalog, restConfig) if err != nil { - return nil, fmt.Errorf("failed to get transport config: %w", err) + return nil, err } - - rt, err := transport.New(transportConfig) + defer stop() + transport, err := catalogHTTPTransport(inClusterConfig) if err != nil { - return nil, fmt.Errorf("failed to create transport: %w", err) + return nil, err } - - httpClient := &http.Client{Transport: rt} - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - - resp, err := httpClient.Do(req) + req.Header.Set("Accept", "application/json") + resp, err := (&http.Client{Transport: transport}).Do(req) if err != nil { return nil, fmt.Errorf("failed to query catalog: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("catalog returned status %d", resp.StatusCode) } @@ -89,6 +87,147 @@ const ( fbcSchemaChannel = "olm.channel" ) +// catalogHTTPTransport creates an unauthenticated transport for catalogd. +// Kubernetes credentials are used only for the API calls that establish access. +func catalogHTTPTransport(catalogConfig *rest.Config) (http.RoundTripper, error) { + if catalogConfig == nil { + return nil, fmt.Errorf("catalog transport requires a REST config") + } + transport, err := rest.TransportFor(rest.AnonymousClientConfig(catalogConfig)) + if err != nil { + return nil, fmt.Errorf("create catalog transport: %w", err) + } + return transport, nil +} + +// catalogEndpoint returns an authenticated API-server path for port forwarding +// outside a cluster or the catalog URL and its dedicated TLS config in a pod. +func catalogEndpoint(ctx context.Context, catalog *ocv1.ClusterCatalog, config *rest.Config) (string, func(), *rest.Config, error) { + inCluster := config == nil + if config == nil { + var err error + config, err = rest.InClusterConfig() + if err != nil { + return "", nil, nil, fmt.Errorf("load in-cluster REST config: %w", err) + } + } + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return "", nil, nil, fmt.Errorf("create Kubernetes client for catalog port-forward: %w", err) + } + catalogConfig, err := catalogdTLSConfig(ctx, clientset, config) + if err != nil { + return "", nil, nil, err + } + if inCluster { + return catalog.Status.URLs.Base + "/api/v1/all", func() {}, catalogConfig, nil + } + podName, err := catalogdLeader(ctx, clientset) + if err != nil { + return "", nil, nil, err + } + u, err := url.Parse(config.Host) + if err != nil { + return "", nil, nil, err + } + u.Path = path.Join(u.Path, "api", "v1", "namespaces", "olmv1-system", "pods", podName, "portforward") + rt, upgrader, err := spdy.RoundTripperFor(config) + if err != nil { + return "", nil, nil, fmt.Errorf("create catalogd port-forward: %w", err) + } + stop, ready := make(chan struct{}), make(chan struct{}) + fw, err := portforward.NewOnAddresses(spdy.NewDialer(upgrader, &http.Client{Transport: rt}, http.MethodPost, u), []string{"127.0.0.1"}, []string{"0:8443"}, stop, ready, io.Discard, io.Discard) + if err != nil { + return "", nil, nil, err + } + forwardErr := make(chan error, 1) + go func() { forwardErr <- fw.ForwardPorts() }() + waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + select { + case <-ready: + case err := <-forwardErr: + close(stop) + if err != nil { + return "", nil, nil, fmt.Errorf("start catalogd port-forward: %w", err) + } + return "", nil, nil, fmt.Errorf("catalogd port-forward stopped before becoming ready") + case <-waitCtx.Done(): + close(stop) + return "", nil, nil, fmt.Errorf("wait for catalogd port-forward: %w", waitCtx.Err()) + } + ports, err := fw.GetPorts() + if err != nil { + close(stop) + return "", nil, nil, err + } + catalogConfig.ServerName = "localhost" + return fmt.Sprintf("https://127.0.0.1:%d/catalogs/%s/api/v1/all", ports[0].Local, catalog.Name), func() { close(stop) }, catalogConfig, nil +} + +// catalogdTLSConfig replaces the Kubernetes API CA with catalogd's serving CA. +func catalogdTLSConfig(ctx context.Context, clientset kubernetes.Interface, config *rest.Config) (*rest.Config, error) { + secret, err := clientset.CoreV1().Secrets("cert-manager").Get(ctx, "olmv1-ca", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get catalogd CA: %w", err) + } + ca := secret.Data["ca.crt"] + if len(ca) == 0 { + ca = secret.Data["tls.crt"] + } + if len(ca) == 0 { + return nil, fmt.Errorf("catalogd CA secret has no certificate") + } + catalogConfig := rest.CopyConfig(config) + catalogConfig.CAFile = "" + catalogConfig.CAData = ca + // The copied API-server configuration may accept an insecure server or use + // an outbound proxy. Neither setting is correct for catalogd: its serving + // certificate must be verified with its own CA, and an external proxy cannot + // reach the loopback endpoint used by the port-forward path. + catalogConfig.Insecure = false + catalogConfig.Proxy = nil + return catalogConfig, nil +} + +// catalogdLeader waits for catalogd's leader Lease to reference a current pod. +func catalogdLeader(ctx context.Context, clientset kubernetes.Interface) (string, error) { + var lastErr error + var leader string + err := wait.PollUntilContextTimeout(ctx, time.Second, 30*time.Second, true, func(context.Context) (bool, error) { + pods, err := clientset.CoreV1().Pods("olmv1-system").List(ctx, metav1.ListOptions{LabelSelector: "app.kubernetes.io/name=catalogd"}) + if err != nil { + lastErr = fmt.Errorf("list catalogd pods: %w", err) + return false, nil + } + lease, err := clientset.CoordinationV1().Leases("olmv1-system").Get(ctx, "catalogd-operator-lock", metav1.GetOptions{}) + if err != nil { + lastErr = fmt.Errorf("get catalogd leader lease: %w", err) + return false, nil + } + if lease.Spec.HolderIdentity == nil || *lease.Spec.HolderIdentity == "" { + lastErr = fmt.Errorf("catalogd leader lease has no holder identity") + return false, nil + } + candidate := strings.SplitN(*lease.Spec.HolderIdentity, "_", 2)[0] + for _, pod := range pods.Items { + if pod.Name == candidate { + leader = candidate + return true, nil + } + } + lastErr = fmt.Errorf("catalogd leader pod %q was not found", candidate) + return false, nil + }) + if err != nil { + if lastErr != nil { + return "", fmt.Errorf("resolve catalogd leader: %w", lastErr) + } + return "", fmt.Errorf("resolve catalogd leader: %w", err) + } + return leader, nil +} + func parseCatalogResponse(body io.Reader, packageName, version, channel string) (*CatalogPackageInfo, error) { info := &CatalogPackageInfo{} versionSet := map[string]bool{} diff --git a/migration/pkg/migration/unit_test.go b/migration/pkg/migration/unit_test.go index f66841a..f1a90e3 100644 --- a/migration/pkg/migration/unit_test.go +++ b/migration/pkg/migration/unit_test.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "net/url" "os" "path/filepath" "strings" @@ -17,6 +19,8 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + k8sfake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -115,6 +119,28 @@ not-json } } +func TestCatalogdTLSConfigUsesCatalogCAWithoutAPIServerTransportSettings(t *testing.T) { + ca := []byte("catalogd-ca") + config := &rest.Config{ + TLSClientConfig: rest.TLSClientConfig{CAData: []byte("api-server-ca"), Insecure: true}, + Proxy: func(*http.Request) (*url.URL, error) { + return &url.URL{Scheme: "https", Host: "proxy.example"}, nil + }, + } + clientset := k8sfake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "olmv1-ca", Namespace: "cert-manager"}, + Data: map[string][]byte{"ca.crt": ca}, + }) + + got, err := catalogdTLSConfig(context.Background(), clientset, config) + if err != nil { + t.Fatalf("catalogdTLSConfig() error = %v", err) + } + if got == config || got.Insecure || got.Proxy != nil || got.CAFile != "" || !bytes.Equal(got.CAData, ca) { + t.Fatalf("catalog transport config = %#v, want copied config with catalog CA, TLS verification, and no proxy", got) + } +} + func TestCompatibilityPureChecks(t *testing.T) { for _, properties := range []string{ `[{"type":"olm.package.required","value":{"packageName":"dep"}}]`, diff --git a/test/e2e/migration/e2e_test.go b/test/e2e/migration/e2e_test.go new file mode 100644 index 0000000..a2f8d2d --- /dev/null +++ b/test/e2e/migration/e2e_test.go @@ -0,0 +1,273 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "k8s.io/client-go/discovery" + "k8s.io/client-go/tools/clientcmd" +) + +// TestEnvironment is deliberately small: it makes both E2E make targets verify +// their supplied cluster before scenario fixtures are introduced. Scenario tests +// select their fixture set through E2E_SUITE and use E2E_ARTIFACTS for diagnostics. +func TestEnvironment(t *testing.T) { + suite := os.Getenv("E2E_SUITE") + if suite != "real-operator" { + t.Fatalf("E2E_SUITE must be real-operator, got %q", suite) + } + + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + t.Fatal("KUBECONFIG is required for E2E tests") + } + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + t.Fatalf("load kubeconfig: %v", err) + } + discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + t.Fatalf("create discovery client: %v", err) + } + serverVersion, err := discoveryClient.ServerVersion() + if err != nil { + t.Fatalf("connect to E2E cluster: %v", err) + } + for _, groupVersion := range []string{"operators.coreos.com/v1alpha1", "olm.operatorframework.io/v1"} { + if _, err := discoveryClient.ServerResourcesForGroupVersion(groupVersion); err != nil { + t.Fatalf("required API %s is unavailable: %v", groupVersion, err) + } + } + t.Logf("running %s suite against Kubernetes %s", suite, serverVersion.GitVersion) +} + +// TestCatalogSourceMigration creates a real OLMv0 image CatalogSource, migrates +// it with the catalog CLI, and verifies the OLMv1 ClusterCatalog is serving. +// The bootstrap catalog's resolved digest is deliberately distinct from the +// bootstrap ClusterCatalog's tag reference, so the test exercises creation. +func TestCatalogSourceMigration(t *testing.T) { + if os.Getenv("E2E_SUITE") != "real-operator" { + t.Skip("CatalogSource controller is intentionally absent from fixture suites") + } + subscription := os.Getenv("E2E_SUBSCRIPTION") + if subscription == "" { + t.Fatal("E2E_SUBSCRIPTION is required") + } + image, err := output("kubectl", "get", "clustercatalog/operatorhubio", "-o", "jsonpath={.status.resolvedSource.image.ref}") + if err != nil || strings.TrimSpace(image) == "" { + t.Fatalf("get bootstrap catalog image: %v (%s)", err, image) + } + name := "migration-catalog-" + subscription + namespace := "migration-e2e-catalog-" + subscription + t.Cleanup(func() { + _, _ = output("kubectl", "delete", "catalogsource/"+name, "-n", namespace, "--ignore-not-found", "--wait=true") + _, _ = output("kubectl", "delete", "clustercatalog/"+name, "--ignore-not-found", "--wait=true") + _, _ = output("kubectl", "delete", "namespace/"+namespace, "--ignore-not-found", "--wait=true") + }) + + catalogSource := map[string]interface{}{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "CatalogSource", + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + }, + "spec": map[string]interface{}{ + "sourceType": "grpc", + "image": strings.TrimSpace(image), + }, + } + data, err := json.Marshal(catalogSource) + if err != nil { + t.Fatalf("encode CatalogSource: %v", err) + } + path := filepath.Join(t.TempDir(), "catalogsource.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write CatalogSource: %v", err) + } + // A previous interrupted test process cannot run t.Cleanup, so clear this + // deterministic test identity before recreating it. + run(t, "kubectl", "delete", "clustercatalog/"+name, "--ignore-not-found", "--wait=true") + run(t, "kubectl", "delete", "namespace/"+namespace, "--ignore-not-found", "--wait=true") + run(t, "kubectl", "create", "namespace", namespace) + run(t, "kubectl", "apply", "-f", path) + existingRefs, err := output("kubectl", "get", "clustercatalogs", "-o", "jsonpath={range .items[*]}{.spec.source.image.ref}{\"\\n\"}{end}") + if err != nil { + t.Fatalf("list existing ClusterCatalog image references: %v\\n%s", err, existingRefs) + } + if strings.Contains("\n"+existingRefs+"\n", "\n"+strings.TrimSpace(image)+"\n") { + t.Fatalf("catalog image %q already belongs to a ClusterCatalog; test would exercise adoption instead of creation", strings.TrimSpace(image)) + } + run(t, binary(t, "migrate-catalogs-v0-to-v1"), "--kubeconfig", os.Getenv("KUBECONFIG")) + run(t, "kubectl", "wait", "--for=jsonpath={.status.conditions[?(@.type=='Serving')].status}=True", "clustercatalog/"+name, "--timeout=10m") + + ref, err := output("kubectl", "get", "clustercatalog/"+name, "-o", "jsonpath={.spec.source.image.ref}") + if err != nil || strings.TrimSpace(ref) != strings.TrimSpace(image) { + t.Fatalf("migrated catalog image = %q, want %q (err=%v)", ref, image, err) + } + annotation, err := output("kubectl", "get", "clustercatalog/"+name, "-o", "jsonpath={.metadata.annotations.olm\\.operatorframework\\.io/migrated-from-catalogsource}") + if err != nil || strings.TrimSpace(annotation) != namespace+"/"+name { + t.Fatalf("migrated-from annotation = %q, want %q (err=%v)", annotation, namespace+"/"+name, err) + } + run(t, "kubectl", "get", "catalogsource/"+name, "-n", namespace) +} + +// TestMigration applies the suite's complete fixture, exercises the two migration +// binaries, and observes the API server rather than mocking either OLM controller. +// E2E_MANIFEST must create the namespace, a CatalogSource, and the named Subscription. +func TestMigration(t *testing.T) { + manifest, namespace, subscription := os.Getenv("E2E_MANIFEST"), os.Getenv("E2E_NAMESPACE"), os.Getenv("E2E_SUBSCRIPTION") + if namespace == "" || subscription == "" { + t.Fatal("E2E_NAMESPACE and E2E_SUBSCRIPTION are required") + } + t.Cleanup(func() { collectArtifacts(t, namespace) }) + if manifest != "" { + run(t, "kubectl", "apply", "-f", manifest) + } + run(t, "kubectl", "wait", "--for=jsonpath={.status.state}=AtLatestKnown", "subscription/"+subscription, "-n", namespace, "--timeout=10m") + csvName, err := output("kubectl", "get", "subscription/"+subscription, "-n", namespace, "-o", "jsonpath={.status.installedCSV}") + if err != nil || strings.TrimSpace(csvName) == "" { + t.Fatalf("get installed CSV for Subscription %s: %v (%s)", subscription, err, csvName) + } + run(t, "kubectl", "wait", "--for=jsonpath={.status.phase}=Succeeded", "csv/"+strings.TrimSpace(csvName), "-n", namespace, "--timeout=10m") + subscriptionJSON, err := output("kubectl", "get", "subscription/"+subscription, "-n", namespace, "-o", "json") + if err != nil { + t.Fatalf("capture source Subscription for conflict cleanup: %v\n%s", err, subscriptionJSON) + } + + // Catalog migration is deliberately run before the operator check: C7 is a + // hard prerequisite and this verifies the prescribed command sequence. + run(t, binary(t, "migrate-catalogs-v0-to-v1"), "--kubeconfig", os.Getenv("KUBECONFIG")) + allChecks, err := output(binary(t, "migrate-operators-v0-to-v1"), "check", "--all", "--kubeconfig", os.Getenv("KUBECONFIG")) + if err != nil { + t.Fatalf("check --all failed: %v\n%s", err, allChecks) + } + if !strings.Contains(allChecks, namespace+"/"+subscription) { + t.Fatalf("check --all did not report %s/%s:\n%s", namespace, subscription, allChecks) + } + run(t, binary(t, "migrate-operators-v0-to-v1"), "check", subscription, "-n", namespace, "--kubeconfig", os.Getenv("KUBECONFIG")) + run(t, binary(t, "migrate-operators-v0-to-v1"), "convert", subscription, "-n", namespace, "--dry-run", "--kubeconfig", os.Getenv("KUBECONFIG")) + if _, err := output("kubectl", "get", "clusterextension", subscription); err == nil { + t.Fatal("convert --dry-run created a ClusterExtension") + } + run(t, binary(t, "migrate-operators-v0-to-v1"), "convert", subscription, "-n", namespace, "--kubeconfig", os.Getenv("KUBECONFIG")) + + // The ClusterExtension name defaults to the Subscription name. Installed=True + // proves the live operator-controller accepted the generated COS and rendered it. + run(t, "kubectl", "wait", "--for=jsonpath={.status.conditions[?(@.type=='Installed')].status}=True", "clusterextension/"+subscription, "--timeout=10m") + cos, err := output("kubectl", "get", "clusterobjectsets", "-l", "olm.operatorframework.io/owner-name="+subscription, "-o", "name") + if err != nil || strings.TrimSpace(cos) == "" { + t.Fatalf("find ClusterObjectSet owned by %s: %v (%s)", subscription, err, cos) + } + if _, err := output("kubectl", "get", "subscription", subscription, "-n", namespace); err == nil { + t.Fatal("Subscription still exists after successful conversion") + } + restoreSubscriptionForConflict(t, subscriptionJSON) + run(t, binary(t, "migrate-operators-v0-to-v1"), "cleanup", subscription, "--kubeconfig", os.Getenv("KUBECONFIG")) + run(t, "kubectl", "get", "clusterextension", subscription) + if _, err := output("kubectl", "get", "subscription", subscription, "-n", namespace); err == nil { + t.Fatal("cleanup left the conflict Subscription in place") + } + if _, err := output(binary(t, "migrate-operators-v0-to-v1"), "rollback", subscription, "--kubeconfig", os.Getenv("KUBECONFIG")); err == nil { + t.Fatal("rollback of an installed ClusterExtension succeeded without acknowledgment") + } + run(t, binary(t, "migrate-operators-v0-to-v1"), "rollback", subscription, "--acknowledge-installed", "--kubeconfig", os.Getenv("KUBECONFIG")) + run(t, "kubectl", "get", "subscription", subscription, "-n", namespace) +} + +// 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) { + t.Helper() + var subscription map[string]interface{} + if err := json.Unmarshal([]byte(raw), &subscription); err != nil { + t.Fatalf("decode captured Subscription: %v", err) + } + delete(subscription, "status") + metadata, ok := subscription["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("captured Subscription has no metadata") + } + for _, field := range []string{"creationTimestamp", "generation", "managedFields", "resourceVersion", "uid"} { + delete(metadata, field) + } + data, err := json.Marshal(subscription) + if err != nil { + t.Fatalf("encode conflict Subscription: %v", err) + } + path := filepath.Join(t.TempDir(), "subscription.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write conflict Subscription: %v", err) + } + run(t, "kubectl", "apply", "-f", path) +} + +// binary returns a verified path to a migration CLI built by the Make target. +func binary(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("..", "..", "..", "bin", name) + if _, err := os.Stat(path); err != nil { + t.Fatalf("migration binary %s: %v", path, err) + } + return path +} + +// run fails the current test with the command's combined output on error. +func run(t *testing.T, command string, args ...string) { + t.Helper() + if out, err := output(command, args...); err != nil { + t.Fatalf("%s %s failed: %v\n%s", command, strings.Join(args, " "), err, out) + } +} + +// output runs a command and returns its combined standard output and error. +func output(command string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + cmd.Env = os.Environ() + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + err := cmd.Run() + return out.String(), err +} + +// collectArtifacts saves cluster diagnostics when the current test has failed. +func collectArtifacts(t *testing.T, namespace string) { + t.Helper() + if !t.Failed() { + return + } + dir := os.Getenv("E2E_ARTIFACTS") + if dir == "" { + return + } + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Logf("create artifacts dir: %v", err) + return + } + for _, resource := range [][]string{ + {"get", "all", "-n", namespace, "-o", "yaml"}, + {"get", "events", "-n", namespace, "-o", "yaml"}, + {"get", "clusterextensions,clusterobjectsets,clustercatalogs", "-o", "yaml"}, + {"get", "events", "-n", "olmv1-system", "-o", "yaml"}, + {"logs", "deployment/catalogd-controller-manager", "-n", "olmv1-system", "--all-containers", "--tail=-1"}, + {"logs", "deployment/operator-controller-controller-manager", "-n", "olmv1-system", "--all-containers", "--tail=-1"}, + } { + out, _ := output("kubectl", resource...) + name := strings.NewReplacer(",", "-", "/", "-").Replace(strings.Join(resource[:2], "-")) + for i, arg := range resource[:len(resource)-1] { + if arg == "-n" { + name += "-" + resource[i+1] + break + } + } + name += ".yaml" + _ = os.WriteFile(filepath.Join(dir, name), []byte(out), 0o600) + } +} diff --git a/test/e2e/migration/kind-config.yaml b/test/e2e/migration/kind-config.yaml new file mode 100644 index 0000000..52aebd2 --- /dev/null +++ b/test/e2e/migration/kind-config.yaml @@ -0,0 +1,9 @@ +# Dedicated cluster configuration for the migration integration suite. The API server +# port is dynamically assigned so multiple developer runs do not collide. +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5 + - role: worker + image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5 diff --git a/test/e2e/migration/operators.tsv b/test/e2e/migration/operators.tsv new file mode 100644 index 0000000..04a0b86 --- /dev/null +++ b/test/e2e/migration/operators.tsv @@ -0,0 +1,4 @@ +# package channel namespace +ecr-secret-operator alpha migration-e2e-ecr-secret +redis-operator stable migration-e2e-redis +external-secrets-operator alpha migration-e2e-external-secrets diff --git a/test/e2e/migration/real-operator.yaml b/test/e2e/migration/real-operator.yaml new file mode 100644 index 0000000..0489481 --- /dev/null +++ b/test/e2e/migration/real-operator.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: migration-e2e-real +--- +apiVersion: operators.coreos.com/v1 +kind: OperatorGroup +metadata: + name: migration-e2e-real + namespace: migration-e2e-real +spec: {} +--- +# This package is available from the OperatorHub CatalogSource installed by +# OLMv0 v0.46.0 and advertises AllNamespaces support. +apiVersion: operators.coreos.com/v1alpha1 +kind: Subscription +metadata: + name: ecr-secret-operator + namespace: migration-e2e-real +spec: + channel: alpha + name: ecr-secret-operator + source: operatorhubio-catalog + sourceNamespace: olm