From 78c03aed450ab28f5ce06e9b70c5b3deb25769a4 Mon Sep 17 00:00:00 2001 From: Todd Short Date: Wed, 26 Aug 2026 14:55:10 -0400 Subject: [PATCH] =?UTF-8?q?Add=20migration/pkg/migration=20core=20library?= =?UTF-8?q?=20(Phases=201=E2=80=935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the OLMv0→OLMv1 migration library as specified in specs/20260821-migration-v0-to-v1/requirements.md, porting and adapting the perdasilva/operator-controller prototype onto current OLMv1 APIs. Package layout: migration/pkg/migration/ labels.go — annotation and label constants (R2.5) types.go — OperatorStatus (4-state), Options, MigrationInfo, Backup.SaveToDisk (R1.3, R2.6) checks.go — CheckResult, PreMigrationReport readiness.go — C8 (Subscription + CSV state), C9 (olm.generated-by) compatibility.go — C1–C6 checks; C1/C4/C5/C6/C8 soft with acknowledge flags; C2/C9 hard blocks; C3 removed (OPRUN-4723) scan.go — ScanAll, Check, Gather, Rollback, Cleanup (R1.1); 4-state classification; C7 catalog check; dependent- operator warning (R9) catalog.go — ResolveClusterCatalog; defaultChannel from FBC (R4); PackageNotFoundError collector.go — 5-source resource collection (R5): Operator CR refs, CRD labels, olm.owner label, ownerRefs, InstallPlan steps; dedup by Group/Kind/namespace/name phase.go — PhaseSort: groups objects into ordered phases secretpacker.go — Secret-backed COS objects (R2.4): gzip, 900 KiB batching, content-addressed keys migration.go — Migrate (full flow), Rollback, CleanupOLMv0Resources Key design points: - ClusterObjectSet (not ClusterExtensionRevision) throughout (R2.2) - CollisionProtection: IfNoController on all COS objects (R2.4) - CE spec.serviceAccount never set — deprecated in OLMv1 (R2.5/R7) - All migration annotations on COS and CE (R2.5) - Default channel resolved from ClusterCatalog FBC when Subscription has no spec.channel (R4) - spec.config mapped to CE.spec.config.inline.deploymentConfig (R4/R7) - kubeconfig via standard clientcmd chain — HCP-compatible (R10) Also adds/replaces: go.mod / go.sum — real module dependencies Makefile — real targets wired to bingo-managed golangci-lint and go-apidiff .bingo/ — bingo tool pins (golangci-lint v2.8.0, go-apidiff v0.8.3) .golangci.yaml — lint config Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Todd Short --- Makefile | 74 +- go.mod | 59 ++ go.sum | 203 ++++++ migration/pkg/migration/catalog.go | 288 ++++++++ migration/pkg/migration/checks.go | 34 + migration/pkg/migration/collector.go | 459 +++++++++++++ migration/pkg/migration/compatibility.go | 347 ++++++++++ migration/pkg/migration/labels.go | 46 ++ migration/pkg/migration/migration.go | 836 +++++++++++++++++++++++ migration/pkg/migration/phase.go | 195 ++++++ migration/pkg/migration/readiness.go | 156 +++++ migration/pkg/migration/scan.go | 538 +++++++++++++++ migration/pkg/migration/secretpacker.go | 182 +++++ migration/pkg/migration/types.go | 163 +++++ 14 files changed, 3561 insertions(+), 19 deletions(-) create mode 100644 go.sum create mode 100644 migration/pkg/migration/catalog.go create mode 100644 migration/pkg/migration/checks.go create mode 100644 migration/pkg/migration/collector.go create mode 100644 migration/pkg/migration/compatibility.go create mode 100644 migration/pkg/migration/labels.go create mode 100644 migration/pkg/migration/migration.go create mode 100644 migration/pkg/migration/phase.go create mode 100644 migration/pkg/migration/readiness.go create mode 100644 migration/pkg/migration/scan.go create mode 100644 migration/pkg/migration/secretpacker.go create mode 100644 migration/pkg/migration/types.go diff --git a/Makefile b/Makefile index f14a8fc..a43931e 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,17 @@ SHELL := /usr/bin/env bash -o pipefail .SHELLFLAGS := -ec -# NOTE: This Makefile contains stub targets. Go packages and real build/lint -# targets will be added in a subsequent PR. All targets pass so that CI is -# green while the repository is being bootstrapped. +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +BIN_DIR := $(ROOT_DIR)/bin + +GOLANG_VERSION := $(shell sed -En 's/^go (.*)$$/\1/p' "go.mod") + +# bingo manages consistent tooling versions. +include .bingo/Variables.mk + +# Output paths for compiled CLI binaries +MIGRATE_OPERATORS_BIN := $(BIN_DIR)/migrate-operators-v0-to-v1 +MIGRATE_CATALOGS_BIN := $(BIN_DIR)/migrate-catalogs-v0-to-v1 ##@ General @@ -13,33 +21,61 @@ help: ## Display this help message ##@ Build -.PHONY: build build-all -build build-all: ## Build CLIs (stub: no packages yet) - @echo "Nothing to build — Go packages will be added in a subsequent PR." +.PHONY: build +build: build-migrate-operators build-migrate-catalogs ## Build both CLI binaries into bin/ + +.PHONY: build-migrate-operators +build-migrate-operators: ## Build migrate-operators-v0-to-v1 into bin/ + @mkdir -p $(BIN_DIR) + go build -o $(MIGRATE_OPERATORS_BIN) ./migration/examples/cmd/migrate-operators-v0-to-v1 + +.PHONY: build-migrate-catalogs +build-migrate-catalogs: ## Build migrate-catalogs-v0-to-v1 into bin/ + @mkdir -p $(BIN_DIR) + go build -o $(MIGRATE_CATALOGS_BIN) ./migration/examples/cmd/migrate-catalogs-v0-to-v1 + +.PHONY: build-all +build-all: ## Build and verify all packages (library + CLIs) + go build ./... ##@ Test -.PHONY: test test-verbose -test test-verbose: ## Run unit tests (stub: no packages yet) - @echo "Nothing to test — Go packages will be added in a subsequent PR." +.PHONY: test +test: ## Run unit tests + go test ./... -count=1 + +.PHONY: test-verbose +test-verbose: ## Run unit tests with verbose output + go test ./... -v -count=1 ##@ Lint & Verify -.PHONY: lint fmt vet tidy verify api-diff -fmt vet tidy: ## No-op stubs until Go packages are present - @echo "Nothing to format/vet/tidy — Go packages will be added in a subsequent PR." +.PHONY: lint +lint: $(GOLANGCI_LINT) ## Run golangci-lint + $(GOLANGCI_LINT) run ./... + +.PHONY: fmt +fmt: ## Run gofmt + go fmt ./... + +.PHONY: vet +vet: ## Run go vet + go vet ./... -lint: ## No-op stub until Go packages are present - @echo "Nothing to lint — Go packages will be added in a subsequent PR." +.PHONY: tidy +tidy: ## Run go mod tidy + go mod tidy -verify: fmt vet tidy lint ## Run all verification steps (stub) - @echo "Nothing to verify — Go packages will be added in a subsequent PR." +.PHONY: verify +verify: tidy fmt vet lint ## Run all verification steps (tidy, fmt, vet, lint) + @git diff --exit-code || (echo "Files modified by verify — please commit the changes" && exit 1) -api-diff: ## Check for breaking API changes (stub: no packages yet) - @echo "Nothing to diff — Go packages will be added in a subsequent PR." +.PHONY: api-diff +api-diff: $(GO_APIDIFF) ## Check for breaking API changes against origin/main + $(GO_APIDIFF) origin/main --repo-path=. --print-compatible ##@ Clean .PHONY: clean clean: ## Remove built binaries from bin/ - @rm -rf bin/ + rm -f $(MIGRATE_OPERATORS_BIN) $(MIGRATE_CATALOGS_BIN) diff --git a/go.mod b/go.mod index dc70a1e..961888f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,62 @@ module github.com/operator-framework/library-olm go 1.26.5 + +require ( + github.com/operator-framework/api v0.45.0 + github.com/operator-framework/operator-controller v1.11.0 + k8s.io/api v0.36.2 + k8s.io/apiextensions-apiserver v0.36.2 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 + k8s.io/utils v0.0.0-20260626114624-be93311217bd + sigs.k8s.io/controller-runtime v0.24.1 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + 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/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // 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 + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + 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 + 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 new file mode 100644 index 0000000..b4d38c3 --- /dev/null +++ b/go.sum @@ -0,0 +1,203 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +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/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= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= +github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +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/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= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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/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= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/operator-framework/api v0.45.0 h1:hkROwtsLH3oszp4IW+WsXEFSDgveSahHI7DKStOtrUI= +github.com/operator-framework/api v0.45.0/go.mod h1:IQ4uuISTiIhV09oAurJSGD4KabayhY5nV6k1XmA235M= +github.com/operator-framework/operator-controller v1.11.0 h1:I1isTdEJ5mT5WHV+o3DPI3bI+eO0xls4gaVbx8kS/Mg= +github.com/operator-framework/operator-controller v1.11.0/go.mod h1:/BvZx/whTj5qkrO6TVg9oq6C3gmmTvB6QgFGlaROfT4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4= +k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E= +k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/component-base v0.36.2 h1:Z0VH80O7Ng0HDZnZj3WRR3urEGa0kTwmO8CwEwjVK1w= +k8s.io/component-base v0.36.2/go.mod h1:mGfFOA7Gwpdm1VW2cwSQYbiDIlz8GD2WGwH88QSeCyA= +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/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= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/migration/pkg/migration/catalog.go b/migration/pkg/migration/catalog.go new file mode 100644 index 0000000..7836dca --- /dev/null +++ b/migration/pkg/migration/catalog.go @@ -0,0 +1,288 @@ +package migration + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/rest" + "k8s.io/client-go/transport" + "sigs.k8s.io/controller-runtime/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// catalogMeta represents a single entry from the catalog JSONL response. +type catalogMeta struct { + Schema string `json:"schema"` + Name string `json:"name"` + Package string `json:"package"` + DefaultChannel string `json:"defaultChannel,omitempty"` + Props json.RawMessage `json:"properties,omitempty"` + Entries []channelEntry `json:"entries,omitempty"` +} + +type channelEntry struct { + Name string `json:"name"` +} + +// CatalogPackageInfo holds the results of querying a catalog for a package. +type CatalogPackageInfo struct { + Found bool + DefaultChannel string // the package's declared defaultChannel from the FBC + AvailableVersions []string + AvailableChannels []string + VersionFound bool + ChannelFound bool +} + +// QueryCatalogForPackage queries a ClusterCatalog's content to check if the +// specified package, version, and channel are available. +func (m *Migrator) QueryCatalogForPackage(ctx context.Context, catalog *ocv1.ClusterCatalog, packageName, version, channel string, restConfig *rest.Config) (*CatalogPackageInfo, error) { + if catalog.Status.URLs == nil { + 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() + if err != nil { + return nil, fmt.Errorf("failed to get transport config: %w", err) + } + + rt, err := transport.New(transportConfig) + if err != nil { + return nil, fmt.Errorf("failed to create transport: %w", err) + } + + httpClient := &http.Client{Transport: rt} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.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) + } + + return parseCatalogResponse(resp.Body, packageName, version, channel) +} + +func parseCatalogResponse(body io.Reader, packageName, version, channel string) (*CatalogPackageInfo, error) { + info := &CatalogPackageInfo{} + versionSet := map[string]bool{} + channelSet := map[string]bool{} + + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + var meta catalogMeta + if err := json.Unmarshal(scanner.Bytes(), &meta); err != nil { + continue + } + + switch meta.Schema { + case "olm.package": + if meta.Name == packageName { + info.Found = true + if meta.DefaultChannel != "" { + info.DefaultChannel = meta.DefaultChannel + } + } + case "olm.bundle": + if meta.Package != packageName { + continue + } + bundleVersion := extractBundleVersion(meta.Props) + if bundleVersion != "" { + versionSet[bundleVersion] = true + } + case "olm.channel": + if meta.Package != packageName { + continue + } + channelSet[meta.Name] = true + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading catalog response: %w", err) + } + + for v := range versionSet { + info.AvailableVersions = append(info.AvailableVersions, v) + } + for ch := range channelSet { + info.AvailableChannels = append(info.AvailableChannels, ch) + } + + info.VersionFound = versionSet[version] + info.ChannelFound = channel == "" || channelSet[channel] + + return info, nil +} + +func extractBundleVersion(propsRaw json.RawMessage) string { + if propsRaw == nil { + return "" + } + var props []struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(propsRaw, &props); err != nil { + return "" + } + for _, p := range props { + if p.Type == "olm.package" { + var pkg struct { + Version string `json:"version"` + } + if err := json.Unmarshal(p.Value, &pkg); err == nil { + return pkg.Version + } + } + } + return "" +} + +// ResolveClusterCatalog finds a ClusterCatalog that serves the package at the installed version. +func (m *Migrator) ResolveClusterCatalog(ctx context.Context, info *MigrationInfo, restConfig *rest.Config) (string, error) { + var catalogList ocv1.ClusterCatalogList + if err := m.Client.List(ctx, &catalogList); err != nil { + return "", fmt.Errorf("failed to list ClusterCatalogs: %w", err) + } + + type catalogCandidate struct { + name string + priority int32 + pkgInfo *CatalogPackageInfo + } + var candidates []catalogCandidate + var queriedCatalogs []string + + for i := range catalogList.Items { + catalog := &catalogList.Items[i] + + if catalog.Spec.AvailabilityMode == ocv1.AvailabilityModeUnavailable { + continue + } + + serving := false + for _, c := range catalog.Status.Conditions { + if c.Type == "Serving" && c.Status == metav1.ConditionTrue { + serving = true + break + } + } + if !serving { + continue + } + + queriedCatalogs = append(queriedCatalogs, catalog.Name) + m.progress(fmt.Sprintf("Querying catalog %s for package %s@%s...", catalog.Name, info.PackageName, info.Version)) + + pkgInfo, err := m.QueryCatalogForPackage(ctx, catalog, info.PackageName, info.Version, info.Channel, restConfig) + if err != nil { + m.progress(fmt.Sprintf("Could not query catalog %s: %v", catalog.Name, err)) + continue + } + + if pkgInfo.Found && pkgInfo.VersionFound && pkgInfo.ChannelFound { + candidates = append(candidates, catalogCandidate{ + name: catalog.Name, + priority: catalog.Spec.Priority, + pkgInfo: pkgInfo, + }) + } + } + + if len(candidates) == 0 { + return "", &PackageNotFoundError{ + PackageName: info.PackageName, + Version: info.Version, + Channel: info.Channel, + QueriedCatalogs: queriedCatalogs, + } + } + + best := candidates[0] + for _, c := range candidates[1:] { + if c.priority > best.priority { + best = c + } + } + + return best.name, nil +} + +// PackageNotFoundError is returned when no ClusterCatalog contains the required package. +type PackageNotFoundError struct { + PackageName string + Version string + Channel string + QueriedCatalogs []string +} + +func (e *PackageNotFoundError) Error() string { + msg := fmt.Sprintf("package %q at version %q", e.PackageName, e.Version) + if e.Channel != "" { + msg += fmt.Sprintf(" in channel %q", e.Channel) + } + msg += " not found in any serving ClusterCatalog" + if len(e.QueriedCatalogs) > 0 { + msg += fmt.Sprintf(" (queried: %v)", e.QueriedCatalogs) + } + return msg +} + +// CreateClusterCatalog creates a ClusterCatalog from a CatalogSource image reference +// and waits for it to reach a serving state. +func (m *Migrator) CreateClusterCatalog(ctx context.Context, name, imageRef string) error { + catalog := &ocv1.ClusterCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: ocv1.ClusterCatalogSpec{ + Source: ocv1.CatalogSource{ + Type: ocv1.SourceTypeImage, + Image: &ocv1.ImageSource{ + Ref: imageRef, + }, + }, + }, + } + + if err := m.Client.Create(ctx, catalog); err != nil { + return fmt.Errorf("failed to create ClusterCatalog: %w", err) + } + + return wait.PollUntilContextTimeout(ctx, 5*time.Second, 3*time.Minute, true, func(ctx context.Context) (bool, error) { + var cat ocv1.ClusterCatalog + if err := m.Client.Get(ctx, client.ObjectKeyFromObject(catalog), &cat); err != nil { + return false, err + } + for _, c := range cat.Status.Conditions { + if c.Type == "Serving" && c.Status == metav1.ConditionTrue { + return true, nil + } + } + m.progress(fmt.Sprintf("Waiting for ClusterCatalog %s to become ready...", name)) + return false, nil + }) +} diff --git a/migration/pkg/migration/checks.go b/migration/pkg/migration/checks.go new file mode 100644 index 0000000..ae06703 --- /dev/null +++ b/migration/pkg/migration/checks.go @@ -0,0 +1,34 @@ +package migration + +// CheckResult represents the outcome of a single pre-migration check. +type CheckResult struct { + Name string // short name of the check + Passed bool + Message string // detail — pass reason or failure reason +} + +// PreMigrationReport contains the results of all readiness and compatibility checks. +type PreMigrationReport struct { + Checks []CheckResult +} + +// Passed returns true if all checks passed. +func (r *PreMigrationReport) Passed() bool { + for _, c := range r.Checks { + if !c.Passed { + return false + } + } + return true +} + +// FailedChecks returns only the checks that failed. +func (r *PreMigrationReport) FailedChecks() []CheckResult { + var failed []CheckResult + for _, c := range r.Checks { + if !c.Passed { + failed = append(failed, c) + } + } + return failed +} diff --git a/migration/pkg/migration/collector.go b/migration/pkg/migration/collector.go new file mode 100644 index 0000000..b983951 --- /dev/null +++ b/migration/pkg/migration/collector.go @@ -0,0 +1,459 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorsv1 "github.com/operator-framework/api/pkg/operators/v1" + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +// possibleResourceGVKs lists all resource GVKs that may be part of an OLMv0 operator installation. +var possibleResourceGVKs = []schema.GroupVersionKind{ + {Group: "", Version: "v1", Kind: "Namespace"}, + {Group: "", Version: "v1", Kind: "Secret"}, + {Group: "", Version: "v1", Kind: "ConfigMap"}, + {Group: "", Version: "v1", Kind: "ServiceAccount"}, + {Group: "", Version: "v1", Kind: "Service"}, + {Group: "apps", Version: "v1", Kind: "Deployment"}, + {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole"}, + {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRoleBinding"}, + {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"}, + {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "RoleBinding"}, + {Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, + {Group: "admissionregistration.k8s.io", Version: "v1", Kind: "ValidatingWebhookConfiguration"}, + {Group: "admissionregistration.k8s.io", Version: "v1", Kind: "MutatingWebhookConfiguration"}, + {Group: "monitoring.coreos.com", Version: "v1", Kind: "PrometheusRule"}, + {Group: "monitoring.coreos.com", Version: "v1", Kind: "ServiceMonitor"}, + {Group: "monitoring.coreos.com", Version: "v1", Kind: "PodMonitor"}, + {Group: "policy", Version: "v1", Kind: "PodDisruptionBudget"}, + {Group: "scheduling.k8s.io", Version: "v1", Kind: "PriorityClass"}, + {Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"}, + {Group: "autoscaling.k8s.io", Version: "v1", Kind: "VerticalPodAutoscaler"}, + {Group: "console.openshift.io", Version: "v1", Kind: "ConsoleYAMLSample"}, + {Group: "console.openshift.io", Version: "v1", Kind: "ConsoleQuickStart"}, + {Group: "console.openshift.io", Version: "v1", Kind: "ConsoleCLIDownload"}, + {Group: "console.openshift.io", Version: "v1", Kind: "ConsoleLink"}, + {Group: "console.openshift.io", Version: "v1", Kind: "ConsolePlugin"}, +} + +// clusterScopedKinds is the set of kinds that are cluster-scoped (no namespace in lookups). +var clusterScopedKinds = map[string]bool{ + "Namespace": true, + "ClusterRole": true, + "ClusterRoleBinding": true, + "CustomResourceDefinition": true, + "PriorityClass": true, + "ConsoleYAMLSample": true, + "ConsoleQuickStart": true, + "ConsoleCLIDownload": true, + "ConsoleLink": true, + "ConsolePlugin": true, + "ValidatingWebhookConfiguration": true, + "MutatingWebhookConfiguration": true, +} + +// olmv0OnlyKinds are OLMv0 management resources that should not be included in the COS. +var olmv0OnlyKinds = map[string]bool{ + "OperatorCondition": true, + "Operator": true, + "OperatorGroup": true, +} + +// GetCSVAndInstallPlan retrieves the Subscription, CSV, and InstallPlan. +func (m *Migrator) GetCSVAndInstallPlan(ctx context.Context, opts Options) (*operatorsv1alpha1.Subscription, *operatorsv1alpha1.ClusterServiceVersion, *operatorsv1alpha1.InstallPlan, error) { + var sub operatorsv1alpha1.Subscription + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: opts.SubscriptionName, + Namespace: opts.SubscriptionNamespace, + }, &sub); err != nil { + return nil, nil, nil, fmt.Errorf("failed to get Subscription: %w", err) + } + + csvName := sub.Status.InstalledCSV + if csvName == "" { + return nil, nil, nil, fmt.Errorf("subscription has no installedCSV") + } + + var csv operatorsv1alpha1.ClusterServiceVersion + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: csvName, + Namespace: opts.SubscriptionNamespace, + }, &csv); err != nil { + return nil, nil, nil, fmt.Errorf("failed to get CSV %s: %w", csvName, err) + } + + var ip *operatorsv1alpha1.InstallPlan + ipName, ipNamespace := "", sub.Namespace + if sub.Status.InstallPlanRef != nil { + ipName = sub.Status.InstallPlanRef.Name + if sub.Status.InstallPlanRef.Namespace != "" { + ipNamespace = sub.Status.InstallPlanRef.Namespace + } + } else if sub.Status.Install != nil { + // Fallback to the deprecated status.install field (R4). + ipName = sub.Status.Install.Name + } + if ipName != "" { + ip = &operatorsv1alpha1.InstallPlan{} + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: ipName, + Namespace: ipNamespace, + }, ip); err != nil { + return nil, nil, nil, fmt.Errorf("failed to get InstallPlan %s: %w", ipName, err) + } + } + + return &sub, &csv, ip, nil +} + +// GetBundleInfo extracts bundle metadata from the Subscription and CSV. +func (m *Migrator) GetBundleInfo(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion, ip *operatorsv1alpha1.InstallPlan) (*MigrationInfo, error) { + var sub operatorsv1alpha1.Subscription + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: opts.SubscriptionName, + Namespace: opts.SubscriptionNamespace, + }, &sub); err != nil { + return nil, fmt.Errorf("failed to get Subscription: %w", err) + } + + info := &MigrationInfo{ + PackageName: sub.Spec.Package, + Channel: sub.Spec.Channel, + ManualApproval: sub.Spec.InstallPlanApproval == operatorsv1alpha1.ApprovalManual, + CatalogSourceRef: types.NamespacedName{ + Name: sub.Spec.CatalogSource, + Namespace: sub.Spec.CatalogSourceNamespace, + }, + } + + info.BundleName = csv.Name + info.Version = parseCSVVersion(csv) + + // R4: spec.config → CE deploymentConfig. Drop spec.config.selector (never honored in + // OLMv0; no CE equivalent) and warn if it was set. + if sub.Spec.Config != nil { + cfg := sub.Spec.Config.DeepCopy() + if cfg.Selector != nil { + m.progress("Warning: Subscription spec.config.selector is not supported by OLMv1 and will be dropped during migration") + cfg.Selector = nil + } + info.SubscriptionConfig = cfg + } + + if ip != nil { + for _, bl := range ip.Status.BundleLookups { + if bl.Identifier == csv.Name { + info.BundleImage = bl.Path + if bl.CatalogSourceRef != nil { + info.CatalogSourceRef = types.NamespacedName{ + Name: bl.CatalogSourceRef.Name, + Namespace: bl.CatalogSourceRef.Namespace, + } + } + break + } + } + } + + return info, nil +} + +// parseCSVVersion extracts the version from the CSV's operatorframework.io/properties annotation. +func parseCSVVersion(csv *operatorsv1alpha1.ClusterServiceVersion) string { + propsJSON := csv.Annotations["operatorframework.io/properties"] + if propsJSON == "" { + return csv.Spec.Version.String() + } + + props, err := parseProperties(propsJSON) + if err != nil { + return csv.Spec.Version.String() + } + + for _, p := range props { + if p.Type == "olm.package" { + var pkg struct { + PackageName string `json:"packageName"` + Version string `json:"version"` + } + if err := json.Unmarshal(p.Value, &pkg); err == nil && pkg.Version != "" { + return pkg.Version + } + } + } + return csv.Spec.Version.String() +} + +// GetCatalogSourceImage retrieves the image reference from the CatalogSource spec. +func (m *Migrator) GetCatalogSourceImage(ctx context.Context, csRef types.NamespacedName) (string, error) { + var cs operatorsv1alpha1.CatalogSource + if err := m.Client.Get(ctx, csRef, &cs); err != nil { + return "", fmt.Errorf("failed to get CatalogSource %s/%s: %w", csRef.Namespace, csRef.Name, err) + } + if cs.Spec.Image == "" { + return "", fmt.Errorf("CatalogSource %s/%s has no spec.image set", csRef.Namespace, csRef.Name) + } + return cs.Spec.Image, nil +} + +// CollectResources gathers all resources belonging to the operator using multiple collection strategies. +func (m *Migrator) CollectResources(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion, ip *operatorsv1alpha1.InstallPlan, packageName string) ([]unstructured.Unstructured, error) { + seen := make(map[string]bool) + var collected []unstructured.Unstructured + + addIfNew := func(obj unstructured.Unstructured) { + if olmv0OnlyKinds[obj.GetKind()] { + return + } + key := resourceKey(obj) + if !seen[key] { + seen[key] = true + collected = append(collected, obj) + } + } + + // Strategy 1: Operator CR status.components.refs (primary) + fromOperatorCR, _ := m.gatherResourcesFromOperatorCR(ctx, packageName, opts.SubscriptionNamespace) + for _, obj := range fromOperatorCR { + addIfNew(obj) + } + + // Strategy 2: CRDs by package label + crds, err := m.getCRDsByPackage(ctx, opts, packageName) + if err != nil { + return nil, fmt.Errorf("failed to collect CRDs by package: %w", err) + } + for _, obj := range crds { + addIfNew(obj) + } + + // Strategy 3: Resources by olm.owner label + for _, obj := range m.gatherResourcesByOwnerLabel(ctx, csv.Name) { + addIfNew(obj) + } + + // Strategy 4: Resources by ownerReference in the subscription namespace + for _, obj := range m.gatherResourcesByOwnerRef(ctx, opts.SubscriptionNamespace, csv) { + addIfNew(obj) + } + + // Strategy 5: Resources from InstallPlan steps + if ip != nil { + for _, obj := range m.gatherResourcesFromInstallPlan(ctx, ip, csv.Name) { + addIfNew(obj) + } + } + + return collected, nil +} + +// resourceKey produces a dedup key that is stable across API version aliases. +// APIVersion is intentionally excluded: the same resource may be returned from +// different collection sources using different version strings (e.g. "v1" vs +// "core/v1"), and including it would prevent correct deduplication (R5). +func resourceKey(obj unstructured.Unstructured) string { + gvk := obj.GetObjectKind().GroupVersionKind() + return fmt.Sprintf("%s/%s/%s/%s", + gvk.Group, + gvk.Kind, + obj.GetNamespace(), + obj.GetName()) +} + +func (m *Migrator) getCRDsByPackage(ctx context.Context, opts Options, packageName string) ([]unstructured.Unstructured, error) { + packageLabel := fmt.Sprintf("operators.coreos.com/%s.%s", packageName, opts.SubscriptionNamespace) + + var crdList unstructured.UnstructuredList + crdList.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinitionList", + }) + + if err := m.Client.List(ctx, &crdList, + client.MatchingLabels{ + "olm.managed": "true", + packageLabel: "", + }, + ); err != nil { + return nil, err + } + return crdList.Items, nil +} + +func (m *Migrator) gatherResourcesByOwnerLabel(ctx context.Context, csvName string) []unstructured.Unstructured { + var result []unstructured.Unstructured + + for _, gvk := range possibleResourceGVKs { + var list unstructured.UnstructuredList + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + + // R5: match on olm.owner= only. The spec does not require + // olm.managed=true; adding it would miss resources that carry olm.owner + // but were not stamped with the managed label. + if err := m.Client.List(ctx, &list, + client.MatchingLabels{"olm.owner": csvName}, + ); err != nil { + continue + } + result = append(result, list.Items...) + } + return result +} + +func (m *Migrator) gatherResourcesByOwnerRef(ctx context.Context, namespace string, csv *operatorsv1alpha1.ClusterServiceVersion) []unstructured.Unstructured { + var result []unstructured.Unstructured + + for _, gvk := range possibleResourceGVKs { + if clusterScopedKinds[gvk.Kind] { + continue + } + + var list unstructured.UnstructuredList + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + + if err := m.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { + continue + } + + for _, obj := range list.Items { + for _, ref := range obj.GetOwnerReferences() { + if ref.Kind == "ClusterServiceVersion" && ref.Name == csv.Name { + result = append(result, obj) + break + } + } + } + } + return result +} + +func (m *Migrator) gatherResourcesFromInstallPlan(ctx context.Context, ip *operatorsv1alpha1.InstallPlan, csvName string) []unstructured.Unstructured { + var result []unstructured.Unstructured + + for _, step := range ip.Status.Plan { + if step == nil || step.Resolving != csvName { + continue + } + + res := step.Resource + if res.Kind == "ClusterServiceVersion" || res.Kind == "Subscription" || res.Kind == "InstallPlan" { + continue + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: res.Group, + Version: res.Version, + Kind: res.Kind, + }) + + nn := types.NamespacedName{Name: res.Name} + if !clusterScopedKinds[res.Kind] { + nn.Namespace = ip.Namespace + } + + if err := m.Client.Get(ctx, nn, obj); err != nil { + continue + } + result = append(result, *obj) + } + return result +} + +// gatherResourcesFromOperatorCR collects resources from the Operator CR's status.components.refs. +func (m *Migrator) gatherResourcesFromOperatorCR(ctx context.Context, packageName, namespace string) ([]unstructured.Unstructured, error) { + op, err := m.GetOperatorCR(ctx, packageName, namespace) + if err != nil { + return nil, err + } + + if op.Status.Components == nil { + return nil, nil + } + + skipKinds := map[string]bool{ + "ClusterServiceVersion": true, + "Subscription": true, + "InstallPlan": true, + } + + var result []unstructured.Unstructured + for _, ref := range op.Status.Components.Refs { + if ref.ObjectReference == nil { + continue + } + if skipKinds[ref.Kind] { + continue + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: ref.GroupVersionKind().Group, + Version: ref.GroupVersionKind().Version, + Kind: ref.Kind, + }) + + nn := types.NamespacedName{Name: ref.Name} + if ref.Namespace != "" { + nn.Namespace = ref.Namespace + } + + if err := m.Client.Get(ctx, nn, obj); err != nil { + continue + } + result = append(result, *obj) + } + return result, nil +} + +// GatherMigrationInfo profiles the operator and collects all migration information. +func (m *Migrator) GatherMigrationInfo(ctx context.Context, opts Options) (*MigrationInfo, error) { + _, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + return nil, err + } + + info, err := m.GetBundleInfo(ctx, opts, csv, ip) + if err != nil { + return nil, err + } + + csImage, err := m.GetCatalogSourceImage(ctx, info.CatalogSourceRef) + if err == nil { + info.CatalogSourceImage = csImage + } + + objects, err := m.CollectResources(ctx, opts, csv, ip, info.PackageName) + if err != nil { + return nil, err + } + info.CollectedObjects = objects + + return info, nil +} + +// GetOperatorCR retrieves the Operator CR for the given package and namespace. +func (m *Migrator) GetOperatorCR(ctx context.Context, packageName, namespace string) (*operatorsv1.Operator, error) { + operatorName := fmt.Sprintf("%s.%s", packageName, namespace) + var op operatorsv1.Operator + if err := m.Client.Get(ctx, types.NamespacedName{Name: operatorName}, &op); err != nil { + return nil, err + } + return &op, nil +} diff --git a/migration/pkg/migration/compatibility.go b/migration/pkg/migration/compatibility.go new file mode 100644 index 0000000..8549301 --- /dev/null +++ b/migration/pkg/migration/compatibility.go @@ -0,0 +1,347 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorsv1 "github.com/operator-framework/api/pkg/operators/v1" + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +// CheckCompatibility runs all compatibility checks and returns a report with individual results. +func (m *Migrator) CheckCompatibility(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion, bundleProperties string) (*PreMigrationReport, error) { + report := &PreMigrationReport{} + + // OperatorGroup checks + ogChecks, err := m.checkAllNamespacesMode(ctx, opts) + if err != nil { + return nil, err + } + report.Checks = append(report.Checks, ogChecks...) + + // Dependency checks (C2 — hard block) + report.Checks = append(report.Checks, checkNoDependencies(bundleProperties)...) + + // C3 (APIService definitions) was removed: OLMv1 now manages APIService objects + // natively via the registry+v1 renderer (OPRUN-4723). Operators with owned + // APIService definitions are now Eligible with no override required. + + // OperatorCondition checks (C4) + condCheck, err := m.checkNoOperatorConditions(ctx, opts, csv) + if err != nil { + return nil, err + } + report.Checks = append(report.Checks, condCheck) + + // OLMv0-API RBAC check (C5 — soft) + report.Checks = append(report.Checks, checkOLMv0APIAccess(opts, csv)) + + return report, nil +} + +func (m *Migrator) checkAllNamespacesMode(ctx context.Context, opts Options) ([]CheckResult, error) { + var ogList operatorsv1.OperatorGroupList + if err := m.Client.List(ctx, &ogList, client.InNamespace(opts.SubscriptionNamespace)); err != nil { + return nil, fmt.Errorf("failed to list OperatorGroups in %s: %w", opts.SubscriptionNamespace, err) + } + if len(ogList.Items) == 0 { + return []CheckResult{{ + Name: "OperatorGroup exists", + Passed: false, + Message: fmt.Sprintf("no OperatorGroup found in namespace %s", opts.SubscriptionNamespace), + }}, nil + } + + og := ogList.Items[0] + var checks []CheckResult + + // spec.serviceAccountName (C6 — soft) + if og.Spec.ServiceAccountName != "" { + if opts.AcknowledgeScopedServiceAccount { + checks = append(checks, CheckResult{ + Name: "No scoped ServiceAccount", + Passed: true, + Message: "overridden: operator will use cluster-admin (scoped ServiceAccount acknowledged)", + }) + } else { + checks = append(checks, CheckResult{ + Name: "No scoped ServiceAccount", + Passed: false, + Message: "OperatorGroup has spec.serviceAccountName set; OLMv1 does not support scoped service accounts", + }) + } + } else { + checks = append(checks, CheckResult{ + Name: "No scoped ServiceAccount", + Passed: true, + Message: "OperatorGroup does not use a scoped service account", + }) + } + + // spec.selector — scoped namespace selector means not AllNamespaces (C1 — soft) + if og.Spec.Selector != nil && !isEmptyLabelSelector(og.Spec.Selector) { + if opts.AcknowledgeWatchScopeChange { + checks = append(checks, CheckResult{ + Name: "No namespace selector", + Passed: true, + Message: "overridden: operator will run AllNamespaces post-migration (watch scope change acknowledged)", + }) + } else { + checks = append(checks, CheckResult{ + Name: "No namespace selector", + Passed: false, + Message: "OperatorGroup has spec.selector set; OLMv1 uses AllNamespaces — pass --acknowledge-watch-scope-change to override", + }) + } + } else { + checks = append(checks, CheckResult{ + Name: "No namespace selector", + Passed: true, + Message: "OperatorGroup does not use a namespace selector", + }) + } + + // spec.upgradeStrategy — TechPreviewUnsafeFailForward is not mapped to OLMv1 and is not + // equivalent to SelfCertified. R6 says this is informational only: warn but do not block. + if og.Spec.UpgradeStrategy != "" && og.Spec.UpgradeStrategy != operatorsv1.UpgradeStrategyDefault { + checks = append(checks, CheckResult{ + Name: "Upgrade strategy", + Passed: true, + Message: fmt.Sprintf("OperatorGroup upgradeStrategy %q is not mapped to OLMv1 and will be ignored post-migration", og.Spec.UpgradeStrategy), + }) + } else { + checks = append(checks, CheckResult{ + Name: "Upgrade strategy", + Passed: true, + Message: "upgrade strategy is Default or unset", + }) + } + + // spec.targetNamespaces — AllNamespaces mode (C1 — soft) + if len(og.Spec.TargetNamespaces) > 0 { + if opts.AcknowledgeWatchScopeChange { + checks = append(checks, CheckResult{ + Name: "AllNamespaces mode", + Passed: true, + Message: "overridden: operator will run AllNamespaces post-migration (watch scope change acknowledged)", + }) + } else { + checks = append(checks, CheckResult{ + Name: "AllNamespaces mode", + Passed: false, + Message: "OperatorGroup has spec.targetNamespaces set; operator must be in AllNamespaces mode for migration", + }) + } + } else { + checks = append(checks, CheckResult{ + Name: "AllNamespaces mode", + Passed: true, + Message: "operator is in AllNamespaces mode", + }) + } + + // status.namespaces warning — single-namespace targets will become AllNamespaces (C1 — soft) + if len(og.Status.Namespaces) == 1 && og.Status.Namespaces[0] != "" { + if opts.AcknowledgeWatchScopeChange { + checks = append(checks, CheckResult{ + Name: "Namespace scope change", + Passed: true, + Message: "overridden: watch scope change acknowledged", + }) + } else { + checks = append(checks, CheckResult{ + Name: "Namespace scope change", + Passed: false, + Message: fmt.Sprintf("OperatorGroup targets namespace %q; post-migration the operator will run in AllNamespaces mode", og.Status.Namespaces[0]), + }) + } + } + + return checks, nil +} + +func isEmptyLabelSelector(s *metav1.LabelSelector) bool { + return s == nil || (len(s.MatchLabels) == 0 && len(s.MatchExpressions) == 0) +} + +// olmProperty represents a single entry in the operatorframework.io/properties annotation. +type olmProperty struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` +} + +// parseProperties handles both bare-array and wrapped-object formats of the +// operatorframework.io/properties annotation. +func parseProperties(propertiesJSON string) ([]olmProperty, error) { + raw := []byte(propertiesJSON) + + var props []olmProperty + if err := json.Unmarshal(raw, &props); err == nil { + return props, nil + } + + var wrapped struct { + Properties []olmProperty `json:"properties"` + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return nil, err + } + return wrapped.Properties, nil +} + +// checkNoDependencies enforces C2 — no olm.package.required or olm.gvk.required (hard block). +func checkNoDependencies(propertiesJSON string) []CheckResult { + if propertiesJSON == "" { + return []CheckResult{{ + Name: "No dependency resolution", + Passed: true, + Message: "no bundle properties declared", + }} + } + + props, err := parseProperties(propertiesJSON) + if err != nil { + return []CheckResult{{ + Name: "No dependency resolution", + Passed: false, + Message: fmt.Sprintf("failed to parse bundle properties: %v", err), + }} + } + + var issues []CheckResult + for _, p := range props { + switch p.Type { + case "olm.package.required": + issues = append(issues, CheckResult{ + Name: "No dependency resolution", + Passed: false, + Message: fmt.Sprintf("bundle declares olm.package.required dependency: %s", string(p.Value)), + }) + case "olm.gvk.required": + issues = append(issues, CheckResult{ + Name: "No dependency resolution", + Passed: false, + Message: fmt.Sprintf("bundle declares olm.gvk.required dependency: %s", string(p.Value)), + }) + } + } + + if len(issues) == 0 { + return []CheckResult{{ + Name: "No dependency resolution", + Passed: true, + Message: "no olm.package.required or olm.gvk.required properties", + }} + } + return issues +} + +// olmv0APIResources is the set of operators.coreos.com resource names that signal OLMv0-API +// dependency (per R3 C5). operatorconditions is explicitly excluded — OLMv0 stamps that RBAC +// on every operator and its presence is not a usage signal. +var olmv0APIResources = map[string]bool{ + "subscriptions": true, + "installplans": true, + "clusterserviceversions": true, + "catalogsources": true, +} + +// checkOLMv0APIAccess implements C5: flag operators whose clusterPermissions grant access to +// OLMv0 APIs (operators.coreos.com, excluding operatorconditions) without also granting +// equivalent OLMv1 API access (olm.operatorframework.io). Operators updated for OLMv1 +// compatibility carry both sets of permissions and pass this check. +func checkOLMv0APIAccess(opts Options, csv *operatorsv1alpha1.ClusterServiceVersion) CheckResult { + hasOLMv0Access := false + hasOLMv1Access := false + + for _, perm := range csv.Spec.InstallStrategy.StrategySpec.ClusterPermissions { + for _, rule := range perm.Rules { + for _, group := range rule.APIGroups { + switch group { + case "operators.coreos.com": + for _, res := range rule.Resources { + if olmv0APIResources[res] || res == "*" { + hasOLMv0Access = true + } + } + case "olm.operatorframework.io": + hasOLMv1Access = true + } + } + } + } + + if !hasOLMv0Access { + return CheckResult{ + Name: "OLMv0-API RBAC", + Passed: true, + Message: "CSV clusterPermissions do not grant OLMv0 API access", + } + } + if hasOLMv1Access { + return CheckResult{ + Name: "OLMv0-API RBAC", + Passed: true, + Message: "CSV clusterPermissions grant both OLMv0 and OLMv1 API access (updated for compatibility)", + } + } + + // OLMv0 access without OLMv1 access — soft block + if opts.AcknowledgeOLMv0APIAccess { + return CheckResult{ + Name: "OLMv0-API RBAC", + Passed: true, + Message: "overridden: OLMv0 API RBAC without OLMv1 equivalent (olmv0-api-access acknowledged)", + } + } + return CheckResult{ + Name: "OLMv0-API RBAC", + Passed: false, + Message: "CSV clusterPermissions grant operators.coreos.com access without OLMv1 equivalent; pass --acknowledge-olmv0-api-access to override", + } +} + +// checkNoOperatorConditions enforces C4 — no active OperatorCondition status entries. +// RBAC presence alone is NOT treated as usage; only status.conditions entries count. +func (m *Migrator) checkNoOperatorConditions(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion) (CheckResult, error) { + var oc operatorsv1.OperatorCondition + err := m.Client.Get(ctx, types.NamespacedName{ + Name: csv.Name, + Namespace: opts.SubscriptionNamespace, + }, &oc) + if err != nil { + if client.IgnoreNotFound(err) != nil { + return CheckResult{}, fmt.Errorf("failed to get OperatorCondition: %w", err) + } + return CheckResult{ + Name: "No OperatorCondition usage", + Passed: true, + Message: "no OperatorCondition resource found", + }, nil + } + + if len(oc.Status.Conditions) > 0 { + if opts.AcknowledgeOperatorCondition { + return CheckResult{ + Name: "No OperatorCondition usage", + Passed: true, + Message: "overridden: active OperatorCondition usage acknowledged", + }, nil + } + return CheckResult{ + Name: "No OperatorCondition usage", + Passed: false, + Message: "OperatorCondition has status.conditions entries; operator actively uses the OperatorCondition API", + }, nil + } + return CheckResult{ + Name: "No OperatorCondition usage", + Passed: true, + Message: "OperatorCondition exists but has no status entries", + }, nil +} diff --git a/migration/pkg/migration/labels.go b/migration/pkg/migration/labels.go new file mode 100644 index 0000000..c95aaf3 --- /dev/null +++ b/migration/pkg/migration/labels.go @@ -0,0 +1,46 @@ +package migration + +// Label and annotation keys used by the migration tool. +// These match the values expected by operator-controller but are defined here +// to avoid importing internal packages from that module. +const ( + // LabelOwnerKind is set on ClusterObjectSet to indicate its owner's kind. + LabelOwnerKind = "olm.operatorframework.io/owner-kind" + // LabelOwnerName is set on ClusterObjectSet to indicate its owner's name. + LabelOwnerName = "olm.operatorframework.io/owner-name" + // LabelRevisionName is set on ref Secrets to identify the ClusterObjectSet. + LabelRevisionName = "olm.operatorframework.io/revision-name" + // LabelPackageName records the operator package associated with a ClusterObjectSet. + LabelPackageName = "olm.operatorframework.io/package-name" + // LabelBundleName records the bundle name for a ClusterObjectSet. + LabelBundleName = "olm.operatorframework.io/bundle-name" + // LabelBundleVersion records the bundle version for a ClusterObjectSet. + LabelBundleVersion = "olm.operatorframework.io/bundle-version" + // LabelBundleReference records the bundle image reference for a ClusterObjectSet. + LabelBundleReference = "olm.operatorframework.io/bundle-reference" + // LabelMetadataName is the well-known label key for ClusterCatalog name selection. + LabelMetadataName = "olm.operatorframework.io/metadata.name" + + // SecretTypeObjectData is the Secret type for externalized COS object content. + SecretTypeObjectData = "olm.operatorframework.io/object-data" //nolint:gosec // G101 false positive: this is a Kubernetes Secret type identifier, not a credential + + // MigratedFromSubscriptionAnnotation is set on both the COS and CE. + // Value is "/" of the source Subscription. + MigratedFromSubscriptionAnnotation = "olm.operatorframework.io/migrated-from-subscription" + + // MigrationSubscriptionBackupAnnotation holds JSON-encoded Subscription spec on the CE (R2.5). + MigrationSubscriptionBackupAnnotation = "olm.operatorframework.io/migration-subscription-backup" + + // MigrationOperatorGroupBackupAnnotation holds JSON-encoded OperatorGroup spec on the CE (R2.5). + MigrationOperatorGroupBackupAnnotation = "olm.operatorframework.io/migration-operatorgroup-backup" + + // AnnotationAcknowledgedPrefix is the prefix for per-flag audit annotations on the CE (R2.5). + // Full key: AnnotationAcknowledgedPrefix + "", value "true". + AnnotationAcknowledgedPrefix = "olm.operatorframework.io/acknowledged-" + + // MigratedFromCatalogSourceAnnotation is set on ClusterCatalog by the catalog migration tool. + MigratedFromCatalogSourceAnnotation = "olm.operatorframework.io/migrated-from-catalogsource" + + // fieldManager is the SSA field manager used for all apply operations. + fieldManager = "olm.operatorframework.io/migration" +) diff --git a/migration/pkg/migration/migration.go b/migration/pkg/migration/migration.go new file mode 100644 index 0000000..58a9f6c --- /dev/null +++ b/migration/pkg/migration/migration.go @@ -0,0 +1,836 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorsv1 "github.com/operator-framework/api/pkg/operators/v1" + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + ocv1 "github.com/operator-framework/operator-controller/api/v1" + ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" +) + +// annotationPrefixesToStrip are annotation prefixes that should be removed from migrated resources. +var annotationPrefixesToStrip = []string{ + "kubectl.kubernetes.io/", + "olm.operatorframework.io/installed-alongside", + "deployment.kubernetes.io/", +} + +// Migrate performs the full migration of an OLMv0-managed operator to OLMv1. +// Steps: +// 1. Profile the Operator (Subscription/CSV/InstallPlan) +// 2. Determine Compatibility and Readiness +// 3. Determine Target ClusterCatalog +// 4. Backup resources +// 5. Prepare for Migration (delete Sub/CSV with orphan cascade) +// 6. Collect Operator Resources +// 7. Create ClusterObjectSet (wait Succeeded=True) +// 8. Create ClusterExtension (wait Installed=True) +// 9. Clean Up OLMv0 Resources +func (m *Migrator) Migrate(ctx context.Context, opts Options) error { + opts.ApplyDefaults() + + _, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + return fmt.Errorf("failed to profile operator: %w", err) + } + + info, err := m.GetBundleInfo(ctx, opts, csv, ip) + if err != nil { + return fmt.Errorf("failed to get bundle info: %w", err) + } + + readiness, err := m.CheckReadiness(ctx, opts) + if err != nil { + return fmt.Errorf("readiness check failed: %w", err) + } + if !readiness.Passed() { + return fmt.Errorf("readiness checks failed (%d issues)", len(readiness.FailedChecks())) + } + + propsJSON := csv.Annotations["operatorframework.io/properties"] + compat, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + return fmt.Errorf("compatibility check failed: %w", err) + } + if !compat.Passed() { + return fmt.Errorf("operator is not compatible with OLMv1 migration (%d issues found)", len(compat.FailedChecks())) + } + + catalogName, err := m.ResolveClusterCatalog(ctx, info, m.RESTConfig) + if err != nil { + return fmt.Errorf("failed to resolve ClusterCatalog: %w", err) + } + info.ResolvedCatalogName = catalogName + + backup, err := m.BackupResources(ctx, opts, csv, ip) + if err != nil { + return fmt.Errorf("failed to backup resources: %w", err) + } + + // Populate CE backup annotations (R2.5) — must happen before PrepareForMigration deletes the Sub. + if backup.Subscription != nil { + if j, err := json.Marshal(backup.Subscription.Spec); err == nil { + info.SubscriptionBackupJSON = string(j) + } + } + if backup.OperatorGroup != nil { + if j, err := json.Marshal(backup.OperatorGroup.Spec); err == nil { + info.OperatorGroupBackupJSON = string(j) + } + } + + // Disk backup (non-fatal per R2.6 — CE annotation backup is authoritative). + if opts.BackupDirectory != "" { + if err := backup.SaveToDisk(opts.BackupDirectory); err != nil { + m.progress(fmt.Sprintf("Warning: backup to disk failed (CE annotation backup is authoritative): %v", err)) + } + } + + if err := m.PrepareForMigration(ctx, opts, csv); err != nil { + if recoverErr := m.RecoverFromBackup(ctx, opts, backup); recoverErr != nil { + return fmt.Errorf("preparation failed: %w; recovery also failed: %v", err, recoverErr) + } + return fmt.Errorf("preparation failed (recovered): %w", err) + } + + objects, err := m.CollectResources(ctx, opts, csv, ip, info.PackageName) + if err != nil { + return fmt.Errorf("failed to collect resources: %w", err) + } + info.CollectedObjects = objects + + // R9: warn about TLS certificate pivot. OLMv0 manages certs directly via its own + // cert rotation; OLMv1 delegates to cert-manager (upstream) or openshift-service-ca + // (downstream). Pod restarts are expected during this pivot as the new cert secrets + // are provisioned. This is known behavior and does not indicate a migration failure. + m.progress("Note: TLS certificate management will transfer from OLMv0 to cert-manager/service-ca; " + + "expect pod restarts while new cert secrets are provisioned") + + if err := m.CreateClusterObjectSet(ctx, opts, info); err != nil { + if recoverErr := m.RecoverBeforeCE(ctx, opts, backup); recoverErr != nil { + return fmt.Errorf("COS creation failed: %w; recovery also failed: %v", err, recoverErr) + } + return fmt.Errorf("COS creation failed (recovered): %w", err) + } + + if err := m.CreateClusterExtension(ctx, opts, info); err != nil { + return fmt.Errorf("failed to create ClusterExtension: %w", err) + } + + m.CleanupOLMv0Resources(ctx, opts, info.PackageName, csv.Name) + + return nil +} + +// EnsurePrerequisites verifies that all prerequisites for migration are met. +func (m *Migrator) EnsurePrerequisites(ctx context.Context, opts Options) (*operatorsv1alpha1.ClusterServiceVersion, *operatorsv1alpha1.InstallPlan, *PreMigrationReport, *PreMigrationReport, error) { + readiness, err := m.CheckReadiness(ctx, opts) + if err != nil { + return nil, nil, nil, nil, err + } + + _, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + return nil, nil, nil, nil, err + } + + propsJSON := csv.Annotations["operatorframework.io/properties"] + compat, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + return nil, nil, nil, nil, err + } + + return csv, ip, readiness, compat, nil +} + +// BackupResources creates in-memory backup copies of the Subscription, CSV, OperatorGroup, +// and InstallPlan for recovery and auditing (R1.8, R2.6). +func (m *Migrator) BackupResources(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion, ip *operatorsv1alpha1.InstallPlan) (*Backup, error) { + var sub operatorsv1alpha1.Subscription + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: opts.SubscriptionName, + Namespace: opts.SubscriptionNamespace, + }, &sub); err != nil { + return nil, fmt.Errorf("failed to backup Subscription: %w", err) + } + + // Best-effort: fetch the OperatorGroup from the Subscription namespace. + var ogList operatorsv1.OperatorGroupList + _ = m.Client.List(ctx, &ogList, client.InNamespace(opts.SubscriptionNamespace)) + var og *operatorsv1.OperatorGroup + if len(ogList.Items) > 0 { + og = ogList.Items[0].DeepCopy() + } + + return &Backup{ + Subscription: sub.DeepCopy(), + ClusterServiceVersion: csv.DeepCopy(), + OperatorGroup: og, + InstallPlan: ip, + }, nil +} + +// PrepareForMigration removes OLMv0 management of the operator by deleting +// the Subscription and CSV with orphan cascading (operator workloads keep running). +func (m *Migrator) PrepareForMigration(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion) error { + // Delete Subscription with orphan cascading + sub := &operatorsv1alpha1.Subscription{} + sub.Name = opts.SubscriptionName + sub.Namespace = opts.SubscriptionNamespace + if err := m.Client.Delete(ctx, sub, client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete Subscription: %w", err) + } + } + + // Delete CSV with orphan cascading + if err := m.Client.Delete(ctx, csv, client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete CSV: %w", err) + } + } + + return nil +} + +// RecoverFromBackup restores the Subscription from backup after a failed preparation. +func (m *Migrator) RecoverFromBackup(ctx context.Context, opts Options, backup *Backup) error { + if backup == nil { + return fmt.Errorf("no backup available for recovery") + } + + sub := backup.Subscription.DeepCopy() + sub.ResourceVersion = "" + sub.UID = "" + sub.Generation = 0 + sub.CreationTimestamp = metav1.Time{} + sub.Status = operatorsv1alpha1.SubscriptionStatus{} + + if backup.Subscription.Status.InstalledCSV != "" { + sub.Spec.StartingCSV = backup.Subscription.Status.InstalledCSV + } + + if err := m.Client.Create(ctx, sub); err != nil { + return fmt.Errorf("failed to re-create Subscription: %w", err) + } + + return wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var restored operatorsv1alpha1.Subscription + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: opts.SubscriptionName, + Namespace: opts.SubscriptionNamespace, + }, &restored); err != nil { + return false, err + } + if restored.Status.State == operatorsv1alpha1.SubscriptionStateAtLatest || + restored.Status.State == operatorsv1alpha1.SubscriptionStateUpgradePending { + return true, nil + } + m.progress(fmt.Sprintf("Subscription state: %s (waiting for AtLatestKnown)", restored.Status.State)) + return false, nil + }) +} + +// RecoverBeforeCE implements recovery when COS creation fails. +// Deletes the failed COS with orphan cascade, then restores the Subscription. +func (m *Migrator) RecoverBeforeCE(ctx context.Context, opts Options, backup *Backup) error { + cosName := fmt.Sprintf("%s-1", opts.ClusterExtensionName) + cos := &ocv1.ClusterObjectSet{} + cos.Name = cosName + if err := m.Client.Delete(ctx, cos, client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete COS during recovery: %w", err) + } + } + + return m.RecoverFromBackup(ctx, opts, backup) +} + +// CreateClusterObjectSet builds and creates a COS from the collected resources. +// It uses CollisionProtection=IfNoController so OLMv1 can adopt existing resources (including CRDs). +// The COS is annotated with the source Subscription reference. +// +// TODO(R2.7): when boxcutter phase 2 introduces ClusterObjectDeployment as a replacement or +// complement to ClusterObjectSet, update this function (and its callers) to create whichever +// OLMv1 revision object(s) are appropriate. Track upstream progress at OPRUN-4716 and the +// boxcutter ClusterObjectDeployment design. +func (m *Migrator) CreateClusterObjectSet(ctx context.Context, opts Options, info *MigrationInfo) error { + cosName := fmt.Sprintf("%s-1", opts.ClusterExtensionName) + systemNS := opts.systemNamespace() + + cosObjects := make([]ocv1ac.ClusterObjectSetObjectApplyConfiguration, 0, len(info.CollectedObjects)) + for _, obj := range info.CollectedObjects { + stripped := stripResource(obj) + cosObjects = append(cosObjects, *ocv1ac.ClusterObjectSetObject(). + WithObject(stripped). + WithCollisionProtection(ocv1.CollisionProtectionIfNoController)) + } + + phases := PhaseSort(cosObjects) + + // Pack inline objects into Secrets to stay within etcd's size limit (R2.4). + // SecretPacker gzip-compresses large objects and splits across multiple Secrets + // when the combined size would exceed 900 KiB per Secret. + packer := &secretPacker{ + RevisionName: cosName, + OwnerName: opts.ClusterExtensionName, + SystemNamespace: systemNS, + } + packed, err := packer.pack(phases) + if err != nil { + return fmt.Errorf("failed to pack COS objects into Secrets: %w", err) + } + + // Create ref Secrets before the COS so the COS controller can find them immediately. + for i := range packed.Secrets { + secret := &packed.Secrets[i] + if err := m.Client.Create(ctx, secret); err != nil { + return fmt.Errorf("failed to create COS ref Secret %s: %w", secret.Name, err) + } + } + + // Replace inline objects with Secret refs in the phases. + for pos, ref := range packed.Refs { + phaseIdx, objIdx := pos[0], pos[1] + localRef := ref + phases[phaseIdx].Objects[objIdx].Object = nil + phases[phaseIdx].Objects[objIdx].Ref = &ocv1ac.ObjectSourceRefApplyConfiguration{ + Name: &localRef.Name, + Namespace: &localRef.Namespace, + Key: &localRef.Key, + } + } + + cosSpec := ocv1ac.ClusterObjectSetSpec(). + WithRevision(1). + WithCollisionProtection(ocv1.CollisionProtectionIfNoController). + WithLifecycleState(ocv1.ClusterObjectSetLifecycleStateActive). + WithPhases(phases...) + + cosAnnotations := map[string]string{ + MigratedFromSubscriptionAnnotation: fmt.Sprintf("%s/%s", opts.SubscriptionNamespace, opts.SubscriptionName), + LabelPackageName: info.PackageName, + LabelBundleName: info.BundleName, + LabelBundleVersion: info.Version, + } + if info.BundleImage != "" { + cosAnnotations[LabelBundleReference] = info.BundleImage + } + + cos := ocv1ac.ClusterObjectSet(cosName). + WithSpec(cosSpec). + WithLabels(map[string]string{ + LabelOwnerKind: ocv1.ClusterExtensionKind, + LabelOwnerName: opts.ClusterExtensionName, + }). + WithAnnotations(cosAnnotations) + + cosObj := &ocv1.ClusterObjectSet{} + cosObj.Name = cosName + + cosData, err := json.Marshal(cos) + if err != nil { + return fmt.Errorf("failed to marshal COS: %w", err) + } + + if err := m.Client.Patch(ctx, cosObj, client.RawPatch(types.ApplyPatchType, cosData), + client.ForceOwnership, client.FieldOwner(fieldManager)); err != nil { + return fmt.Errorf("failed to apply ClusterObjectSet: %w", err) + } + + return m.WaitForCOSSucceeded(ctx, cosName) +} + +// WaitForCOSSucceeded waits for the COS to reach Succeeded=True. +func (m *Migrator) WaitForCOSSucceeded(ctx context.Context, cosName string) error { + return wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var cos ocv1.ClusterObjectSet + if err := m.Client.Get(ctx, types.NamespacedName{Name: cosName}, &cos); err != nil { + m.progress(fmt.Sprintf("Waiting for COS %s (not found yet)", cosName)) + return false, err + } + + for _, c := range cos.Status.Conditions { + if c.Type == ocv1.ClusterObjectSetTypeSucceeded && c.Status == metav1.ConditionTrue { + return true, nil + } + if c.Type == ocv1.ClusterObjectSetTypeSucceeded && c.Reason == ocv1.ClusterObjectSetReasonBlocked { + return false, fmt.Errorf("ClusterObjectSet %s is blocked: %s", cosName, c.Message) + } + } + + m.progress(fmt.Sprintf("Waiting for ClusterObjectSet %s to reach Succeeded=True...", cosName)) + return false, nil + }) +} + +// CreateClusterExtension creates a CE that adopts the COS (R2.3). +// spec.serviceAccount is NOT set — deprecated and ignored in OLMv1 (R2.5/R7). +// Migration annotations (R2.5) are added for AlreadyMigrated/Conflict detection and rollback. +func (m *Migrator) CreateClusterExtension(ctx context.Context, opts Options, info *MigrationInfo) error { + // Build annotations (R2.5). + annotations := map[string]string{ + MigratedFromSubscriptionAnnotation: fmt.Sprintf("%s/%s", opts.SubscriptionNamespace, opts.SubscriptionName), + } + if info.SubscriptionBackupJSON != "" { + annotations[MigrationSubscriptionBackupAnnotation] = info.SubscriptionBackupJSON + } + if info.OperatorGroupBackupJSON != "" { + annotations[MigrationOperatorGroupBackupAnnotation] = info.OperatorGroupBackupJSON + } + // Record which eligibility-override flags were acknowledged (audit trail). + if opts.AcknowledgeWatchScopeChange { + annotations[AnnotationAcknowledgedPrefix+"watch-scope-change"] = "true" + } + if opts.AcknowledgeOperatorCondition { + annotations[AnnotationAcknowledgedPrefix+"operator-condition"] = "true" + } + if opts.AcknowledgeOLMv0APIAccess { + annotations[AnnotationAcknowledgedPrefix+"olmv0-api-access"] = "true" + } + if opts.AcknowledgeScopedServiceAccount { + annotations[AnnotationAcknowledgedPrefix+"scoped-serviceaccount"] = "true" + } + if opts.AcknowledgeNotSteadyState { + annotations[AnnotationAcknowledgedPrefix+"not-steady-state"] = "true" + } + + ce := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.ClusterExtensionName, + Annotations: annotations, + }, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: opts.InstallNamespace, + // ServiceAccount is deliberately not set — deprecated and ignored in OLMv1. + Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeCatalog, + Catalog: &ocv1.CatalogFilter{ + PackageName: info.PackageName, + }, + }, + }, + } + + // Version pinning: Manual approval → pin to installed version; Automatic → channel-based upgrades. + if info.ManualApproval { + ce.Spec.Source.Catalog.Version = info.Version + } + + // R4: when spec.channel is empty, OLMv0 resolved the defaultChannel from the catalog. + // OLMv1 without channels considers upgrade edges across *all* channels, which may differ. + // Query the resolved ClusterCatalog for the package's declared defaultChannel and set it + // explicitly. Warn if it cannot be determined (R4 spec requirement). + channel := info.Channel + if channel == "" && info.ResolvedCatalogName != "" && m.RESTConfig != nil { + var catalog ocv1.ClusterCatalog + if err := m.Client.Get(ctx, client.ObjectKey{Name: info.ResolvedCatalogName}, &catalog); err == nil { + pkgInfo, qErr := m.QueryCatalogForPackage(ctx, &catalog, info.PackageName, "", "", m.RESTConfig) + if qErr == nil && pkgInfo.DefaultChannel != "" { + channel = pkgInfo.DefaultChannel + m.progress(fmt.Sprintf("Resolved default channel %q for package %q from ClusterCatalog %s", channel, info.PackageName, info.ResolvedCatalogName)) + } else { + m.progress(fmt.Sprintf("Warning: could not determine defaultChannel for package %q — CE will consider all channels; verify upgrade behavior post-migration", info.PackageName)) + } + } + } + if channel != "" { + ce.Spec.Source.Catalog.Channels = []string{channel} + } + + if info.ResolvedCatalogName != "" { + ce.Spec.Source.Catalog.Selector = &metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelMetadataName: info.ResolvedCatalogName, + }, + } + } + + // R4: map spec.config → CE.spec.config.inline.deploymentConfig (R4, R7). + // DeploymentConfig is a type alias of SubscriptionConfig in operator-controller; + // the JSON key must be "deploymentConfig" per the bundle config schema. + if info.SubscriptionConfig != nil { + cfgJSON, err := json.Marshal(info.SubscriptionConfig) + if err != nil { + return fmt.Errorf("failed to marshal SubscriptionConfig for CE: %w", err) + } + inlineJSON, err := json.Marshal(map[string]json.RawMessage{"deploymentConfig": cfgJSON}) + if err != nil { + return fmt.Errorf("failed to marshal CE inline config: %w", err) + } + ce.Spec.Config = &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{Raw: inlineJSON}, + } + } + + if err := m.Client.Create(ctx, ce); err != nil { + return fmt.Errorf("failed to create ClusterExtension: %w", err) + } + + return m.WaitForClusterExtensionInstalled(ctx, opts.ClusterExtensionName) +} + +// WaitForClusterExtensionInstalled waits for the CE to reach Installed=True. +func (m *Migrator) WaitForClusterExtensionInstalled(ctx context.Context, ceName string) error { + return wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var ce ocv1.ClusterExtension + if err := m.Client.Get(ctx, types.NamespacedName{Name: ceName}, &ce); err != nil { + m.progress(fmt.Sprintf("Waiting for CE %s (not found yet)", ceName)) + return false, err + } + + for _, c := range ce.Status.Conditions { + if c.Type == ocv1.TypeInstalled && c.Status == metav1.ConditionTrue { + return true, nil + } + } + + m.progress(fmt.Sprintf("Waiting for ClusterExtension %s to reach Installed=True...", ceName)) + return false, nil + }) +} + +// CleanupAction describes a single cleanup operation and its result. +type CleanupAction struct { + Description string + Succeeded bool + Skipped bool + Error error +} + +// CleanupResult holds the results of all cleanup operations. +type CleanupResult struct { + Actions []CleanupAction +} + +// CleanupOLMv0Resources removes remaining OLMv0 resources after migration. +func (m *Migrator) CleanupOLMv0Resources(ctx context.Context, opts Options, packageName, csvName string) *CleanupResult { + result := &CleanupResult{} + + // 1. Delete the Operator CR + operatorName := fmt.Sprintf("%s.%s", packageName, opts.SubscriptionNamespace) + err := m.deleteOperatorCR(ctx, packageName, opts.SubscriptionNamespace) + result.Actions = append(result.Actions, CleanupAction{ + Description: fmt.Sprintf("Delete Operator CR %s", operatorName), + Succeeded: err == nil, + Error: err, + }) + + // 2. Delete the OperatorCondition + if csvName != "" { + err = m.deleteOperatorCondition(ctx, csvName, opts.SubscriptionNamespace) + result.Actions = append(result.Actions, CleanupAction{ + Description: fmt.Sprintf("Delete OperatorCondition %s/%s", opts.SubscriptionNamespace, csvName), + Succeeded: err == nil, + Error: err, + }) + + // 3. Delete copied CSVs + copiedCount, err := m.deleteCopiedCSVs(ctx, csvName) + if copiedCount > 0 { + result.Actions = append(result.Actions, CleanupAction{ + Description: fmt.Sprintf("Delete %d copied CSV(s)", copiedCount), + Succeeded: err == nil, + Error: err, + }) + } else { + result.Actions = append(result.Actions, CleanupAction{ + Description: "Delete copied CSVs", + Skipped: true, + }) + } + } + + // 4. OperatorGroup cleanup + ogActions := m.cleanupOperatorGroup(ctx, opts) + result.Actions = append(result.Actions, ogActions...) + + return result +} + +func (m *Migrator) deleteCopiedCSVs(ctx context.Context, csvName string) (int, error) { + var csvList operatorsv1alpha1.ClusterServiceVersionList + if err := m.Client.List(ctx, &csvList, + client.MatchingLabels{ + "olm.managed": "true", + "olm.copiedFrom": csvName, + }, + ); err != nil { + return 0, err + } + + deleted := 0 + for i := range csvList.Items { + if err := m.Client.Delete(ctx, &csvList.Items[i], client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil { + if client.IgnoreNotFound(err) != nil { + return deleted, err + } + } + deleted++ + } + return deleted, nil +} + +func (m *Migrator) deleteOperatorCR(ctx context.Context, packageName, namespace string) error { + operatorName := fmt.Sprintf("%s.%s", packageName, namespace) + op := &operatorsv1.Operator{} + op.Name = operatorName + if err := m.Client.Delete(ctx, op); err != nil { + return client.IgnoreNotFound(err) + } + return nil +} + +func (m *Migrator) deleteOperatorCondition(ctx context.Context, csvName, namespace string) error { + oc := &operatorsv1.OperatorCondition{} + oc.Name = csvName + oc.Namespace = namespace + if err := m.Client.Delete(ctx, oc); err != nil { + return client.IgnoreNotFound(err) + } + return nil +} + +// cleanupOperatorGroup deletes the OperatorGroup when both --delete-operatorgroup is set +// AND no other Subscriptions remain in the namespace (R6). +func (m *Migrator) cleanupOperatorGroup(ctx context.Context, opts Options) []CleanupAction { + var actions []CleanupAction + + // Both conditions required per R6: flag must be set AND no remaining Subscriptions. + if !opts.DeleteOperatorGroup { + actions = append(actions, CleanupAction{ + Description: "Delete OperatorGroup (skipped: --delete-operatorgroup not set)", + Skipped: true, + }) + return actions + } + + var subList operatorsv1alpha1.SubscriptionList + if err := m.Client.List(ctx, &subList, client.InNamespace(opts.SubscriptionNamespace)); err != nil { + actions = append(actions, CleanupAction{ + Description: "Check remaining Subscriptions", + Error: err, + }) + return actions + } + + if len(subList.Items) > 0 { + actions = append(actions, CleanupAction{ + Description: fmt.Sprintf("Delete OperatorGroup (skipped: %d Subscription(s) remain)", len(subList.Items)), + Skipped: true, + }) + return actions + } + + var ogList operatorsv1.OperatorGroupList + if err := m.Client.List(ctx, &ogList, client.InNamespace(opts.SubscriptionNamespace)); err != nil { + actions = append(actions, CleanupAction{ + Description: "List OperatorGroups", + Error: err, + }) + return actions + } + + for i := range ogList.Items { + og := &ogList.Items[i] + + stripped := m.stripOGAggregationClusterRoles(ctx, og.Name) + for _, name := range stripped { + actions = append(actions, CleanupAction{ + Description: fmt.Sprintf("Strip OLM labels from aggregation ClusterRole %s", name), + Succeeded: true, + }) + } + + err := m.Client.Delete(ctx, og) + if err != nil && client.IgnoreNotFound(err) != nil { + actions = append(actions, CleanupAction{ + Description: fmt.Sprintf("Delete OperatorGroup %s/%s", og.Namespace, og.Name), + Error: err, + }) + } else { + actions = append(actions, CleanupAction{ + Description: fmt.Sprintf("Delete OperatorGroup %s/%s", og.Namespace, og.Name), + Succeeded: true, + }) + } + } + + return actions +} + +// stripOGAggregationClusterRoles strips olm.owner and olm.managed labels from +// OperatorGroup aggregation ClusterRoles (olm.og..-). +func (m *Migrator) stripOGAggregationClusterRoles(ctx context.Context, ogName string) []string { + prefix := fmt.Sprintf("olm.og.%s.", ogName) + + var crList unstructured.UnstructuredList + crList.SetAPIVersion("rbac.authorization.k8s.io/v1") + crList.SetKind("ClusterRoleList") + + if err := m.Client.List(ctx, &crList); err != nil { + return nil + } + + var stripped []string + for _, cr := range crList.Items { + if !strings.HasPrefix(cr.GetName(), prefix) { + continue + } + + lbls := cr.GetLabels() + if lbls == nil { + continue + } + + changed := false + for _, key := range []string{"olm.owner", "olm.owner.namespace", "olm.owner.kind", "olm.managed"} { + if _, ok := lbls[key]; ok { + delete(lbls, key) + changed = true + } + } + + if changed { + cr.SetLabels(lbls) + if err := m.Client.Update(ctx, &cr); err == nil { + stripped = append(stripped, cr.GetName()) + } + } + } + return stripped +} + +// FindCRDClusterRoles returns CRD-owned ClusterRoles that are not managed by OLMv1. +func (m *Migrator) FindCRDClusterRoles(ctx context.Context, csvName string) []string { + var crList unstructured.UnstructuredList + crList.SetAPIVersion("rbac.authorization.k8s.io/v1") + crList.SetKind("ClusterRoleList") + + if err := m.Client.List(ctx, &crList); err != nil { + return nil + } + + var crdRoles []string + for _, cr := range crList.Items { + name := cr.GetName() + lbls := cr.GetLabels() + if lbls != nil && lbls["olm.owner"] == csvName { + for _, suffix := range []string{"-admin", "-edit", "-view", "-crd"} { + if strings.HasSuffix(name, suffix) { + crdRoles = append(crdRoles, name) + break + } + } + } + } + return crdRoles +} + +// stripResource removes server-side fields from a resource for inclusion in a COS. +func stripResource(obj unstructured.Unstructured) unstructured.Unstructured { + stripped := unstructured.Unstructured{Object: make(map[string]interface{})} + + stripped.SetAPIVersion(obj.GetAPIVersion()) + stripped.SetKind(obj.GetKind()) + stripped.SetName(obj.GetName()) + if obj.GetNamespace() != "" { + stripped.SetNamespace(obj.GetNamespace()) + } + + if lbls := obj.GetLabels(); len(lbls) > 0 { + stripped.SetLabels(lbls) + } + + if annotations := obj.GetAnnotations(); len(annotations) > 0 { + filtered := filterAnnotations(annotations) + if len(filtered) > 0 { + stripped.SetAnnotations(filtered) + } + } + + if spec, ok := obj.Object["spec"]; ok { + stripped.Object["spec"] = spec + stripNestedAnnotations(&stripped) + } + + if data, ok := obj.Object["data"]; ok { + stripped.Object["data"] = data + } + if stringData, ok := obj.Object["stringData"]; ok { + stripped.Object["stringData"] = stringData + } + + if rules, ok := obj.Object["rules"]; ok { + stripped.Object["rules"] = rules + } + + if roleRef, ok := obj.Object["roleRef"]; ok { + stripped.Object["roleRef"] = roleRef + } + if subjects, ok := obj.Object["subjects"]; ok { + stripped.Object["subjects"] = subjects + } + + if webhooks, ok := obj.Object["webhooks"]; ok { + stripped.Object["webhooks"] = webhooks + } + + return stripped +} + +// filterAnnotations removes annotation prefixes that should not be migrated. +func filterAnnotations(annotations map[string]string) map[string]string { + filtered := make(map[string]string) + for k, v := range annotations { + shouldStrip := false + for _, prefix := range annotationPrefixesToStrip { + if strings.HasPrefix(k, prefix) { + shouldStrip = true + break + } + } + if !shouldStrip { + filtered[k] = v + } + } + return filtered +} + +// stripNestedAnnotations removes transient annotations from Deployment pod template metadata. +func stripNestedAnnotations(obj *unstructured.Unstructured) { + templateAnnotations, found, _ := unstructured.NestedMap(obj.Object, "spec", "template", "metadata", "annotations") + if found && templateAnnotations != nil { + filtered := make(map[string]interface{}) + for k, v := range templateAnnotations { + shouldStrip := false + for _, prefix := range annotationPrefixesToStrip { + if strings.HasPrefix(k, prefix) { + shouldStrip = true + break + } + } + if !shouldStrip { + filtered[k] = v + } + } + if len(filtered) > 0 { + _ = unstructured.SetNestedField(obj.Object, filtered, "spec", "template", "metadata", "annotations") + } else { + unstructured.RemoveNestedField(obj.Object, "spec", "template", "metadata", "annotations") + } + } +} diff --git a/migration/pkg/migration/phase.go b/migration/pkg/migration/phase.go new file mode 100644 index 0000000..291e2c2 --- /dev/null +++ b/migration/pkg/migration/phase.go @@ -0,0 +1,195 @@ +package migration + +// PhaseSort logic is adapted from: +// https://github.com/operator-framework/operator-controller/blob/main/internal/operator-controller/applier/phase.go +// which in turn is adapted from: +// https://github.com/package-operator/package-operator/blob/v1.18.2/internal/packages/internal/packagekickstart/presets/phases.go + +import ( + "cmp" + "slices" + + "k8s.io/apimachinery/pkg/runtime/schema" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" +) + +// Phase represents a well-known deployment phase name. +type Phase string + +const ( + PhaseNamespaces Phase = "namespaces" + PhasePolicies Phase = "policies" + PhaseIdentity Phase = "identity" + PhaseConfiguration Phase = "configuration" + PhaseStorage Phase = "storage" + PhaseCRDs Phase = "crds" + PhaseRoles Phase = "roles" + PhaseBindings Phase = "bindings" + PhaseInfrastructure Phase = "infrastructure" + PhaseDeploy Phase = "deploy" + PhaseScaling Phase = "scaling" + PhasePublish Phase = "publish" + PhaseAdmission Phase = "admission" +) + +// defaultPhaseOrder is the ordered list of phases for rollout sequencing. +var defaultPhaseOrder = []Phase{ + PhaseNamespaces, + PhasePolicies, + PhaseIdentity, + PhaseConfiguration, + PhaseStorage, + PhaseCRDs, + PhaseRoles, + PhaseBindings, + PhaseInfrastructure, + PhaseDeploy, + PhaseScaling, + PhasePublish, + PhaseAdmission, +} + +var ( + gkPhaseMap = map[schema.GroupKind]Phase{} + phaseGKMap = map[Phase][]schema.GroupKind{ + PhaseNamespaces: { + {Kind: "Namespace"}, + }, + PhasePolicies: { + {Kind: "NetworkPolicy", Group: "networking.k8s.io"}, + {Kind: "PodDisruptionBudget", Group: "policy"}, + {Kind: "PriorityClass", Group: "scheduling.k8s.io"}, + }, + PhaseIdentity: { + {Kind: "ServiceAccount"}, + }, + PhaseConfiguration: { + {Kind: "Secret"}, + {Kind: "ConfigMap"}, + }, + PhaseStorage: { + {Kind: "PersistentVolume"}, + {Kind: "PersistentVolumeClaim"}, + {Kind: "StorageClass", Group: "storage.k8s.io"}, + }, + PhaseCRDs: { + {Kind: "CustomResourceDefinition", Group: "apiextensions.k8s.io"}, + }, + PhaseRoles: { + {Kind: "ClusterRole", Group: "rbac.authorization.k8s.io"}, + {Kind: "Role", Group: "rbac.authorization.k8s.io"}, + }, + PhaseBindings: { + {Kind: "ClusterRoleBinding", Group: "rbac.authorization.k8s.io"}, + {Kind: "RoleBinding", Group: "rbac.authorization.k8s.io"}, + }, + PhaseInfrastructure: { + {Kind: "Service"}, + {Kind: "Issuer", Group: "cert-manager.io"}, + {Kind: "Certificate", Group: "cert-manager.io"}, + }, + PhaseDeploy: { + {Kind: "Deployment", Group: "apps"}, + }, + PhaseScaling: { + {Kind: "VerticalPodAutoscaler", Group: "autoscaling.k8s.io"}, + }, + PhasePublish: { + {Kind: "PrometheusRule", Group: "monitoring.coreos.com"}, + {Kind: "ServiceMonitor", Group: "monitoring.coreos.com"}, + {Kind: "PodMonitor", Group: "monitoring.coreos.com"}, + {Kind: "Ingress", Group: "networking.k8s.io"}, + {Kind: "Route", Group: "route.openshift.io"}, + {Kind: "ConsoleYAMLSample", Group: "console.openshift.io"}, + {Kind: "ConsoleQuickStart", Group: "console.openshift.io"}, + {Kind: "ConsoleCLIDownload", Group: "console.openshift.io"}, + {Kind: "ConsoleLink", Group: "console.openshift.io"}, + {Kind: "ConsolePlugin", Group: "console.openshift.io"}, + }, + PhaseAdmission: { + {Kind: "ValidatingWebhookConfiguration", Group: "admissionregistration.k8s.io"}, + {Kind: "MutatingWebhookConfiguration", Group: "admissionregistration.k8s.io"}, + }, + } +) + +func init() { + for phase, gks := range phaseGKMap { + for _, gk := range gks { + gkPhaseMap[gk] = phase + } + } +} + +func determinePhase(gk schema.GroupKind) Phase { + phase, ok := gkPhaseMap[gk] + if !ok { + return PhaseDeploy + } + return phase +} + +func compareObjects(a, b ocv1ac.ClusterObjectSetObjectApplyConfiguration) int { + var aGVK, bGVK schema.GroupVersionKind + if a.Object != nil { + aGVK = a.Object.GroupVersionKind() + } + if b.Object != nil { + bGVK = b.Object.GroupVersionKind() + } + var aNs, bNs, aName, bName string + if a.Object != nil { + aNs = a.Object.GetNamespace() + aName = a.Object.GetName() + } + if b.Object != nil { + bNs = b.Object.GetNamespace() + bName = b.Object.GetName() + } + return cmp.Or( + cmp.Compare(aGVK.Group, bGVK.Group), + cmp.Compare(aGVK.Version, bGVK.Version), + cmp.Compare(aGVK.Kind, bGVK.Kind), + cmp.Compare(aNs, bNs), + cmp.Compare(aName, bName), + ) +} + +// PhaseSort takes an unsorted list of objects and organizes them into sorted phases +// for use in a ClusterObjectSet spec. +func PhaseSort(unsortedObjs []ocv1ac.ClusterObjectSetObjectApplyConfiguration) []*ocv1ac.ClusterObjectSetPhaseApplyConfiguration { + phaseMap := make(map[Phase][]ocv1ac.ClusterObjectSetObjectApplyConfiguration) + + for _, obj := range unsortedObjs { + var gk schema.GroupKind + if obj.Object != nil { + gk = obj.Object.GroupVersionKind().GroupKind() + } + phase := determinePhase(gk) + phaseMap[phase] = append(phaseMap[phase], obj) + } + + var phasesSorted []*ocv1ac.ClusterObjectSetPhaseApplyConfiguration + for _, phaseName := range defaultPhaseOrder { + objs, ok := phaseMap[phaseName] + if !ok { + continue + } + slices.SortFunc(objs, compareObjects) + + objPtrs := make([]*ocv1ac.ClusterObjectSetObjectApplyConfiguration, len(objs)) + for i := range objs { + objPtrs[i] = &objs[i] + } + + cp := ocv1.CollisionProtectionIfNoController + phasesSorted = append(phasesSorted, ocv1ac.ClusterObjectSetPhase(). + WithName(string(phaseName)). + WithCollisionProtection(cp). + WithObjects(objPtrs...)) + } + + return phasesSorted +} diff --git a/migration/pkg/migration/readiness.go b/migration/pkg/migration/readiness.go new file mode 100644 index 0000000..9c696d9 --- /dev/null +++ b/migration/pkg/migration/readiness.go @@ -0,0 +1,156 @@ +package migration + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/types" + + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +// CheckReadiness verifies that the cluster is ready for migration. +// It checks Subscription state, CSV health, uniqueness, and dependency status. +func (m *Migrator) CheckReadiness(ctx context.Context, opts Options) (*PreMigrationReport, error) { + report := &PreMigrationReport{} + + var sub operatorsv1alpha1.Subscription + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: opts.SubscriptionName, + Namespace: opts.SubscriptionNamespace, + }, &sub); err != nil { + return nil, fmt.Errorf("failed to get Subscription %s/%s: %w", opts.SubscriptionNamespace, opts.SubscriptionName, err) + } + + // Subscription state (C8 — soft; same flag as CSV health) + if sub.Status.State == operatorsv1alpha1.SubscriptionStateAtLatest || + sub.Status.State == operatorsv1alpha1.SubscriptionStateUpgradePending { + report.Checks = append(report.Checks, CheckResult{ + Name: "Subscription state", + Passed: true, + Message: fmt.Sprintf("state is %q", sub.Status.State), + }) + } else if opts.AcknowledgeNotSteadyState { + report.Checks = append(report.Checks, CheckResult{ + Name: "Subscription state", + Passed: true, + Message: fmt.Sprintf("overridden: Subscription state is %q (not steady state acknowledged)", sub.Status.State), + }) + } else { + report.Checks = append(report.Checks, CheckResult{ + Name: "Subscription state", + Passed: false, + Message: fmt.Sprintf("must be %q or %q, got %q; pass --acknowledge-not-steady-state to override", operatorsv1alpha1.SubscriptionStateAtLatest, operatorsv1alpha1.SubscriptionStateUpgradePending, sub.Status.State), + }) + } + + // installedCSV + if sub.Status.InstalledCSV != "" { + report.Checks = append(report.Checks, CheckResult{ + Name: "Installed CSV", + Passed: true, + Message: sub.Status.InstalledCSV, + }) + } else { + report.Checks = append(report.Checks, CheckResult{ + Name: "Installed CSV", + Passed: false, + Message: "no installedCSV set", + }) + } + + // olm.generated-by — auto-generated dependency Subscriptions must not be individually migrated + if _, ok := sub.Annotations["olm.generated-by"]; ok { + report.Checks = append(report.Checks, CheckResult{ + Name: "Not a dependency", + Passed: false, + Message: "olm.generated-by annotation present — operator is an OLMv0-managed dependency of another operator; do not migrate individually", + }) + } else { + report.Checks = append(report.Checks, CheckResult{ + Name: "Not a dependency", + Passed: true, + Message: "no olm.generated-by annotation", + }) + } + + // Uniqueness — no other Subscription should reference the same package + var subList operatorsv1alpha1.SubscriptionList + if err := m.Client.List(ctx, &subList); err != nil { + return nil, fmt.Errorf("failed to list Subscriptions: %w", err) + } + duplicate := false + for _, other := range subList.Items { + if other.Name == sub.Name && other.Namespace == sub.Namespace { + continue + } + if other.Spec.Package == sub.Spec.Package { + report.Checks = append(report.Checks, CheckResult{ + Name: "Package uniqueness", + Passed: false, + Message: fmt.Sprintf("another Subscription %s/%s references the same package %q", other.Namespace, other.Name, sub.Spec.Package), + }) + duplicate = true + break + } + } + if !duplicate { + report.Checks = append(report.Checks, CheckResult{ + Name: "Package uniqueness", + Passed: true, + Message: fmt.Sprintf("no other Subscription references package %q", sub.Spec.Package), + }) + } + + // CSV phase and reason + if sub.Status.InstalledCSV != "" { //nolint:nestif + csvName := sub.Status.InstalledCSV + var csv operatorsv1alpha1.ClusterServiceVersion + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: csvName, + Namespace: opts.SubscriptionNamespace, + }, &csv); err != nil { + report.Checks = append(report.Checks, CheckResult{ + Name: "CSV health", + Passed: false, + Message: fmt.Sprintf("failed to get CSV %s: %v", csvName, err), + }) + } else if csv.Status.Phase != operatorsv1alpha1.CSVPhaseSucceeded { + if opts.AcknowledgeNotSteadyState { + report.Checks = append(report.Checks, CheckResult{ + Name: "CSV health", + Passed: true, + Message: fmt.Sprintf("overridden: phase is %q (not at steady state acknowledged)", csv.Status.Phase), + }) + } else { + report.Checks = append(report.Checks, CheckResult{ + Name: "CSV health", + Passed: false, + Message: fmt.Sprintf("phase is %q, expected %q", csv.Status.Phase, operatorsv1alpha1.CSVPhaseSucceeded), + }) + } + } else if csv.Status.Reason != operatorsv1alpha1.CSVReasonInstallSuccessful { + if opts.AcknowledgeNotSteadyState { + report.Checks = append(report.Checks, CheckResult{ + Name: "CSV health", + Passed: true, + Message: fmt.Sprintf("overridden: reason is %q (not at steady state acknowledged)", csv.Status.Reason), + }) + } else { + report.Checks = append(report.Checks, CheckResult{ + Name: "CSV health", + Passed: false, + Message: fmt.Sprintf("reason is %q, expected %q", csv.Status.Reason, operatorsv1alpha1.CSVReasonInstallSuccessful), + }) + } + } else { + report.Checks = append(report.Checks, CheckResult{ + Name: "CSV health", + Passed: true, + Message: fmt.Sprintf("phase: %s, reason: %s", csv.Status.Phase, csv.Status.Reason), + }) + } + } + + return report, nil +} diff --git a/migration/pkg/migration/scan.go b/migration/pkg/migration/scan.go new file mode 100644 index 0000000..8a51d89 --- /dev/null +++ b/migration/pkg/migration/scan.go @@ -0,0 +1,538 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// OperatorScanResult holds the result of scanning a single Subscription for migration eligibility. +type OperatorScanResult struct { + SubscriptionName string + SubscriptionNamespace string + PackageName string + InstalledCSV string + Version string + State string + Status OperatorStatus // four-state classification + Reason string // human-readable explanation of the status (R1.3) + Eligible bool // true when Status == Eligible (backwards compat) + // Warnings are informational notices that do not affect eligibility (R9). + // Example: other installed operators declare a dependency on this package. + Warnings []string + Error error + FailedChecks []CheckResult +} + +// ScanAllSubscriptions discovers all Subscriptions on the cluster, checks each for migration +// eligibility, and also detects AlreadyMigrated and Conflict states from ClusterExtensions. +func (m *Migrator) ScanAllSubscriptions(ctx context.Context) ([]OperatorScanResult, error) { + // List all Subscriptions + var subList operatorsv1alpha1.SubscriptionList + if err := m.Client.List(ctx, &subList); err != nil { + return nil, fmt.Errorf("failed to list Subscriptions: %w", err) + } + + // List all ClusterExtensions with migrated-from-subscription annotation + var ceList ocv1.ClusterExtensionList + if err := m.Client.List(ctx, &ceList); err != nil { + return nil, fmt.Errorf("failed to list ClusterExtensions: %w", err) + } + + // Build a map of migration-annotated CEs: "/" -> CE name + migratedCEBySubRef := make(map[string]string) + for _, ce := range ceList.Items { + if ref, ok := ce.Annotations[MigratedFromSubscriptionAnnotation]; ok { + migratedCEBySubRef[ref] = ce.Name + } + } + + // Build a set of Subscription refs that currently exist + existingSubs := make(map[string]bool) + for _, sub := range subList.Items { + existingSubs[fmt.Sprintf("%s/%s", sub.Namespace, sub.Name)] = true + } + + var results []OperatorScanResult + + // Check each existing Subscription + for _, sub := range subList.Items { + subRef := fmt.Sprintf("%s/%s", sub.Namespace, sub.Name) + + result := OperatorScanResult{ + SubscriptionName: sub.Name, + SubscriptionNamespace: sub.Namespace, + PackageName: sub.Spec.Package, + InstalledCSV: sub.Status.InstalledCSV, + State: string(sub.Status.State), + } + + // Conflict: both Subscription and annotated CE exist + if _, hasCE := migratedCEBySubRef[subRef]; hasCE { + result.Status = OperatorStatusConflict + result.Reason = "both Subscription and annotated ClusterExtension exist; resolve with cleanup or rollback" + result.Eligible = false + result.Error = fmt.Errorf("%s", result.Reason) + results = append(results, result) + continue + } + + opts := Options{ + SubscriptionName: sub.Name, + SubscriptionNamespace: sub.Namespace, + } + opts.ApplyDefaults() + + m.progress(fmt.Sprintf("Checking %s/%s (%s)...", sub.Namespace, sub.Name, sub.Spec.Package)) + + // Readiness checks + readiness, err := m.CheckReadiness(ctx, opts) + if err != nil { + result.Status = OperatorStatusIneligible + result.Reason = err.Error() + result.Error = err + results = append(results, result) + continue + } + + // Get CSV for compatibility checks + _, csv, _, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + result.Status = OperatorStatusIneligible + result.Reason = fmt.Sprintf("failed to get CSV: %v", err) + result.Error = fmt.Errorf("failed to get CSV: %w", err) + results = append(results, result) + continue + } + + result.Version = parseCSVVersion(csv) + + // Compatibility checks + propsJSON := csv.Annotations["operatorframework.io/properties"] + compat, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + result.Status = OperatorStatusIneligible + result.Reason = fmt.Sprintf("compatibility check error: %v", err) + result.Error = fmt.Errorf("compatibility check error: %w", err) + results = append(results, result) + continue + } + + // Merge readiness + compat failed checks + result.FailedChecks = append(readiness.FailedChecks(), compat.FailedChecks()...) + + // C7: catalog availability (hard check — no override). + // Only run when readiness+compat pass to avoid noisy catalog errors for clearly ineligible operators. + if len(result.FailedChecks) == 0 { //nolint:nestif + catalogName, catalogErr := m.ResolveClusterCatalog(ctx, &MigrationInfo{ + PackageName: sub.Spec.Package, + Channel: sub.Spec.Channel, + Version: result.Version, + }, m.RESTConfig) + if catalogErr != nil { + result.FailedChecks = append(result.FailedChecks, CheckResult{ + Name: "Catalog availability", + Passed: false, + Message: fmt.Sprintf("package not found in any serving ClusterCatalog; run migrate-catalogs-v0-to-v1 first: %v", catalogErr), + }) + result.Status = OperatorStatusIneligible + result.Reason = fmt.Sprintf("package %q not found in any serving ClusterCatalog", sub.Spec.Package) + result.Eligible = false + } else { + result.FailedChecks = append(result.FailedChecks, CheckResult{ + Name: "Catalog availability", + Passed: true, + Message: fmt.Sprintf("package available in ClusterCatalog %s", catalogName), + }) + result.Status = OperatorStatusEligible + result.Reason = "passes all readiness, compatibility, and catalog-availability checks" + result.Eligible = true + // R9: warn if other installed operators declare a dependency on this package. + if dependents := m.findDependents(ctx, sub.Spec.Package); len(dependents) > 0 { + result.Warnings = append(result.Warnings, + fmt.Sprintf("other operator(s) may depend on package %q: %v — verify they remain functional after migration", sub.Spec.Package, dependents)) + } + } + } else { + result.Status = OperatorStatusIneligible + result.Reason = fmt.Sprintf("%d check(s) failed", len(result.FailedChecks)) + result.Eligible = false + } + results = append(results, result) + } + + // Check for AlreadyMigrated: CE with annotation but no matching Subscription + for subRef, ceName := range migratedCEBySubRef { + if existingSubs[subRef] { + continue // handled above as Conflict or normal sub + } + results = append(results, OperatorScanResult{ + SubscriptionName: ceName, + Status: OperatorStatusAlreadyMigrated, + Reason: fmt.Sprintf("ClusterExtension %s exists with migrated-from-subscription annotation; Subscription is gone", ceName), + Eligible: false, + State: fmt.Sprintf("ClusterExtension %s (migrated from %s)", ceName, subRef), + }) + } + + return results, nil +} + +// ScanSubscription checks a single Subscription and returns its scan result. +func (m *Migrator) ScanSubscription(ctx context.Context, opts Options) (*OperatorScanResult, error) { + opts.ApplyDefaults() + + result := &OperatorScanResult{ + SubscriptionName: opts.SubscriptionName, + SubscriptionNamespace: opts.SubscriptionNamespace, + } + + // Check for Conflict first + var ceList ocv1.ClusterExtensionList + if err := m.Client.List(ctx, &ceList); err != nil { + return nil, fmt.Errorf("failed to list ClusterExtensions: %w", err) + } + subRef := fmt.Sprintf("%s/%s", opts.SubscriptionNamespace, opts.SubscriptionName) + for _, ce := range ceList.Items { + if ref, ok := ce.Annotations[MigratedFromSubscriptionAnnotation]; ok && ref == subRef { + result.Status = OperatorStatusConflict + result.Reason = fmt.Sprintf("both Subscription and annotated ClusterExtension %s exist; resolve with cleanup or rollback", ce.Name) + result.Error = fmt.Errorf("%s", result.Reason) + return result, nil + } + } + + readiness, err := m.CheckReadiness(ctx, opts) + if err != nil { + return nil, err + } + + sub, csv, _, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + result.Status = OperatorStatusIneligible + result.Reason = err.Error() + result.Error = err + return result, nil + } + + result.PackageName = sub.Spec.Package + result.InstalledCSV = csv.Name + result.Version = parseCSVVersion(csv) + + propsJSON := csv.Annotations["operatorframework.io/properties"] + compat, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + result.Status = OperatorStatusIneligible + result.Reason = err.Error() + result.Error = err + return result, nil + } + + result.FailedChecks = append(readiness.FailedChecks(), compat.FailedChecks()...) + if len(result.FailedChecks) > 0 { + result.Status = OperatorStatusIneligible + result.Reason = fmt.Sprintf("%d check(s) failed", len(result.FailedChecks)) + return result, nil + } + + // C7: catalog availability (hard check — no override) + catalogName, catalogErr := m.ResolveClusterCatalog(ctx, &MigrationInfo{ + PackageName: result.PackageName, + Channel: sub.Spec.Channel, + Version: result.Version, + }, m.RESTConfig) + if catalogErr != nil { + result.FailedChecks = append(result.FailedChecks, CheckResult{ + Name: "Catalog availability", + Passed: false, + Message: fmt.Sprintf("package not found in any serving ClusterCatalog; run migrate-catalogs-v0-to-v1 first: %v", catalogErr), + }) + result.Status = OperatorStatusIneligible + result.Reason = fmt.Sprintf("package %q not found in any serving ClusterCatalog", result.PackageName) + return result, nil + } + + result.FailedChecks = append(result.FailedChecks, CheckResult{ + Name: "Catalog availability", + Passed: true, + Message: fmt.Sprintf("package available in ClusterCatalog %s", catalogName), + }) + result.Status = OperatorStatusEligible + result.Reason = "passes all readiness, compatibility, and catalog-availability checks" + result.Eligible = true + // R9: warn if other installed operators declare a dependency on this package. + if dependents := m.findDependents(ctx, result.PackageName); len(dependents) > 0 { + result.Warnings = append(result.Warnings, + fmt.Sprintf("other operator(s) may depend on package %q: %v — verify they remain functional after migration", result.PackageName, dependents)) + } + return result, nil +} + +// PrintScanSummary prints results in the required order: Conflict → Ineligible → AlreadyMigrated → Eligible. +func PrintScanSummary(results []OperatorScanResult, printf func(string, ...interface{})) { + byStatus := make(map[OperatorStatus][]OperatorScanResult) + for _, r := range results { + byStatus[r.Status] = append(byStatus[r.Status], r) + } + + order := []OperatorStatus{ + OperatorStatusConflict, + OperatorStatusIneligible, + OperatorStatusAlreadyMigrated, + OperatorStatusEligible, + } + + for _, status := range order { + list := byStatus[status] + if len(list) == 0 { + continue + } + printf("\n=== %s (%d) ===\n", status, len(list)) + for _, r := range list { + switch status { + case OperatorStatusConflict: + printf(" ⚠️ %s/%s — CONFLICT: %v\n", r.SubscriptionNamespace, r.SubscriptionName, r.Error) + case OperatorStatusIneligible: + printf(" ✗ %s/%s", r.SubscriptionNamespace, r.SubscriptionName) + for _, fc := range r.FailedChecks { + printf("\n [%s] %s", fc.Name, fc.Message) + } + if r.Error != nil { + printf("\n error: %v", r.Error) + } + printf("\n") + case OperatorStatusAlreadyMigrated: + printf(" ✓ %s (already migrated)\n", r.State) + case OperatorStatusEligible: + printf(" ✓ %s/%s (%s)\n", r.SubscriptionNamespace, r.SubscriptionName, r.PackageName) + } + } + } +} + +// EligibleFromScan returns only the Eligible results from a scan. +func EligibleFromScan(results []OperatorScanResult) []OperatorScanResult { + var eligible []OperatorScanResult + for _, r := range results { + if r.Status == OperatorStatusEligible { + eligible = append(eligible, r) + } + } + return eligible +} + +// RollbackClusterExtension deletes the CE and COS (orphan cascade), then restores the Subscription. +func (m *Migrator) RollbackClusterExtension(ctx context.Context, ceName string, acknowledgeInstalled bool) error { + var ce ocv1.ClusterExtension + if err := m.Client.Get(ctx, client.ObjectKey{Name: ceName}, &ce); err != nil { + return fmt.Errorf("failed to get ClusterExtension %s: %w", ceName, err) + } + + // Check if Installed=True and require acknowledgment + if !acknowledgeInstalled { + for _, cond := range ce.Status.Conditions { + if cond.Type == "Installed" && cond.Status == "True" { + return fmt.Errorf("ClusterExtension %s is Installed=True; pass --acknowledge-installed to confirm rollback", ceName) + } + } + } + + // Delete CE (orphan cascade — preserves operator workloads) + if err := m.Client.Delete(ctx, &ce, client.PropagationPolicy("Orphan")); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete ClusterExtension: %w", err) + } + } + + // Delete COS (orphan cascade) + cosName := fmt.Sprintf("%s-1", ceName) + var cos ocv1.ClusterObjectSet + if err := m.Client.Get(ctx, client.ObjectKey{Name: cosName}, &cos); err == nil { + if err := m.Client.Delete(ctx, &cos, client.PropagationPolicy("Orphan")); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete ClusterObjectSet: %w", err) + } + } + } + + // Restore Subscription from backup annotation + subBackupJSON, ok := ce.Annotations["olm.operatorframework.io/migration-subscription-backup"] + if !ok || subBackupJSON == "" { + return fmt.Errorf("ClusterExtension %s has no migration-subscription-backup annotation; cannot restore Subscription", ceName) + } + + subRef := ce.Annotations[MigratedFromSubscriptionAnnotation] + if subRef == "" { + return fmt.Errorf("ClusterExtension %s has no migrated-from-subscription annotation", ceName) + } + + // Restore Subscription + var subSpec operatorsv1alpha1.SubscriptionSpec + if err := unmarshalJSON(subBackupJSON, &subSpec); err != nil { + return fmt.Errorf("failed to unmarshal subscription backup: %w", err) + } + + ns, name, err := splitNamespacedName(subRef) + if err != nil { + return fmt.Errorf("invalid migrated-from-subscription annotation %q: %w", subRef, err) + } + + restoredSub := &operatorsv1alpha1.Subscription{} + restoredSub.Name = name + restoredSub.Namespace = ns + restoredSub.Spec = &subSpec + + if err := m.Client.Create(ctx, restoredSub); err != nil { + return fmt.Errorf("failed to restore Subscription %s/%s: %w", ns, name, err) + } + + m.progress(fmt.Sprintf("Subscription %s/%s restored; operator returning to OLMv0 management", ns, name)) + return nil +} + +// CleanupConflict resolves a Conflict state: deletes the Subscription and OLMv0 artifacts, +// leaving the ClusterExtension intact. +func (m *Migrator) CleanupConflict(ctx context.Context, ceName string) error { + var ce ocv1.ClusterExtension + if err := m.Client.Get(ctx, client.ObjectKey{Name: ceName}, &ce); err != nil { + return fmt.Errorf("failed to get ClusterExtension %s: %w", ceName, err) + } + + subRef := ce.Annotations[MigratedFromSubscriptionAnnotation] + if subRef == "" { + return fmt.Errorf("ClusterExtension %s has no migrated-from-subscription annotation", ceName) + } + + ns, name, err := splitNamespacedName(subRef) + if err != nil { + return fmt.Errorf("invalid migrated-from-subscription annotation %q: %w", subRef, err) + } + + // Delete Subscription (orphan) + sub := &operatorsv1alpha1.Subscription{} + sub.Name = name + sub.Namespace = ns + if err := m.Client.Delete(ctx, sub, client.PropagationPolicy("Orphan")); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete Subscription %s/%s: %w", ns, name, err) + } + } + m.progress(fmt.Sprintf("Deleted Subscription %s/%s", ns, name)) + + // Cleanup remaining OLMv0 resources + opts := Options{ + SubscriptionName: name, + SubscriptionNamespace: ns, + ClusterExtensionName: ceName, + InstallNamespace: ns, + } + + // Try to get package name from CE annotations + packageName := ce.Spec.Source.Catalog.PackageName + csvName := "" // best effort + m.CleanupOLMv0Resources(ctx, opts, packageName, csvName) + + return nil +} + +// splitNamespacedName splits "namespace/name" into its components. +func splitNamespacedName(ref string) (string, string, error) { + for i, c := range ref { + if c == '/' { + return ref[:i], ref[i+1:], nil + } + } + return "", "", fmt.Errorf("expected namespace/name format, got %q", ref) +} + +func unmarshalJSON(data string, v interface{}) error { + return json.Unmarshal([]byte(data), v) +} + +// ── Canonical R1.1 library API ──────────────────────────────────────────────── + +// ScanAll classifies all OLMv0 Subscriptions into the four states (R1.1), +// including catalog-availability (C7) per operator. +func (m *Migrator) ScanAll(ctx context.Context) ([]OperatorScanResult, error) { + return m.ScanAllSubscriptions(ctx) +} + +// Check runs all readiness, compatibility, and catalog-availability checks for +// one operator without mutating the cluster (R1.1). +func (m *Migrator) Check(ctx context.Context, opts Options) (*OperatorScanResult, error) { + opts.ApplyDefaults() + return m.ScanSubscription(ctx, opts) +} + +// findDependents returns the names of installed operators (Subscription names) whose +// bundle properties declare an olm.package.required dependency on packageName (R9). +// The spec requires a warning — not a block — when migrating an operator others depend on. +func (m *Migrator) findDependents(ctx context.Context, packageName string) []string { + var subList operatorsv1alpha1.SubscriptionList + if err := m.Client.List(ctx, &subList); err != nil { + return nil + } + + var dependents []string + for _, sub := range subList.Items { + if sub.Spec.Package == packageName { + continue // skip the operator itself + } + if sub.Status.InstalledCSV == "" { + continue + } + var csv operatorsv1alpha1.ClusterServiceVersion + if err := m.Client.Get(ctx, client.ObjectKey{ + Name: sub.Status.InstalledCSV, + Namespace: sub.Namespace, + }, &csv); err != nil { + continue + } + propsJSON := csv.Annotations["operatorframework.io/properties"] + if propsJSON == "" { + continue + } + props, err := parseProperties(propsJSON) + if err != nil { + continue + } + for _, p := range props { + if p.Type == "olm.package.required" { + var req struct { + PackageName string `json:"packageName"` + } + if err := json.Unmarshal(p.Value, &req); err == nil && req.PackageName == packageName { + dependents = append(dependents, fmt.Sprintf("%s/%s", sub.Namespace, sub.Name)) + break + } + } + } + } + return dependents +} + +// Gather collects and returns everything that would be migrated without making +// any cluster mutations — backs the CLI convert --dry-run (R1.1). +func (m *Migrator) Gather(ctx context.Context, opts Options) (*MigrationInfo, error) { + opts.ApplyDefaults() + return m.GatherMigrationInfo(ctx, opts) +} + +// Rollback restores an operator to OLMv0 management (R1.1). +// opts.AcknowledgeInstalled must be true when the CE is Installed=True. +func (m *Migrator) Rollback(ctx context.Context, opts Options) error { + opts.ApplyDefaults() + return m.RollbackClusterExtension(ctx, opts.ClusterExtensionName, opts.AcknowledgeInstalled) +} + +// Cleanup finishes a partial migration in Conflict state by deleting the +// Subscription and OLMv0 artifacts, leaving the CE intact (R1.1). +func (m *Migrator) Cleanup(ctx context.Context, opts Options) error { + opts.ApplyDefaults() + return m.CleanupConflict(ctx, opts.ClusterExtensionName) +} diff --git a/migration/pkg/migration/secretpacker.go b/migration/pkg/migration/secretpacker.go new file mode 100644 index 0000000..edf47e8 --- /dev/null +++ b/migration/pkg/migration/secretpacker.go @@ -0,0 +1,182 @@ +package migration + +// SecretPacker packs serialized objects from COS phases into one or more immutable +// Secrets so that large bundles do not exceed Kubernetes etcd's size limit. +// +// Ported from operator-controller internal/operator-controller/applier/secretpacker.go. +// Objects are gzip-compressed when they exceed gzipThreshold, and a new Secret is +// started whenever the current batch would exceed maxSecretDataSize. + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" +) + +const ( + // maxSecretDataSize is the target maximum for Secret .data size before starting a + // new Secret. 900 KiB leaves headroom for base64 overhead within etcd's 1.5 MiB limit. + maxSecretDataSize = 900 * 1024 + + // gzipThreshold is the object size above which individual objects are compressed before storage. + gzipThreshold = 900 * 1024 +) + +// secretPacker packs serialized COS phase objects into immutable Secrets. +type secretPacker struct { + // RevisionName is the COS name — used to derive Secret names. + RevisionName string + // OwnerName is the CE name — recorded as a label on each Secret. + OwnerName string + // SystemNamespace is where Secrets are created (e.g. "olmv1-system"). + SystemNamespace string +} + +// packResult holds the packed Secrets and the ref entries that replace inline objects. +type packResult struct { + // Secrets to be created before the COS. + Secrets []corev1.Secret + // Refs maps (phaseIndex, objectIndex) to the ObjectSourceRef that replaces the inline object. + Refs map[[2]int]ocv1.ObjectSourceRef +} + +// pack takes COS phases with inline objects and produces: +// 1. A set of immutable Secrets containing the serialized objects. +// 2. A mapping from (phaseIdx, objIdx) to the corresponding ObjectSourceRef. +func (p *secretPacker) pack(phases []*ocv1ac.ClusterObjectSetPhaseApplyConfiguration) (*packResult, error) { + result := &packResult{ + Refs: make(map[[2]int]ocv1.ObjectSourceRef), + } + + type pendingRef struct { + pos [2]int + key string + } + + var ( + currentData = make(map[string][]byte) + currentSize int + currentPending []pendingRef + ) + + finalizeCurrent := func() { + if len(currentData) == 0 { + return + } + secret := p.newSecret(currentData) + for _, pr := range currentPending { + result.Refs[pr.pos] = ocv1.ObjectSourceRef{ + Name: secret.Name, + Namespace: p.SystemNamespace, + Key: pr.key, + } + } + result.Secrets = append(result.Secrets, secret) + currentData = make(map[string][]byte) + currentSize = 0 + currentPending = nil + } + + for phaseIdx, phase := range phases { + for objIdx, obj := range phase.Objects { + if obj.Object == nil { + continue // already a ref + } + + data, err := json.Marshal(obj.Object) + if err != nil { + return nil, fmt.Errorf("serializing object in phase %d index %d: %w", phaseIdx, objIdx, err) + } + + if len(data) > gzipThreshold { + compressed, cErr := gzipData(data) + if cErr != nil { + return nil, fmt.Errorf("compressing object in phase %d index %d: %w", phaseIdx, objIdx, cErr) + } + data = compressed + } + + if len(data) > maxSecretDataSize { + return nil, fmt.Errorf( + "object in phase %d index %d exceeds maximum Secret data size (%d bytes > %d bytes) even after compression", + phaseIdx, objIdx, len(data), maxSecretDataSize, + ) + } + + key := contentHash(data) + + if _, exists := currentData[key]; !exists { + if currentSize+len(data) > maxSecretDataSize && len(currentData) > 0 { + finalizeCurrent() + } + currentData[key] = data + currentSize += len(data) + } + currentPending = append(currentPending, pendingRef{pos: [2]int{phaseIdx, objIdx}, key: key}) + } + } + finalizeCurrent() + + return result, nil +} + +func (p *secretPacker) newSecret(data map[string][]byte) corev1.Secret { + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: p.secretNameFromData(data), + Namespace: p.SystemNamespace, + Labels: map[string]string{ + LabelRevisionName: p.RevisionName, + LabelOwnerName: p.OwnerName, + }, + }, + Immutable: ptr.To(true), + Type: corev1.SecretType(SecretTypeObjectData), //nolint:gosec // G101 false positive + Data: data, + } +} + +func (p *secretPacker) secretNameFromData(data map[string][]byte) string { + h := sha256.New() + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + h.Write([]byte(k)) + h.Write(data[k]) + } + return fmt.Sprintf("%s-%x", p.RevisionName, h.Sum(nil)[:8]) +} + +func contentHash(data []byte) string { + h := sha256.Sum256(data) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func gzipData(data []byte) ([]byte, error) { + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return nil, err + } + if _, err := w.Write(data); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/migration/pkg/migration/types.go b/migration/pkg/migration/types.go new file mode 100644 index 0000000..b80f55f --- /dev/null +++ b/migration/pkg/migration/types.go @@ -0,0 +1,163 @@ +package migration + +import ( + "fmt" + "os" + "path/filepath" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + operatorsv1 "github.com/operator-framework/api/pkg/operators/v1" + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +// OperatorStatus is the four-state classification of a Subscription's migration readiness. +type OperatorStatus string + +const ( + OperatorStatusEligible OperatorStatus = "Eligible" + OperatorStatusIneligible OperatorStatus = "Ineligible" + OperatorStatusAlreadyMigrated OperatorStatus = "AlreadyMigrated" + OperatorStatusConflict OperatorStatus = "Conflict" +) + +// Options configures the migration process. +type Options struct { + SubscriptionName string + SubscriptionNamespace string + ClusterExtensionName string + InstallNamespace string + + // BackupDirectory, when non-empty, writes OLM objects to disk before deletions (R2.6). + BackupDirectory string + + // Soft eligibility override flags (R3). Setting a flag records an + // acknowledged-:"true" annotation on the CE for audit (R2.5). + AcknowledgeWatchScopeChange bool // C1 + AcknowledgeOperatorCondition bool // C4 + AcknowledgeOLMv0APIAccess bool // C5 + AcknowledgeScopedServiceAccount bool // C6 + AcknowledgeNotSteadyState bool // C8 + + // AcknowledgeInstalled is required for Rollback when the CE is Installed=True. + AcknowledgeInstalled bool + + // DeleteOperatorGroup deletes the OperatorGroup when both this flag is set AND + // no other Subscriptions remain in the namespace (R6). + DeleteOperatorGroup bool + + // SystemNamespace is the namespace where COS ref Secrets are created (R2.4). + // Defaults to "olmv1-system" when empty. + SystemNamespace string +} + +// systemNamespace returns the effective system namespace. +func (o Options) systemNamespace() string { + if o.SystemNamespace != "" { + return o.SystemNamespace + } + return "olmv1-system" +} + +// ApplyDefaults fills in default values for any unset optional fields. +func (o *Options) ApplyDefaults() { + if o.ClusterExtensionName == "" { + o.ClusterExtensionName = o.SubscriptionName + } + if o.InstallNamespace == "" { + o.InstallNamespace = o.SubscriptionNamespace + } +} + +// MigrationInfo holds the profiled operator information gathered during the migration. +type MigrationInfo struct { + PackageName string + Version string + BundleName string + BundleImage string + Channel string + ManualApproval bool // true if the Subscription had Manual install plan approval + CatalogSourceRef types.NamespacedName + CatalogSourceImage string // tag-based image from CatalogSource.Spec.Image + ResolvedCatalogName string + CollectedObjects []unstructured.Unstructured + + // SubscriptionConfig holds spec.config from the Subscription for mapping to CE (R4). + // spec.config.selector is dropped (never honored in OLMv0; no CE equivalent). + SubscriptionConfig *operatorsv1alpha1.SubscriptionConfig + + // Subscription spec JSON for the CE migration-subscription-backup annotation (R2.5). + SubscriptionBackupJSON string + // OperatorGroup spec JSON for the CE migration-operatorgroup-backup annotation (R2.5). + OperatorGroupBackupJSON string +} + +// ProgressFunc is called periodically during wait operations to report status. +type ProgressFunc func(message string) + +// Migrator performs the migration operations using a controller-runtime client. +type Migrator struct { + Client client.Client + RESTConfig *rest.Config + Progress ProgressFunc +} + +// NewMigrator creates a new Migrator with the given client and REST config. +func NewMigrator(c client.Client, cfg *rest.Config) *Migrator { + return &Migrator{Client: c, RESTConfig: cfg} +} + +func (m *Migrator) progress(msg string) { + if m.Progress != nil { + m.Progress(msg) + } +} + +// Backup holds serialized copies of OLMv0 resources for recovery and auditing. +type Backup struct { + Subscription *operatorsv1alpha1.Subscription + ClusterServiceVersion *operatorsv1alpha1.ClusterServiceVersion + OperatorGroup *operatorsv1.OperatorGroup + InstallPlan *operatorsv1alpha1.InstallPlan +} + +// SaveToDisk writes backup files to dir, creating it if absent. Per R2.6, +// failures here are non-fatal — the CE annotation backup is the authoritative path. +func (b *Backup) SaveToDisk(dir string) error { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + if err := writeYAMLFile(filepath.Join(dir, "subscription.yaml"), b.Subscription); err != nil { + return fmt.Errorf("failed to write subscription.yaml: %w", err) + } + if b.OperatorGroup != nil { + if err := writeYAMLFile(filepath.Join(dir, "operatorgroup.yaml"), b.OperatorGroup); err != nil { + return fmt.Errorf("failed to write operatorgroup.yaml: %w", err) + } + } + if err := writeYAMLFile(filepath.Join(dir, "clusterserviceversion.yaml"), b.ClusterServiceVersion); err != nil { + return fmt.Errorf("failed to write clusterserviceversion.yaml: %w", err) + } + if b.InstallPlan != nil { + ipDir := filepath.Join(dir, "installplans") + if err := os.MkdirAll(ipDir, 0o750); err != nil { + return fmt.Errorf("failed to create installplans directory: %w", err) + } + if err := writeYAMLFile(filepath.Join(ipDir, b.InstallPlan.Name+".yaml"), b.InstallPlan); err != nil { + return fmt.Errorf("failed to write installplan: %w", err) + } + } + return nil +} + +func writeYAMLFile(path string, obj interface{}) error { + data, err := yaml.Marshal(obj) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +}