From 8b253540c1e3d3e737ee1dcf5932a2b9aa71561f Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Thu, 19 Mar 2026 14:35:09 +0100 Subject: [PATCH] Add OLMv0-to-OLMv1 migration CLI and library Add a CLI tool (hack/tools/migrate/) and supporting migration library (internal/operator-controller/migration/) to migrate operators managed by OLMv0 (Subscription/CSV) to OLMv1 (ClusterExtension/ClusterExtensionRevision). CLI subcommands: - migrate (root): Full interactive migration of a single operator - migrate all: Scan cluster, check eligibility, and migrate all eligible operators - migrate check: Run readiness/compatibility checks without modifying resources - migrate gather: Collect and display migration info (dry-run) Migration library capabilities: - Operator profiling: read Subscription, CSV, InstallPlan state - Readiness checks: Subscription state, CSV health, uniqueness, dependencies - Compatibility checks: AllNamespaces mode, dependencies, APIServices, OperatorConditions - Resource collection: 4 strategies (CRD labels, owner labels, ownerRefs, InstallPlan steps) - ClusterCatalog resolution: match OLMv0 CatalogSource to OLMv1 ClusterCatalog - Zero-downtime migration with backup/recovery at each phase - Server-side apply with field manager olm.operatorframework.io/migration Signed-off-by: Per G. da Silva --- hack/tools/migrate/all.go | 300 ++++++ hack/tools/migrate/check.go | 117 +++ hack/tools/migrate/gather.go | 128 +++ hack/tools/migrate/main.go | 123 +++ hack/tools/migrate/migrate.go | 313 ++++++ hack/tools/migrate/output.go | 129 +++ .../operator-controller/applier/boxcutter.go | 48 + .../operator-controller/migration/catalog.go | 308 ++++++ .../migration/collector.go | 457 +++++++++ .../migration/compatibility.go | 201 ++++ .../migration/compatibility_test.go | 114 +++ .../migration/migration.go | 918 ++++++++++++++++++ .../migration/migration_test.go | 148 +++ .../migration/readiness.go | 91 ++ .../operator-controller/migration/scan.go | 81 ++ .../operator-controller/migration/types.go | 67 ++ 16 files changed, 3543 insertions(+) create mode 100644 hack/tools/migrate/all.go create mode 100644 hack/tools/migrate/check.go create mode 100644 hack/tools/migrate/gather.go create mode 100644 hack/tools/migrate/main.go create mode 100644 hack/tools/migrate/migrate.go create mode 100644 hack/tools/migrate/output.go create mode 100644 internal/operator-controller/migration/catalog.go create mode 100644 internal/operator-controller/migration/collector.go create mode 100644 internal/operator-controller/migration/compatibility.go create mode 100644 internal/operator-controller/migration/compatibility_test.go create mode 100644 internal/operator-controller/migration/migration.go create mode 100644 internal/operator-controller/migration/migration_test.go create mode 100644 internal/operator-controller/migration/readiness.go create mode 100644 internal/operator-controller/migration/scan.go create mode 100644 internal/operator-controller/migration/types.go diff --git a/hack/tools/migrate/all.go b/hack/tools/migrate/all.go new file mode 100644 index 000000000..e28c0a955 --- /dev/null +++ b/hack/tools/migrate/all.go @@ -0,0 +1,300 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "k8s.io/client-go/rest" + + "github.com/operator-framework/operator-controller/internal/operator-controller/migration" +) + +var allCmd = &cobra.Command{ + Use: "all", + Short: "Discover and migrate all eligible OLMv0 operators to OLMv1", + Long: `Scans the cluster for all OLMv0 Subscriptions, checks each for migration +eligibility, presents a summary, and migrates the eligible operators one by one. + +Examples: + # Interactive — review and approve each operator + migrate all + + # Non-interactive — migrate all eligible operators + migrate all -y`, + RunE: runAll, +} + +func runAll(cmd *cobra.Command, _ []string) error { + c, restCfg, err := newClient() + if err != nil { + return err + } + + ctx := cmd.Context() + m := migration.NewMigrator(c, restCfg) + m.Progress = progressFunc + + // Phase 1: Scan + fmt.Printf("\n%s%sšŸ”Ž Scanning cluster for OLMv0 Subscriptions...%s\n", colorBold, colorCyan, colorReset) + startProgress() + results, err := m.ScanAllSubscriptions(ctx) + clearProgress() + if err != nil { + fail(fmt.Sprintf("Failed to scan Subscriptions: %v", err)) + return err + } + + if len(results) == 0 { + info("No Subscriptions found on the cluster.") + return nil + } + + // Phase 2: Display results + var eligible, ineligible []migration.OperatorScanResult + for _, r := range results { + if r.Eligible { + eligible = append(eligible, r) + } else { + ineligible = append(ineligible, r) + } + } + + sectionHeader(fmt.Sprintf("Scan Results (%d Subscriptions found)", len(results))) + + if len(eligible) > 0 { + fmt.Printf("\n %s%sEligible for migration (%d):%s\n", colorBold, colorGreen, len(eligible), colorReset) + for i, r := range eligible { + fmt.Printf(" %s%d)%s %s%s%s/%s%s (package: %s, version: %s)\n", + colorGreen, i+1, colorReset, + colorBold, r.SubscriptionNamespace, colorReset, + r.SubscriptionName, colorReset, + r.PackageName, r.Version) + } + } + + if len(ineligible) > 0 { + fmt.Printf("\n %s%sNot eligible (%d):%s\n", colorBold, colorRed, len(ineligible), colorReset) + for _, r := range ineligible { + reason := summarizeIneligibility(r) + fmt.Printf(" %sāœ—%s %s/%s (package: %s) — %s%s%s\n", + colorRed, colorReset, + r.SubscriptionNamespace, r.SubscriptionName, + r.PackageName, + colorDim, reason, colorReset) + } + } + + if len(eligible) == 0 { + fmt.Println() + warn("No operators are eligible for migration.") + return nil + } + + // Phase 3: Confirmation + if !autoApprove { + fmt.Printf("\n%sšŸ”„ Migrate %d eligible operator(s) to OLMv1? [y/N]: %s", colorYellow, len(eligible), colorReset) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + warn("Migration cancelled by user") + return nil + } + } + + // Phase 4: Migrate each operator + var succeeded, failed int + for i, r := range eligible { + fmt.Printf("\n%s%s════════════════════════════════════════════════════════════%s\n", + colorBold, colorCyan, colorReset) + fmt.Printf("%s%s [%d/%d] Migrating %s/%s (%s@%s)%s\n", + colorBold, colorCyan, i+1, len(eligible), + r.SubscriptionNamespace, r.SubscriptionName, + r.PackageName, r.Version, colorReset) + fmt.Printf("%s%s════════════════════════════════════════════════════════════%s\n", + colorBold, colorCyan, colorReset) + + if err := migrateSingle(ctx, m, r, restCfg); err != nil { + fail(fmt.Sprintf("Migration failed: %v", err)) + failed++ + + if !autoApprove && i < len(eligible)-1 { + fmt.Printf("\n %sContinue with remaining operators? [y/N]: %s", colorYellow, colorReset) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + warn("Remaining migrations cancelled") + break + } + } + } else { + succeeded++ + } + } + + // Phase 5: Summary + fmt.Printf("\n%s%s════════════════════════════════════════════════════════════%s\n", + colorBold, colorCyan, colorReset) + sectionHeader("Migration Summary") + if succeeded > 0 { + success(fmt.Sprintf("%d operator(s) migrated successfully", succeeded)) + } + if failed > 0 { + fail(fmt.Sprintf("%d operator(s) failed to migrate", failed)) + } + if len(ineligible) > 0 { + info(fmt.Sprintf("%d operator(s) were not eligible", len(ineligible))) + } + fmt.Println() + + if failed > 0 { + return fmt.Errorf("%d migration(s) failed", failed) + } + return nil +} + +func migrateSingle(ctx context.Context, m *migration.Migrator, r migration.OperatorScanResult, restCfg *rest.Config) error { + opts := migration.Options{ + SubscriptionName: r.SubscriptionName, + SubscriptionNamespace: r.SubscriptionNamespace, + } + opts.ApplyDefaults() + + // Profile + sub, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + return fmt.Errorf("profiling failed: %w", err) + } + + bundleInfo, err := m.GetBundleInfo(ctx, opts, csv, ip) + if err != nil { + return fmt.Errorf("bundle info failed: %w", err) + } + _ = sub // already checked in scan + + detail("Package:", bundleInfo.PackageName) + detail("Version:", bundleInfo.Version) + + // Catalog resolution + info("Resolving ClusterCatalog...") + csImage, _ := m.GetCatalogSourceImage(ctx, bundleInfo.CatalogSourceRef) + if csImage != "" { + bundleInfo.CatalogSourceImage = csImage + } + + startProgress() + catalogName, err := m.ResolveClusterCatalog(ctx, bundleInfo, restCfg) + clearProgress() + if err != nil { + var notFound *migration.PackageNotFoundError + if errors.As(err, ¬Found) && bundleInfo.CatalogSourceImage != "" { + warn(err.Error()) + catalogName, err = promptCreateCatalog(ctx, m, bundleInfo, restCfg) + if err != nil { + return err + } + } else { + return fmt.Errorf("catalog resolution failed: %w", err) + } + } + bundleInfo.ResolvedCatalogName = catalogName + success(fmt.Sprintf("Using catalog: %s", catalogName)) + + // Collect + info("Collecting resources...") + objects, err := m.CollectResources(ctx, opts, csv, ip, bundleInfo.PackageName) + if err != nil { + return fmt.Errorf("resource collection failed: %w", err) + } + bundleInfo.CollectedObjects = objects + success(fmt.Sprintf("Collected %d resources", len(objects))) + + // Backup + backup, err := m.BackupResources(ctx, opts, csv) + if err != nil { + return fmt.Errorf("backup failed: %w", err) + } + if err := backup.SaveToDisk("."); err != nil { + warn(fmt.Sprintf("Could not save backup to disk: %v", err)) + } else { + info(fmt.Sprintf("Backup: %s", backup.Dir)) + } + + // Prepare + info("Removing OLMv0 management (orphan cascade)...") + if err := m.PrepareForMigration(ctx, opts, csv); err != nil { + fail("Preparation failed, recovering...") + startProgress() + if recoverErr := m.RecoverFromBackup(ctx, opts, backup); recoverErr != nil { + clearProgress() + return fmt.Errorf("preparation failed: %w; recovery also failed: %v", err, recoverErr) + } + clearProgress() + return fmt.Errorf("preparation failed (recovered): %w", err) + } + success("OLMv0 management removed") + + // CER + info("Creating ClusterExtensionRevision...") + startProgress() + if err := m.CreateClusterExtensionRevision(ctx, opts, bundleInfo); err != nil { + clearProgress() + fail("CER failed, recovering...") + startProgress() + if recoverErr := m.RecoverBeforeCE(ctx, opts, backup); recoverErr != nil { + clearProgress() + return fmt.Errorf("CER creation failed: %w; recovery also failed: %v", err, recoverErr) + } + clearProgress() + return fmt.Errorf("CER creation failed (recovered): %w", err) + } + clearProgress() + success(fmt.Sprintf("CER %s-1 available", opts.ClusterExtensionName)) + + // CE + info("Creating ClusterExtension...") + startProgress() + if err := m.CreateClusterExtension(ctx, opts, bundleInfo); err != nil { + clearProgress() + return fmt.Errorf("CE creation failed: %w", err) + } + clearProgress() + success(fmt.Sprintf("CE %s installed", opts.ClusterExtensionName)) + + // Cleanup + info("Cleaning up OLMv0 resources...") + cleanupResult := m.CleanupOLMv0Resources(ctx, opts, bundleInfo.PackageName, csv.Name) + for _, action := range cleanupResult.Actions { + switch { + case action.Skipped: + info(fmt.Sprintf("ā­ļø %s", action.Description)) + case action.Error != nil: + warn(fmt.Sprintf("%s: %v", action.Description, action.Error)) + case action.Succeeded: + success(action.Description) + } + } + + banner(fmt.Sprintf("%s migrated successfully!", bundleInfo.PackageName)) + return nil +} + +func summarizeIneligibility(r migration.OperatorScanResult) string { + if r.ReadinessError != nil { + return r.ReadinessError.Error() + } + if len(r.CompatibilityIssues) > 0 { + reasons := make([]string, len(r.CompatibilityIssues)) + for i, issue := range r.CompatibilityIssues { + reasons[i] = issue.String() + } + return strings.Join(reasons, "; ") + } + return "unknown" +} diff --git a/hack/tools/migrate/check.go b/hack/tools/migrate/check.go new file mode 100644 index 000000000..e554361dd --- /dev/null +++ b/hack/tools/migrate/check.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/operator-framework/operator-controller/internal/operator-controller/migration" +) + +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Check readiness and compatibility without performing migration", + Long: `Runs all pre-migration checks (readiness and compatibility) and reports +any issues that would prevent migration. Does not modify any cluster resources. + +Examples: + migrate check -s my-operator -n operators`, + RunE: runCheck, +} + +func runCheck(cmd *cobra.Command, _ []string) error { + c, restConfig, err := newClient() + if err != nil { + return err + } + + opts := migration.Options{ + SubscriptionName: subscriptionName, + SubscriptionNamespace: subscriptionNamespace, + ClusterExtensionName: clusterExtensionName, + InstallNamespace: installNamespace, + } + opts.ApplyDefaults() + + ctx := cmd.Context() + m := migration.NewMigrator(c, restConfig) + + fmt.Printf("\n%s%sšŸ” Pre-migration checks for %s/%s%s\n", colorBold, colorCyan, subscriptionNamespace, subscriptionName, colorReset) + + // Readiness checks + sectionHeader("Readiness Checks") + info("Verifying Subscription state, CSV health, uniqueness, and dependencies...") + readinessErr := m.CheckReadiness(ctx, opts) + if readinessErr != nil { + fail(fmt.Sprintf("%v", readinessErr)) + } else { + success("Subscription state: AtLatestKnown, currentCSV == installedCSV") + success("CSV phase: Succeeded (InstallSucceeded)") + success("No olm.generated-by annotation (not a dependency)") + success("No duplicate Subscriptions for the same package") + } + + // Profile the operator for compatibility checks + sectionHeader("Compatibility Checks") + info("Reading Subscription, CSV, and OperatorGroup...") + _, csv, _, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + fail(fmt.Sprintf("Could not profile operator: %v", err)) + if readinessErr != nil { + return fmt.Errorf("readiness and profiling checks failed") + } + return fmt.Errorf("profiling failed: %w", err) + } + + info("Checking OperatorGroup mode, upgrade strategy, service account...") + info("Checking for olm.package.required / olm.gvk.required dependencies...") + info("Checking for APIServiceDefinitions...") + info("Checking OperatorCondition status...") + + propsJSON := csv.Annotations["operatorframework.io/properties"] + issues, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + fail(fmt.Sprintf("Compatibility check error: %v", err)) + return fmt.Errorf("compatibility check error: %w", err) + } + + if len(issues) > 0 { + for _, issue := range issues { + fail(issue.String()) + } + } else { + success("OperatorGroup: AllNamespaces mode, no scoped SA, default upgrade strategy") + success("No dependency resolution requirements") + success("No APIServiceDefinitions") + success("OperatorCondition API not in use") + } + + // ClusterCatalog check + sectionHeader("ClusterCatalog Availability") + info("Looking for serving ClusterCatalogs...") + bundleInfo, _ := m.GetBundleInfo(ctx, opts, csv, nil) + if bundleInfo != nil { + catalogName, catalogErr := m.ResolveClusterCatalog(ctx, bundleInfo, restConfig) + if catalogErr != nil { + warn(fmt.Sprintf("No ClusterCatalog resolved: %v", catalogErr)) + } else { + success(fmt.Sprintf("ClusterCatalog available: %s", catalogName)) + } + } + + // Summary + sectionHeader("Summary") + if readinessErr != nil || len(issues) > 0 { + totalIssues := len(issues) + if readinessErr != nil { + totalIssues++ + } + fail(fmt.Sprintf("%d issue(s) found — operator is NOT ready for migration", totalIssues)) + fmt.Println() + return fmt.Errorf("pre-migration checks failed") + } + + success(fmt.Sprintf("Operator %s/%s is ready for migration to OLMv1", subscriptionNamespace, subscriptionName)) + fmt.Println() + return nil +} diff --git a/hack/tools/migrate/gather.go b/hack/tools/migrate/gather.go new file mode 100644 index 000000000..a9c1fa91f --- /dev/null +++ b/hack/tools/migrate/gather.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/operator-framework/operator-controller/internal/operator-controller/migration" +) + +var gatherCmd = &cobra.Command{ + Use: "gather", + Short: "Gather migration info without creating resources", + Long: `Profiles the operator installation and collects all resources that would be +migrated, without actually creating any OLMv1 resources. Useful for reviewing +what the migration will do before committing. + +Examples: + migrate gather -s my-operator -n operators`, + RunE: runGather, +} + +func runGather(cmd *cobra.Command, _ []string) error { + c, restConfig, err := newClient() + if err != nil { + return err + } + + opts := migration.Options{ + SubscriptionName: subscriptionName, + SubscriptionNamespace: subscriptionNamespace, + ClusterExtensionName: clusterExtensionName, + InstallNamespace: installNamespace, + } + opts.ApplyDefaults() + + ctx := cmd.Context() + m := migration.NewMigrator(c, restConfig) + + fmt.Printf("\n%s%sšŸ“‹ Gathering migration info for %s/%s%s\n", colorBold, colorCyan, subscriptionNamespace, subscriptionName, colorReset) + + // Profile + sectionHeader("Operator Profile") + info("Reading Subscription, CSV, and InstallPlan...") + sub, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + fail(fmt.Sprintf("Failed to profile operator: %v", err)) + return fmt.Errorf("failed to profile operator: %w", err) + } + + bundleInfo, err := m.GetBundleInfo(ctx, opts, csv, ip) + if err != nil { + fail(fmt.Sprintf("Failed to get bundle info: %v", err)) + return fmt.Errorf("failed to get bundle info: %w", err) + } + + detail("Package:", bundleInfo.PackageName) + detail("Version:", bundleInfo.Version) + detail("Bundle:", bundleInfo.BundleName) + detail("Channel:", valueOrDefault(bundleInfo.Channel, "(default)")) + detail("Bundle Image:", valueOrDefault(bundleInfo.BundleImage, "(not available)")) + detail("CatalogSource:", fmt.Sprintf("%s/%s", bundleInfo.CatalogSourceRef.Namespace, bundleInfo.CatalogSourceRef.Name)) + detail("Sub State:", fmt.Sprintf("%s (installed: %s)", sub.Status.State, sub.Status.InstalledCSV)) + + // Catalog source image + csImage, err := m.GetCatalogSourceImage(ctx, bundleInfo.CatalogSourceRef) + if err != nil { + detail("Catalog Image:", fmt.Sprintf("%s(unavailable)%s", colorYellow, colorReset)) + } else { + detail("Catalog Image:", csImage) + bundleInfo.CatalogSourceImage = csImage + } + + // Resolve ClusterCatalog + catalogName, err := m.ResolveClusterCatalog(ctx, bundleInfo, restConfig) + if err != nil { + detail("ClusterCatalog:", fmt.Sprintf("%s(not resolved: %v)%s", colorYellow, err, colorReset)) + } else { + detail("ClusterCatalog:", catalogName) + } + + // CSV status + sectionHeader("CSV Status") + detail("Name:", csv.Name) + detail("Phase:", string(csv.Status.Phase)) + detail("Reason:", string(csv.Status.Reason)) + + // Collect resources + sectionHeader("Collected Resources") + info("Scanning Operator CR, owner labels, ownerRefs, and InstallPlan steps...") + objects, err := m.CollectResources(ctx, opts, csv, ip, bundleInfo.PackageName) + if err != nil { + fail(fmt.Sprintf("Failed to collect resources: %v", err)) + return fmt.Errorf("failed to collect resources: %w", err) + } + + // Group by kind for display + kindCounts := make(map[string]int) + for _, obj := range objects { + kindCounts[obj.GetKind()]++ + } + + success(fmt.Sprintf("Found %d resources across %d kinds", len(objects), len(kindCounts))) + fmt.Println() + for kind, count := range kindCounts { + fmt.Printf(" %s%-40s%s %s%d%s\n", colorDim, kind, colorReset, colorBold, count, colorReset) + } + + fmt.Println() + for _, obj := range objects { + ns := obj.GetNamespace() + if ns == "" { + ns = "(cluster)" + } + resource(obj.GetKind(), ns, obj.GetName()) + } + + // Migration plan + sectionHeader("Migration Plan") + detail("CE name:", opts.ClusterExtensionName) + detail("Install NS:", opts.InstallNamespace) + detail("ServiceAccount:", opts.ServiceAccountName()) + detail("CER name:", fmt.Sprintf("%s-1", opts.ClusterExtensionName)) + detail("Collision:", "None (adopt existing objects)") + + fmt.Println() + return nil +} diff --git a/hack/tools/migrate/main.go b/hack/tools/migrate/main.go new file mode 100644 index 000000000..f7c268892 --- /dev/null +++ b/hack/tools/migrate/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "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" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(ocv1.AddToScheme(scheme)) + utilruntime.Must(appsv1.AddToScheme(scheme)) + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) + utilruntime.Must(operatorsv1.AddToScheme(scheme)) + utilruntime.Must(operatorsv1alpha1.AddToScheme(scheme)) +} + +var ( + subscriptionName string + subscriptionNamespace string + clusterExtensionName string + installNamespace string + kubeconfig string + autoApprove bool +) + +var rootCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate OLMv0-managed operators to OLMv1", + Long: `migrate is a CLI tool for migrating operators managed by OLMv0 (Subscription/CSV) +to OLMv1 (ClusterExtension/ClusterExtensionRevision). + +It profiles the existing installation, validates compatibility, collects operator resources, +and creates the corresponding OLMv1 resources for a zero-downtime migration. + +Examples: + # Migrate a single operator (interactive) + migrate -s my-operator -n operators + + # Migrate a single operator (non-interactive) + migrate -s my-operator -n operators -y + + # Migrate all eligible operators + migrate all + + # Check readiness and compatibility only + migrate check -s my-operator -n operators + + # Gather migration info without creating resources + migrate gather -s my-operator -n operators`, + RunE: runMigrate, +} + +// addSubscriptionFlags adds -s/-n and related flags to a command. +func addSubscriptionFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&subscriptionName, "subscription", "s", "", "Name of the OLMv0 Subscription to migrate (required)") + cmd.Flags().StringVarP(&subscriptionNamespace, "namespace", "n", "", "Namespace of the Subscription (required)") + cmd.Flags().StringVar(&clusterExtensionName, "ce-name", "", "Name for the ClusterExtension (default: subscription name)") + cmd.Flags().StringVar(&installNamespace, "install-namespace", "", "Install namespace for the ClusterExtension (default: subscription namespace)") + _ = cmd.MarkFlagRequired("subscription") + _ = cmd.MarkFlagRequired("namespace") +} + +func init() { + // Global flags (available to all commands) + rootCmd.PersistentFlags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: KUBECONFIG env or ~/.kube/config)") + rootCmd.PersistentFlags().BoolVarP(&autoApprove, "yes", "y", false, "Skip confirmation prompts") + + // Root command needs subscription flags + addSubscriptionFlags(rootCmd) + + // Subcommands + addSubscriptionFlags(checkCmd) + addSubscriptionFlags(gatherCmd) + rootCmd.AddCommand(checkCmd) + rootCmd.AddCommand(gatherCmd) + rootCmd.AddCommand(allCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func newClient() (client.Client, *rest.Config, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfig != "" { + loadingRules.ExplicitPath = kubeconfig + } + + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + restConfig, err := kubeConfig.ClientConfig() + if err != nil { + return nil, nil, fmt.Errorf("failed to get REST config: %w", err) + } + + c, err := client.New(restConfig, client.Options{Scheme: scheme}) + if err != nil { + return nil, nil, fmt.Errorf("failed to create client: %w", err) + } + return c, restConfig, nil +} diff --git a/hack/tools/migrate/migrate.go b/hack/tools/migrate/migrate.go new file mode 100644 index 000000000..132ae778d --- /dev/null +++ b/hack/tools/migrate/migrate.go @@ -0,0 +1,313 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "k8s.io/client-go/rest" + + "github.com/operator-framework/operator-controller/internal/operator-controller/migration" +) + +func runMigrate(cmd *cobra.Command, _ []string) error { + c, restConfig, err := newClient() + if err != nil { + return err + } + + opts := migration.Options{ + SubscriptionName: subscriptionName, + SubscriptionNamespace: subscriptionNamespace, + ClusterExtensionName: clusterExtensionName, + InstallNamespace: installNamespace, + } + opts.ApplyDefaults() + + ctx := cmd.Context() + m := migration.NewMigrator(c, restConfig) + m.Progress = progressFunc + + // Step 1: Profile (RFC Step 1) + stepHeader(1, "Profiling operator") + info("Reading Subscription, CSV, and InstallPlan...") + sub, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + fail("Failed to profile operator") + return fmt.Errorf("failed to profile operator: %w", err) + } + + bundleInfo, err := m.GetBundleInfo(ctx, opts, csv, ip) + if err != nil { + fail("Failed to get bundle info") + return fmt.Errorf("failed to get bundle info: %w", err) + } + detail("Package:", bundleInfo.PackageName) + detail("Version:", bundleInfo.Version) + detail("Bundle:", bundleInfo.BundleName) + detail("Channel:", valueOrDefault(bundleInfo.Channel, "(default)")) + detail("CatalogSource:", fmt.Sprintf("%s/%s", bundleInfo.CatalogSourceRef.Namespace, bundleInfo.CatalogSourceRef.Name)) + detail("Sub State:", fmt.Sprintf("%s (installed: %s)", sub.Status.State, sub.Status.InstalledCSV)) + detail("CSV Phase:", fmt.Sprintf("%s (%s)", csv.Status.Phase, csv.Status.Reason)) + if bundleInfo.ManualApproval { + detail("Approval:", "Manual (version will be pinned)") + } else { + detail("Approval:", "Automatic (upgrades will continue)") + } + success("Operator profiled") + + // Step 2: Readiness and Compatibility (RFC Step 2) + stepHeader(2, "Checking readiness and compatibility") + + info("Verifying Subscription state and CSV health...") + if err := m.CheckReadiness(ctx, opts); err != nil { + fail(fmt.Sprintf("Readiness: %v", err)) + return fmt.Errorf("readiness check failed: %w", err) + } + success("Subscription is stable (state: AtLatestKnown, CSV: Succeeded)") + + info("Checking OperatorGroup, dependencies, APIServices, OperatorConditions...") + propsJSON := csv.Annotations["operatorframework.io/properties"] + issues, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + fail(fmt.Sprintf("Compatibility check error: %v", err)) + return fmt.Errorf("compatibility check error: %w", err) + } + if len(issues) > 0 { + for _, issue := range issues { + fail(issue.String()) + } + return fmt.Errorf("operator is not compatible with OLMv1 (%d issues)", len(issues)) + } + success("No compatibility issues found") + + // Step 3: Determine target ClusterCatalog (RFC Step 3) + stepHeader(3, "Determining target ClusterCatalog") + + info("Looking up CatalogSource image...") + csImage, err := m.GetCatalogSourceImage(ctx, bundleInfo.CatalogSourceRef) + if err != nil { + warn(fmt.Sprintf("Could not get CatalogSource image: %v", err)) + } else { + bundleInfo.CatalogSourceImage = csImage + detail("Catalog image:", csImage) + } + + info("Querying available ClusterCatalogs for package content...") + startProgress() + catalogName, err := m.ResolveClusterCatalog(ctx, bundleInfo, restConfig) + clearProgress() + if err != nil { + var notFound *migration.PackageNotFoundError + if errors.As(err, ¬Found) && bundleInfo.CatalogSourceImage != "" { + warn(err.Error()) + catalogName, err = promptCreateCatalog(ctx, m, bundleInfo, restConfig) + if err != nil { + return err + } + } else { + fail(fmt.Sprintf("Failed to resolve ClusterCatalog: %v", err)) + return fmt.Errorf("failed to resolve ClusterCatalog: %w", err) + } + } + bundleInfo.ResolvedCatalogName = catalogName + success(fmt.Sprintf("Selected ClusterCatalog: %s", catalogName)) + + // Step 5: Collect resources (done before Step 4 to preserve ownerRef data) + stepHeader(5, "Collecting operator resources") + info("Scanning Operator CR, owner labels, ownerRefs, and InstallPlan steps...") + objects, err := m.CollectResources(ctx, opts, csv, ip, bundleInfo.PackageName) + if err != nil { + fail(fmt.Sprintf("Failed to collect resources: %v", err)) + return fmt.Errorf("failed to collect resources: %w", err) + } + bundleInfo.CollectedObjects = objects + + // Group by kind + kindCounts := make(map[string]int) + for _, obj := range objects { + kindCounts[obj.GetKind()]++ + } + success(fmt.Sprintf("Found %d resources across %d kinds", len(objects), len(kindCounts))) + for _, obj := range objects { + ns := obj.GetNamespace() + if ns == "" { + ns = "(cluster)" + } + resource(obj.GetKind(), ns, obj.GetName()) + } + + // Confirmation + if !autoApprove { + fmt.Printf("\n%sšŸ”„ Proceed with migration of %s%s%s to OLMv1? [y/N]: %s", + colorYellow, colorBold, opts.SubscriptionName, colorYellow, colorReset) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + warn("Migration cancelled by user") + return nil + } + } + + // Step 4: Prepare — backup and remove OLMv0 management (RFC Step 4) + stepHeader(4, "Preparing operator for migration") + + info("Backing up Subscription and CSV for recovery...") + backup, err := m.BackupResources(ctx, opts, csv) + if err != nil { + fail(fmt.Sprintf("Failed to backup resources: %v", err)) + return fmt.Errorf("failed to backup resources: %w", err) + } + if err := backup.SaveToDisk("."); err != nil { + warn(fmt.Sprintf("Could not save backup to disk: %v", err)) + } else { + success(fmt.Sprintf("Backup saved to %s", backup.Dir)) + } + + info(fmt.Sprintf("Ensuring namespace %s and ServiceAccount %s/%s with cluster-admin...", opts.InstallNamespace, opts.InstallNamespace, opts.ServiceAccountName())) + + info("Deleting Subscription and CSV (orphan cascade)...") + if err := m.PrepareForMigration(ctx, opts, csv); err != nil { + fail("Preparation failed, attempting recovery...") + startProgress() + if recoverErr := m.RecoverFromBackup(ctx, opts, backup); recoverErr != nil { + clearProgress() + fail(fmt.Sprintf("Recovery also failed: %v", recoverErr)) + return fmt.Errorf("preparation failed: %w; recovery also failed: %v", err, recoverErr) + } + clearProgress() + warn("Recovered successfully — Subscription restored") + return fmt.Errorf("preparation failed (recovered successfully): %w", err) + } + success("Installer ServiceAccount ready") + success("OLMv0 management removed (operator workloads running)") + + // Step 6: Create CER (RFC Step 6) + stepHeader(6, "Creating ClusterExtensionRevision") + info(fmt.Sprintf("Applying CER %s-1 with %d objects across %d phases...", + opts.ClusterExtensionName, len(bundleInfo.CollectedObjects), len(kindCounts))) + info("Waiting for CER to reach Available=True...") + startProgress() + if err := m.CreateClusterExtensionRevision(ctx, opts, bundleInfo); err != nil { + clearProgress() + fail("CER creation failed, attempting recovery...") + startProgress() + if recoverErr := m.RecoverBeforeCE(ctx, opts, backup); recoverErr != nil { + clearProgress() + fail(fmt.Sprintf("Recovery also failed: %v", recoverErr)) + return fmt.Errorf("CER creation failed: %w; recovery also failed: %v", err, recoverErr) + } + clearProgress() + warn("Recovered successfully — Subscription restored") + return fmt.Errorf("CER creation failed (recovered successfully): %w", err) + } + clearProgress() + success(fmt.Sprintf("ClusterExtensionRevision %s-1 is Available", opts.ClusterExtensionName)) + + // Step 7: Create CE (RFC Step 7) + stepHeader(7, "Creating ClusterExtension") + if bundleInfo.ManualApproval { + info(fmt.Sprintf("Creating CE %s (package: %s, version: %s [pinned], catalog: %s)...", + opts.ClusterExtensionName, bundleInfo.PackageName, bundleInfo.Version, bundleInfo.ResolvedCatalogName)) + } else { + info(fmt.Sprintf("Creating CE %s (package: %s, channel: %s [auto-upgrade], catalog: %s)...", + opts.ClusterExtensionName, bundleInfo.PackageName, valueOrDefault(bundleInfo.Channel, "default"), bundleInfo.ResolvedCatalogName)) + } + info("Waiting for CE to reach Installed=True...") + startProgress() + if err := m.CreateClusterExtension(ctx, opts, bundleInfo); err != nil { + clearProgress() + fail(fmt.Sprintf("Failed to create ClusterExtension: %v", err)) + return fmt.Errorf("failed to create ClusterExtension: %w", err) + } + clearProgress() + success(fmt.Sprintf("ClusterExtension %s is Installed", opts.ClusterExtensionName)) + + // Step 8: Cleanup (RFC Step 8) + stepHeader(8, "Cleaning up OLMv0 resources") + cleanupResult := m.CleanupOLMv0Resources(ctx, opts, bundleInfo.PackageName, csv.Name) + for _, action := range cleanupResult.Actions { + switch { + case action.Skipped: + info(fmt.Sprintf("ā­ļø %s", action.Description)) + case action.Error != nil: + warn(fmt.Sprintf("%s: %v", action.Description, action.Error)) + case action.Succeeded: + success(action.Description) + } + } + + // Notify about CRD-owned ClusterRoles + crdRoles := m.FindCRDClusterRoles(ctx, csv.Name) + if len(crdRoles) > 0 { + info("CRD-owned ClusterRoles retained for RBAC (not managed by OLMv1):") + for _, name := range crdRoles { + fmt.Printf(" %sšŸ“Œ %s%s\n", colorDim, name, colorReset) + } + } + + banner(fmt.Sprintf("Migration complete! %s is now managed by OLMv1", bundleInfo.PackageName)) + + if backup.Dir != "" { + info(fmt.Sprintf("Backup files: %s", backup.Dir)) + info(" subscription.yaml — original Subscription") + info(" clusterserviceversion.yaml — original CSV") + info("These can be used for manual recovery if needed. Safe to delete once migration is verified.") + } + fmt.Println() + return nil +} + +func valueOrDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func promptCreateCatalog(ctx context.Context, m *migration.Migrator, bundleInfo *migration.MigrationInfo, restConfig *rest.Config) (string, error) { + catalogName := fmt.Sprintf("%s-catalog", bundleInfo.PackageName) + imageRef := bundleInfo.CatalogSourceImage + + fmt.Printf("\n %sThe package was not found in any existing ClusterCatalog.%s\n", colorYellow, colorReset) + fmt.Printf(" A new ClusterCatalog can be created from the CatalogSource image:\n") + detail("Name:", catalogName) + detail("Image:", imageRef) + + if !autoApprove { + fmt.Printf("\n %sCreate ClusterCatalog %s? [y/N]: %s", colorYellow, catalogName, colorReset) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + return "", fmt.Errorf("no ClusterCatalog available and user declined to create one") + } + } + + info(fmt.Sprintf("Creating ClusterCatalog %s...", catalogName)) + startProgress() + if err := m.CreateClusterCatalog(ctx, catalogName, imageRef); err != nil { + clearProgress() + fail(fmt.Sprintf("Failed to create ClusterCatalog: %v", err)) + return "", fmt.Errorf("failed to create ClusterCatalog: %w", err) + } + clearProgress() + success(fmt.Sprintf("ClusterCatalog %s is serving", catalogName)) + + // Verify the package is now available + info("Verifying package is available in the new catalog...") + startProgress() + verifiedName, err := m.ResolveClusterCatalog(ctx, bundleInfo, restConfig) + clearProgress() + if err != nil { + fail(fmt.Sprintf("Package still not found after creating catalog: %v", err)) + return "", fmt.Errorf("package not found in newly created ClusterCatalog: %w", err) + } + + return verifiedName, nil +} diff --git a/hack/tools/migrate/output.go b/hack/tools/migrate/output.go new file mode 100644 index 000000000..39a86b1f3 --- /dev/null +++ b/hack/tools/migrate/output.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + "sync" + "time" +) + +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorCyan = "\033[36m" + colorBold = "\033[1m" + colorDim = "\033[2m" + + clearLine = "\033[2K\r" +) + +var spinnerFrames = []string{"ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "} + +// spinner runs an animated spinner in a background goroutine. +// The message can be updated via the progress callback, and the +// spinner frame advances every 100ms independently of the poll interval. +type spinner struct { + mu sync.Mutex + msg string + start time.Time + stop chan struct{} + stopped chan struct{} +} + +var activeSpinner *spinner + +func stepHeader(num int, title string) { + fmt.Printf("\n%s%s━━━ Step %d: %s ━━━%s\n", colorBold, colorCyan, num, title, colorReset) +} + +func success(msg string) { + fmt.Printf(" %sāœ… %s%s\n", colorGreen, msg, colorReset) +} + +func warn(msg string) { + fmt.Printf(" %sāš ļø %s%s\n", colorYellow, msg, colorReset) +} + +func fail(msg string) { + fmt.Printf(" %sāŒ %s%s\n", colorRed, msg, colorReset) +} + +func info(msg string) { + fmt.Printf(" %s%s%s\n", colorDim, msg, colorReset) +} + +func detail(label, value string) { + fmt.Printf(" %s%-16s%s %s\n", colorBlue, label, colorReset, value) +} + +func resource(kind, location, name string) { + fmt.Printf(" %sšŸ“¦ %s%s %s%s/%s%s\n", colorDim, colorReset, kind, colorDim, location, name, colorReset) +} + +func banner(msg string) { + fmt.Printf("\n%s%sšŸŽ‰ %s%s\n\n", colorBold, colorGreen, msg, colorReset) +} + +func sectionHeader(title string) { + fmt.Printf("\n%s%s── %s ──%s\n", colorBold, colorBlue, title, colorReset) +} + +// startProgress starts the background spinner animation. +func startProgress() { + s := &spinner{ + msg: "Working...", + start: time.Now(), + stop: make(chan struct{}), + stopped: make(chan struct{}), + } + activeSpinner = s + + go func() { + defer close(s.stopped) + idx := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.stop: + return + case <-ticker.C: + s.mu.Lock() + msg := s.msg + elapsed := time.Since(s.start).Truncate(time.Second) + s.mu.Unlock() + + frame := spinnerFrames[idx%len(spinnerFrames)] + idx++ + fmt.Fprintf(os.Stderr, "%s %s%s %s%s (%s)%s", + clearLine, colorYellow, frame, msg, colorDim, elapsed, colorReset) + } + } + }() +} + +// clearProgress stops the spinner and clears its line. +func clearProgress() { + if activeSpinner == nil { + return + } + close(activeSpinner.stop) + <-activeSpinner.stopped + fmt.Fprint(os.Stderr, clearLine) + activeSpinner = nil +} + +// progressFunc is the migration.ProgressFunc callback that updates the spinner message. +// The spinner goroutine handles the animation; this just updates the text. +func progressFunc(msg string) { + if activeSpinner == nil { + return + } + activeSpinner.mu.Lock() + activeSpinner.msg = msg + activeSpinner.mu.Unlock() +} diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index cb4da7e53..01dc48aa9 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -461,6 +461,13 @@ func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust return true, "", nil } + // Ensure ownerReferences on all existing revisions so they are garbage-collected + // when the ClusterExtension is deleted. This handles revisions created by + // migration tools that lack ownerReferences at creation time. + if err := bc.ensureOwnerRefsOnRevisions(ctx, ext, existingRevisions); err != nil { + return false, "", fmt.Errorf("ensuring owner references on existing revisions: %w", err) + } + // Generate desired revision desiredRevision, err := bc.RevisionGenerator.GenerateRevision(ctx, contentFS, ext, objectLabels, revisionAnnotations) if err != nil { @@ -595,6 +602,47 @@ func (bc *Boxcutter) getExistingRevisions(ctx context.Context, extName string) ( return existingRevisionList.Items, nil } +// ensureOwnerRefsOnRevisions checks existing revisions and adds an ownerReference +// to the ClusterExtension if one is missing. This handles adoption of revisions +// created by migration tools that don't set ownerReferences at creation time. +func (bc *Boxcutter) ensureOwnerRefsOnRevisions(ctx context.Context, ext *ocv1.ClusterExtension, revisions []ocv1.ClusterExtensionRevision) error { + gvk, err := apiutil.GVKForObject(ext, bc.Scheme) + if err != nil { + return fmt.Errorf("get GVK for owner: %w", err) + } + + for i := range revisions { + rev := &revisions[i] + if hasOwnerRef(rev.OwnerReferences, ext.Name, string(ext.UID)) { + continue + } + + blockOwnerDeletion := true + controller := true + rev.OwnerReferences = append(rev.OwnerReferences, metav1.OwnerReference{ + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Name: ext.Name, + UID: ext.UID, + BlockOwnerDeletion: &blockOwnerDeletion, + Controller: &controller, + }) + if err := bc.Client.Update(ctx, rev); err != nil { + return fmt.Errorf("setting owner reference on revision %s: %w", rev.Name, err) + } + } + return nil +} + +func hasOwnerRef(refs []metav1.OwnerReference, name string, uid string) bool { + for _, ref := range refs { + if ref.Name == name && string(ref.UID) == uid { + return true + } + } + return false +} + func latestRevisionNumber(prevRevisions []ocv1.ClusterExtensionRevision) int64 { if len(prevRevisions) == 0 { return 0 diff --git a/internal/operator-controller/migration/catalog.go b/internal/operator-controller/migration/catalog.go new file mode 100644 index 000000000..47e81574f --- /dev/null +++ b/internal/operator-controller/migration/catalog.go @@ -0,0 +1,308 @@ +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"` + 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 + 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) + } + + // Build the catalog API URL via the kube API server service proxy + // The catalog's base URL is like: https://catalogd-service.olmv1-system.svc/catalogs/ + // We proxy through the API server: /api/v1/namespaces/olmv1-system/services/https:catalogd-service:443/proxy/catalogs//api/v1/all + proxyURL := fmt.Sprintf("%s/api/v1/namespaces/olmv1-system/services/https:catalogd-service:443/proxy/catalogs/%s/api/v1/all", + restConfig.Host, catalog.Name) + + // Create HTTP client with the kube API server auth + 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) + // Increase buffer for large catalog entries + 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 + } + + // Check if this entry is for our package + switch meta.Schema { + case "olm.package": + if meta.Name == packageName { + info.Found = true + } + case "olm.bundle": + if meta.Package != packageName { + continue + } + // Extract version from properties + bundleVersion := extractBundleVersion(meta.Props) + if bundleVersion != "" { + versionSet[bundleVersion] = true + } + case "olm.channel": + if meta.Package != packageName { + continue + } + channelSet[meta.Name] = true + // Check if our version's bundle is in this channel + for _, entry := range meta.Entries { + bundleName := entry.Name + // Bundle names typically follow . or .v + if bundleName != "" { + _ = bundleName // entries tracked via channel membership + } + } + } + } + + 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. +// Per RFC Step 3: +// - Query each available ClusterCatalog for the package at the installed version +// - If found in multiple, use the highest-priority catalog +// - If not found in any, return an error (the user must create one) +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 + } + + // Check if the catalog is serving + serving := false + for _, c := range catalog.Status.Conditions { + if c.Type == string(metav1.ConditionTrue) { + continue + } + 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, + } + } + + // Pick the highest-priority catalog + 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: "Image", + Image: &ocv1.ImageSource{ + Ref: imageRef, + }, + }, + }, + } + + if err := m.Client.Create(ctx, catalog); err != nil { + return fmt.Errorf("failed to create ClusterCatalog: %w", err) + } + + // Wait for serving + 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/internal/operator-controller/migration/collector.go b/internal/operator-controller/migration/collector.go new file mode 100644 index 000000000..9cbad43bf --- /dev/null +++ b/internal/operator-controller/migration/collector.go @@ -0,0 +1,457 @@ +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, +} + +// 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 + if sub.Status.InstallPlanRef != nil { + ip = &operatorsv1alpha1.InstallPlan{} + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: sub.Status.InstallPlanRef.Name, + Namespace: sub.Status.InstallPlanRef.Namespace, + }, ip); err != nil { + return nil, nil, nil, fmt.Errorf("failed to get InstallPlan %s: %w", sub.Status.InstallPlanRef.Name, err) + } + } + + return &sub, &csv, ip, nil +} + +// GetBundleInfo extracts bundle metadata from the InstallPlan's bundleLookups. +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, + }, + } + + // Extract version and bundle name from CSV + info.BundleName = csv.Name + info.Version = parseCSVVersion(csv) + + // Extract bundle image from InstallPlan bundleLookups + 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 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. +// This returns the tag-based image (e.g., quay.io/org/catalog:latest) rather than +// the resolved digest, so that a ClusterCatalog created from it can pick up updates. +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. +// Per the RFC, no single OLMv0 tracking mechanism provides a complete inventory, so we combine: +// 1. Operator CR status.components.refs +// 2. CRDs by package label +// 3. Resources by olm.owner label +// 4. Resources by ownerReference +// 5. Resources from InstallPlan steps +// +// Results are deduplicated across all sources. +// olmv0OnlyKinds are resource kinds that belong to OLMv0 and should not be +// included in the ClusterExtensionRevision. They are cleaned up separately. +var olmv0OnlyKinds = map[string]bool{ + "OperatorCondition": true, + "Operator": true, + "OperatorGroup": true, +} + +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 (RFC Step 5) + // Non-fatal — Operator CR may not exist on all clusters + 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 +} + +func resourceKey(obj unstructured.Unstructured) string { + return fmt.Sprintf("%s/%s/%s/%s", + obj.GetObjectKind().GroupVersionKind().GroupKind().String(), + obj.GetNamespace(), + obj.GetName(), + obj.GetAPIVersion()) +} + +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", + }) + + listOpts := []client.ListOption{ + client.MatchingLabels{ + "olm.managed": "true", + "olm.owner": csvName, + }, + } + + if err := m.Client.List(ctx, &list, listOpts...); err != nil { + // Skip kinds that don't exist on this cluster (e.g., monitoring CRDs not installed) + 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 // ownerRefs only work within a namespace + } + + 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 + } + + // Fetch the live object + 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 { + // Resource may not exist yet or may have been cleaned up + continue + } + result = append(result, *obj) + } + return result +} + +// gatherResourcesFromOperatorCR collects resources from the Operator CR's status.components.refs. +// This is one of the primary collection strategies specified in RFC Step 5. +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 + } + + // Kinds to skip — these are OLMv0 management resources, not operator workloads + 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 { + // Resource may have been deleted + 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 + } + + // Get catalog source image (non-fatal — only needed if we need to create a ClusterCatalog) + csImage, err := m.GetCatalogSourceImage(ctx, info.CatalogSourceRef) + if err == nil { + info.CatalogSourceImage = csImage + } + + // Collect resources + 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/internal/operator-controller/migration/compatibility.go b/internal/operator-controller/migration/compatibility.go new file mode 100644 index 000000000..a99e0375d --- /dev/null +++ b/internal/operator-controller/migration/compatibility.go @@ -0,0 +1,201 @@ +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" +) + +// CompatibilityIssue describes a single compatibility problem that prevents migration. +type CompatibilityIssue struct { + Check string + Message string +} + +func (c CompatibilityIssue) String() string { + return fmt.Sprintf("[%s] %s", c.Check, c.Message) +} + +// CheckCompatibility runs all compatibility checks and returns any issues found. +// If the returned slice is non-empty, the operator is not eligible for migration. +func (m *Migrator) CheckCompatibility(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion, bundleProperties string) ([]CompatibilityIssue, error) { + var issues []CompatibilityIssue + + allNsIssues, err := m.checkAllNamespacesMode(ctx, opts) + if err != nil { + return nil, err + } + issues = append(issues, allNsIssues...) + + depIssues := checkNoDependencies(bundleProperties) + issues = append(issues, depIssues...) + + apiIssues := checkNoAPIServices(csv) + issues = append(issues, apiIssues...) + + condIssues, err := m.checkNoOperatorConditions(ctx, opts, csv) + if err != nil { + return nil, err + } + issues = append(issues, condIssues...) + + return issues, nil +} + +func (m *Migrator) checkAllNamespacesMode(ctx context.Context, opts Options) ([]CompatibilityIssue, 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 []CompatibilityIssue{{ + Check: "OperatorGroup", + Message: fmt.Sprintf("no OperatorGroup found in namespace %s", opts.SubscriptionNamespace), + }}, nil + } + + og := ogList.Items[0] + var issues []CompatibilityIssue + + if og.Spec.ServiceAccountName != "" { + issues = append(issues, CompatibilityIssue{ + Check: "OperatorGroup.spec.serviceAccountName", + Message: "OperatorGroup has spec.serviceAccountName set; OLMv1 does not support scoped service accounts", + }) + } + + if og.Spec.Selector != nil && !isEmptyLabelSelector(og.Spec.Selector) { + issues = append(issues, CompatibilityIssue{ + Check: "OperatorGroup.spec.selector", + Message: "OperatorGroup has spec.selector set; must convert to spec.targetNamespaces before migration", + }) + } + + if og.Spec.UpgradeStrategy != "" && og.Spec.UpgradeStrategy != operatorsv1.UpgradeStrategyDefault { + issues = append(issues, CompatibilityIssue{ + Check: "OperatorGroup.spec.upgradeStrategy", + Message: fmt.Sprintf("OperatorGroup upgradeStrategy must be %q or unset, got %q", operatorsv1.UpgradeStrategyDefault, og.Spec.UpgradeStrategy), + }) + } + + if len(og.Spec.TargetNamespaces) > 0 { + issues = append(issues, CompatibilityIssue{ + Check: "OperatorGroup.spec.targetNamespaces", + Message: "OperatorGroup has spec.targetNamespaces set; operator must be in AllNamespaces mode for migration", + }) + } + + // RFC Step 2 - OperatorGroup: status.namespaces — if a single non-empty value, + // warn that the operator will run in AllNamespaces mode post-migration + if len(og.Status.Namespaces) == 1 && og.Status.Namespaces[0] != "" { + issues = append(issues, CompatibilityIssue{ + Check: "OperatorGroup.status.namespaces", + Message: fmt.Sprintf("OperatorGroup targets namespace %q; post-migration the operator will run in AllNamespaces mode — user acknowledgment required", og.Status.Namespaces[0]), + }) + } + + return issues, 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 formats of the operatorframework.io/properties annotation: +// - bare array: [{"type":"olm.package","value":{...}}, ...] +// - wrapped object: {"properties": [{"type":"olm.package","value":{...}}, ...]} +func parseProperties(propertiesJSON string) ([]olmProperty, error) { + raw := []byte(propertiesJSON) + + // Try bare array first + var props []olmProperty + if err := json.Unmarshal(raw, &props); err == nil { + return props, nil + } + + // Try wrapped object + var wrapped struct { + Properties []olmProperty `json:"properties"` + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return nil, err + } + return wrapped.Properties, nil +} + +func checkNoDependencies(propertiesJSON string) []CompatibilityIssue { + if propertiesJSON == "" { + return nil + } + + props, err := parseProperties(propertiesJSON) + if err != nil { + return []CompatibilityIssue{{ + Check: "Dependencies", + Message: fmt.Sprintf("failed to parse bundle properties: %v", err), + }} + } + + var issues []CompatibilityIssue + for _, p := range props { + switch p.Type { + case "olm.package.required": + issues = append(issues, CompatibilityIssue{ + Check: "Dependencies", + Message: fmt.Sprintf("bundle declares olm.package.required dependency: %s", string(p.Value)), + }) + case "olm.gvk.required": + issues = append(issues, CompatibilityIssue{ + Check: "Dependencies", + Message: fmt.Sprintf("bundle declares olm.gvk.required dependency: %s", string(p.Value)), + }) + } + } + return issues +} + +func checkNoAPIServices(csv *operatorsv1alpha1.ClusterServiceVersion) []CompatibilityIssue { + if csv.Spec.APIServiceDefinitions.Owned != nil || csv.Spec.APIServiceDefinitions.Required != nil { + return []CompatibilityIssue{{ + Check: "APIServiceDefinitions", + Message: "CSV has spec.apiservicedefinitions set; OLMv1 does not support APIService definitions", + }} + } + return nil +} + +func (m *Migrator) checkNoOperatorConditions(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion) ([]CompatibilityIssue, error) { + var oc operatorsv1.OperatorCondition + err := m.Client.Get(ctx, types.NamespacedName{ + Name: csv.Name, + Namespace: opts.SubscriptionNamespace, + }, &oc) + if err != nil { + // If the OperatorCondition doesn't exist, that's fine + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf("failed to get OperatorCondition: %w", err) + } + return nil, nil + } + + if len(oc.Status.Conditions) > 0 { + return []CompatibilityIssue{{ + Check: "OperatorCondition", + Message: "OperatorCondition has status.conditions entries; operator actively uses the OperatorCondition API which OLMv1 does not support", + }}, nil + } + return nil, nil +} diff --git a/internal/operator-controller/migration/compatibility_test.go b/internal/operator-controller/migration/compatibility_test.go new file mode 100644 index 000000000..60c0c0c44 --- /dev/null +++ b/internal/operator-controller/migration/compatibility_test.go @@ -0,0 +1,114 @@ +package migration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +func TestCheckNoDependencies(t *testing.T) { + tests := []struct { + name string + properties string + wantIssues int + }{ + { + name: "empty properties", + properties: "", + wantIssues: 0, + }, + { + name: "no dependencies", + properties: `[{"type":"olm.package","value":{"packageName":"test","version":"1.0.0"}}]`, + wantIssues: 0, + }, + { + name: "has package required dependency", + properties: `[{"type":"olm.package.required","value":{"packageName":"dep","versionRange":">=1.0.0"}}]`, + wantIssues: 1, + }, + { + name: "has gvk required dependency", + properties: `[{"type":"olm.gvk.required","value":{"group":"example.com","kind":"Foo","version":"v1"}}]`, + wantIssues: 1, + }, + { + name: "has both dependency types", + properties: `[{"type":"olm.package.required","value":{"packageName":"dep1"}},{"type":"olm.gvk.required","value":{"group":"example.com","kind":"Foo","version":"v1"}}]`, + wantIssues: 2, + }, + { + name: "wrapped object format - no dependencies", + properties: `{"properties":[{"type":"olm.gvk","value":{"group":"example.com","kind":"Foo","version":"v1"}},{"type":"olm.package","value":{"packageName":"test","version":"1.0.0"}}]}`, + wantIssues: 0, + }, + { + name: "wrapped object format - has dependency", + properties: `{"properties":[{"type":"olm.package.required","value":{"packageName":"dep","versionRange":">=1.0.0"}}]}`, + wantIssues: 1, + }, + { + name: "invalid JSON", + properties: `not-json`, + wantIssues: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issues := checkNoDependencies(tt.properties) + assert.Len(t, issues, tt.wantIssues) + }) + } +} + +func TestCheckNoAPIServices(t *testing.T) { + tests := []struct { + name string + csv *operatorsv1alpha1.ClusterServiceVersion + wantIssues int + }{ + { + name: "no api service definitions", + csv: &operatorsv1alpha1.ClusterServiceVersion{ + Spec: operatorsv1alpha1.ClusterServiceVersionSpec{}, + }, + wantIssues: 0, + }, + { + name: "has owned api services", + csv: &operatorsv1alpha1.ClusterServiceVersion{ + Spec: operatorsv1alpha1.ClusterServiceVersionSpec{ + APIServiceDefinitions: operatorsv1alpha1.APIServiceDefinitions{ + Owned: []operatorsv1alpha1.APIServiceDescription{ + {Name: "test"}, + }, + }, + }, + }, + wantIssues: 1, + }, + { + name: "has required api services", + csv: &operatorsv1alpha1.ClusterServiceVersion{ + Spec: operatorsv1alpha1.ClusterServiceVersionSpec{ + APIServiceDefinitions: operatorsv1alpha1.APIServiceDefinitions{ + Required: []operatorsv1alpha1.APIServiceDescription{ + {Name: "test"}, + }, + }, + }, + }, + wantIssues: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issues := checkNoAPIServices(tt.csv) + assert.Len(t, issues, tt.wantIssues) + }) + } +} diff --git a/internal/operator-controller/migration/migration.go b/internal/operator-controller/migration/migration.go new file mode 100644 index 000000000..85cc177e7 --- /dev/null +++ b/internal/operator-controller/migration/migration.go @@ -0,0 +1,918 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/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" + "sigs.k8s.io/yaml" + + 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" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" +) + +const ( + fieldManager = "olm.operatorframework.io/migration" +) + +// 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/", +} + +// Backup holds serialized copies of resources for recovery. +type Backup struct { + Subscription *operatorsv1alpha1.Subscription + ClusterServiceVersion *operatorsv1alpha1.ClusterServiceVersion + Dir string // directory where backup files are stored +} + +// SaveToDisk writes the backup resources as YAML files to a directory. +// The directory is created under the given base path as /olm-migration-backup--. +func (b *Backup) SaveToDisk(basePath string) error { + timestamp := time.Now().Format("20060102-150405") + b.Dir = filepath.Join(basePath, fmt.Sprintf("olm-migration-backup-%s-%s", b.Subscription.Name, timestamp)) + + if err := os.MkdirAll(b.Dir, 0o750); err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + if err := writeYAML(filepath.Join(b.Dir, "subscription.yaml"), b.Subscription); err != nil { + return fmt.Errorf("failed to write Subscription backup: %w", err) + } + + if err := writeYAML(filepath.Join(b.Dir, "clusterserviceversion.yaml"), b.ClusterServiceVersion); err != nil { + return fmt.Errorf("failed to write CSV backup: %w", err) + } + + return nil +} + +func writeYAML(path string, obj interface{}) error { + data, err := yaml.Marshal(obj) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// Migrate performs the full migration of an OLMv0-managed operator to OLMv1. +// The steps follow the RFC ordering: +// 1. Profile the Operator +// 2. Determine Compatibility and Readiness +// 3. Determine Target ClusterCatalog +// 4. Prepare the Operator for Migration (backup + delete Sub/CSV) +// 5. Collect Operator Resources +// 6. Create ClusterExtensionRevision +// 7. Create ClusterExtension +// 8. Clean Up +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) + } + + if err := m.CheckReadiness(ctx, opts); err != nil { + return fmt.Errorf("readiness check failed: %w", err) + } + + propsJSON := csv.Annotations["operatorframework.io/properties"] + issues, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + return fmt.Errorf("compatibility check failed: %w", err) + } + if len(issues) > 0 { + return fmt.Errorf("operator is not compatible with OLMv1 migration (%d issues found)", len(issues)) + } + + 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) + if err != nil { + return fmt.Errorf("failed to backup resources: %w", 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 + + if err := m.CreateClusterExtensionRevision(ctx, opts, info); err != nil { + if recoverErr := m.RecoverBeforeCE(ctx, opts, backup); recoverErr != nil { + return fmt.Errorf("CER creation failed: %w; recovery also failed: %v", err, recoverErr) + } + return fmt.Errorf("CER 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, []CompatibilityIssue, error) { + if err := m.CheckReadiness(ctx, opts); err != nil { + return nil, nil, nil, err + } + + _, csv, ip, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + return nil, nil, nil, err + } + + propsJSON := csv.Annotations["operatorframework.io/properties"] + issues, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + return nil, nil, nil, err + } + + return csv, ip, issues, nil +} + +// BackupResources creates backup copies of the Subscription and CSV for recovery. +func (m *Migrator) BackupResources(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion) (*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) + } + + return &Backup{ + Subscription: sub.DeepCopy(), + ClusterServiceVersion: csv.DeepCopy(), + }, nil +} + +// PrepareForMigration shields the operator from OLMv0 reconciliation (RFC Step 4). +// 1. Ensure the installer ServiceAccount exists with cluster-admin binding +// 2. Delete the Subscription (prevents OLMv0 from creating new CSVs) +// 3. Delete the CSV with orphan cascading (detaches from owned resources) +func (m *Migrator) PrepareForMigration(ctx context.Context, opts Options, csv *operatorsv1alpha1.ClusterServiceVersion) error { + // Ensure installer ServiceAccount with cluster-admin binding + if err := m.EnsureInstallerServiceAccount(ctx, opts); err != nil { + return fmt.Errorf("failed to ensure installer ServiceAccount: %w", err) + } + + // 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 +} + +// EnsureInstallerServiceAccount creates the install namespace (if needed), the installer +// ServiceAccount, and binds it to cluster-admin. OLMv1 requires a ServiceAccount with +// cluster-admin privileges for the ClusterExtensionRevision to reconcile. +// +// If the install namespace differs from the subscription namespace, Pod Security Admission +// labels are copied from the subscription namespace to ensure the same security policy applies. +func (m *Migrator) EnsureInstallerServiceAccount(ctx context.Context, opts Options) error { + // Copy PSA labels from subscription namespace if install namespace differs + psaLabels, err := m.getPodSecurityLabels(ctx, opts.SubscriptionNamespace) + if err != nil { + return fmt.Errorf("failed to read PSA labels from namespace %s: %w", opts.SubscriptionNamespace, err) + } + + // Create install namespace if it doesn't exist + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.InstallNamespace, + Labels: psaLabels, + }, + } + if err := m.Client.Create(ctx, ns); err != nil { + if client.IgnoreAlreadyExists(err) != nil { + return fmt.Errorf("failed to create namespace %s: %w", opts.InstallNamespace, err) + } + // Namespace already exists — ensure PSA labels are applied + if len(psaLabels) > 0 { + if err := m.applyPodSecurityLabels(ctx, opts.InstallNamespace, psaLabels); err != nil { + return fmt.Errorf("failed to apply PSA labels to namespace %s: %w", opts.InstallNamespace, err) + } + } + } + + // Create ServiceAccount if it doesn't exist + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.ServiceAccountName(), + Namespace: opts.InstallNamespace, + }, + } + if err := m.Client.Create(ctx, sa); err != nil { + if client.IgnoreAlreadyExists(err) != nil { + return fmt.Errorf("failed to create ServiceAccount %s/%s: %w", opts.InstallNamespace, opts.ServiceAccountName(), err) + } + } + + // Create or update ClusterRoleBinding to cluster-admin. + // We use server-side apply to ensure the binding always has the correct subject, + // even if it already exists from a previous migration attempt with different settings. + crbName := fmt.Sprintf("%s-cluster-admin", opts.ServiceAccountName()) + crb := &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRoleBinding", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: crbName, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "cluster-admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: opts.ServiceAccountName(), + Namespace: opts.InstallNamespace, + }, + }, + } + crbData, err := json.Marshal(crb) + if err != nil { + return fmt.Errorf("failed to marshal ClusterRoleBinding: %w", err) + } + crbObj := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: crbName}} + if err := m.Client.Patch(ctx, crbObj, client.RawPatch(types.ApplyPatchType, crbData), client.ForceOwnership, client.FieldOwner(fieldManager)); err != nil { + return fmt.Errorf("failed to apply ClusterRoleBinding %s: %w", crbName, err) + } + + return nil +} + +const podSecurityLabelPrefix = "pod-security.kubernetes.io/" + +// getPodSecurityLabels reads Pod Security Admission labels from a namespace. +func (m *Migrator) getPodSecurityLabels(ctx context.Context, namespace string) (map[string]string, error) { + var ns corev1.Namespace + if err := m.Client.Get(ctx, types.NamespacedName{Name: namespace}, &ns); err != nil { + return nil, err + } + + psaLabels := make(map[string]string) + for k, v := range ns.Labels { + if strings.HasPrefix(k, podSecurityLabelPrefix) { + psaLabels[k] = v + } + } + return psaLabels, nil +} + +// applyPodSecurityLabels merges PSA labels onto an existing namespace without removing other labels. +func (m *Migrator) applyPodSecurityLabels(ctx context.Context, namespace string, psaLabels map[string]string) error { + var ns corev1.Namespace + if err := m.Client.Get(ctx, types.NamespacedName{Name: namespace}, &ns); err != nil { + return err + } + + if ns.Labels == nil { + ns.Labels = make(map[string]string) + } + + changed := false + for k, v := range psaLabels { + if ns.Labels[k] != v { + ns.Labels[k] = v + changed = true + } + } + + if changed { + return m.Client.Update(ctx, &ns) + } + return nil +} + +// RecoverFromBackup restores the Subscription from backup after a failed preparation or collection. +// This implements the RFC Recovery Before ClusterExtension Creation procedure. +func (m *Migrator) RecoverFromBackup(ctx context.Context, opts Options, backup *Backup) error { + if backup == nil { + return fmt.Errorf("no backup available for recovery") + } + + // Scrub server-set fields from the Subscription backup + sub := backup.Subscription.DeepCopy() + sub.ResourceVersion = "" + sub.UID = "" + sub.Generation = 0 + sub.CreationTimestamp = metav1.Time{} + sub.Status = operatorsv1alpha1.SubscriptionStatus{} + + // Update startingCSV to installedCSV if it was set + if sub.Spec.StartingCSV != "" { + 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) + } + + // Wait for Subscription to stabilize + 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 { + m.progress("Waiting for Subscription to appear...") + 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 CER creation fails (RFC Recovery Before ClusterExtension Creation). +// 1. Delete the CER with orphan cascading +// 2. Re-create the Subscription from backup +// 3. Wait for stabilization +func (m *Migrator) RecoverBeforeCE(ctx context.Context, opts Options, backup *Backup) error { + // Delete the failed CER with orphan cascading + cerName := fmt.Sprintf("%s-1", opts.ClusterExtensionName) + cer := &ocv1.ClusterExtensionRevision{} + cer.Name = cerName + if err := m.Client.Delete(ctx, cer, client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil { + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete CER during recovery: %w", err) + } + } + + return m.RecoverFromBackup(ctx, opts, backup) +} + +// CreateClusterExtensionRevision builds and creates a CER from the collected resources. +func (m *Migrator) CreateClusterExtensionRevision(ctx context.Context, opts Options, info *MigrationInfo) error { + cerName := fmt.Sprintf("%s-1", opts.ClusterExtensionName) + + // Build object list for phase sorting + cerObjects := make([]ocv1ac.ClusterExtensionRevisionObjectApplyConfiguration, 0, len(info.CollectedObjects)) + for _, obj := range info.CollectedObjects { + stripped := stripResource(obj) + cerObjects = append(cerObjects, *ocv1ac.ClusterExtensionRevisionObject(). + WithObject(stripped). + WithCollisionProtection(ocv1.CollisionProtectionNone)) + } + + // Sort objects into phases + phases := applier.PhaseSort(cerObjects) + + // Build the CER + cerSpec := ocv1ac.ClusterExtensionRevisionSpec(). + WithRevision(1). + WithCollisionProtection(ocv1.CollisionProtectionNone). + WithLifecycleState(ocv1.ClusterExtensionRevisionLifecycleStateActive). + WithPhases(phases...) + + // Labels: only OwnerKindKey and OwnerNameKey — this is how the CE controller + // discovers CERs belonging to a ClusterExtension (via label selector). + // All other metadata goes into annotations, matching buildClusterExtensionRevision + // in the boxcutter applier. + cerAnnotations := map[string]string{ + labels.ServiceAccountNameKey: opts.ServiceAccountName(), + labels.ServiceAccountNamespaceKey: opts.InstallNamespace, + labels.PackageNameKey: info.PackageName, + labels.BundleNameKey: info.BundleName, + labels.BundleVersionKey: info.Version, + } + if info.BundleImage != "" { + cerAnnotations[labels.BundleReferenceKey] = info.BundleImage + } + + cer := ocv1ac.ClusterExtensionRevision(cerName). + WithSpec(cerSpec). + WithLabels(map[string]string{ + labels.OwnerKindKey: ocv1.ClusterExtensionKind, + labels.OwnerNameKey: opts.ClusterExtensionName, + }). + WithAnnotations(cerAnnotations) + + // Apply via server-side apply + cerObj := &ocv1.ClusterExtensionRevision{} + cerObj.Name = cerName + + cerData, err := json.Marshal(cer) + if err != nil { + return fmt.Errorf("failed to marshal CER: %w", err) + } + + if err := m.Client.Patch(ctx, cerObj, client.RawPatch(types.ApplyPatchType, cerData), client.ForceOwnership, client.FieldOwner(fieldManager)); err != nil { + return fmt.Errorf("failed to apply ClusterExtensionRevision: %w", err) + } + + return m.WaitForRevisionAvailable(ctx, cerName) +} + +// WaitForRevisionAvailable waits for the CER to reach Available=True. +func (m *Migrator) WaitForRevisionAvailable(ctx context.Context, cerName string) error { + return wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var cer ocv1.ClusterExtensionRevision + if err := m.Client.Get(ctx, types.NamespacedName{Name: cerName}, &cer); err != nil { + m.progress(fmt.Sprintf("Waiting for CER %s (not found yet)", cerName)) + return false, err + } + + available := "" + progressing := "" + for _, c := range cer.Status.Conditions { + switch c.Type { + case ocv1.ClusterExtensionRevisionTypeAvailable: + if c.Status == metav1.ConditionTrue { + return true, nil + } + if c.Reason == ocv1.ClusterExtensionRevisionReasonBlocked { + return false, fmt.Errorf("ClusterExtensionRevision %s is blocked: %s", cerName, c.Message) + } + available = fmt.Sprintf("%s (%s)", c.Status, c.Reason) + case ocv1.ClusterExtensionRevisionTypeProgressing: + progressing = fmt.Sprintf("%s (%s)", c.Status, c.Reason) + } + } + + if available != "" || progressing != "" { + m.progress(fmt.Sprintf("CER %s — Available: %s, Progressing: %s", cerName, available, progressing)) + } else { + m.progress(fmt.Sprintf("Waiting for CER %s to be reconciled...", cerName)) + } + return false, nil + }) +} + +// CreateClusterExtension creates a CE that adopts the CER (RFC Step 7). +func (m *Migrator) CreateClusterExtension(ctx context.Context, opts Options, info *MigrationInfo) error { + ce := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.ClusterExtensionName, + }, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: opts.InstallNamespace, + ServiceAccount: ocv1.ServiceAccountReference{ + Name: opts.ServiceAccountName(), + }, + Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeCatalog, + Catalog: &ocv1.CatalogFilter{ + PackageName: info.PackageName, + }, + }, + }, + } + + // Version pinning strategy: + // - Manual approval (UpgradePending): pin to exact installed version — the user + // was controlling upgrades in OLMv0, so preserve that behavior. + // - Automatic approval (AtLatestKnown): don't pin — allow OLMv1 to auto-upgrade + // within the channel, matching OLMv0's automatic upgrade behavior. + if info.ManualApproval { + ce.Spec.Source.Catalog.Version = info.Version + } + + // RFC Step 7: set channel from Subscription (if set) + if info.Channel != "" { + ce.Spec.Source.Catalog.Channels = []string{info.Channel} + } + + // RFC Step 7: set catalog selector to pin to the resolved ClusterCatalog + if info.ResolvedCatalogName != "" { + ce.Spec.Source.Catalog.Selector = &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "olm.operatorframework.io/metadata.name": info.ResolvedCatalogName, + }, + } + } + + if err := m.Client.Create(ctx, ce); err != nil { + return fmt.Errorf("failed to create ClusterExtension: %w", err) + } + + return m.WaitForClusterExtensionAvailable(ctx, opts.ClusterExtensionName) +} + +// WaitForClusterExtensionAvailable waits for the CE to reach Installed=True. +func (m *Migrator) WaitForClusterExtensionAvailable(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 + } + + installed := "" + progressing := "" + for _, c := range ce.Status.Conditions { + switch c.Type { + case "Installed": + if c.Status == metav1.ConditionTrue { + return true, nil + } + installed = fmt.Sprintf("%s (%s)", c.Status, c.Reason) + case "Progressing": + progressing = fmt.Sprintf("%s (%s)", c.Status, c.Reason) + } + } + + if installed != "" || progressing != "" { + m.progress(fmt.Sprintf("CE %s — Installed: %s, Progressing: %s", ceName, installed, progressing)) + } else { + m.progress(fmt.Sprintf("Waiting for CE %s to be reconciled...", 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 (RFC Step 8). +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 + 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 if no other Subscriptions remain in the namespace. +// Per RFC Step 8.4, also handles aggregation ClusterRoles. +func (m *Migrator) cleanupOperatorGroup(ctx context.Context, opts Options) []CleanupAction { + var actions []CleanupAction + + // Check if other Subscriptions remain + 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 + } + + // No other Subscriptions — find and handle the OperatorGroup + 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] + + // Strip olm.owner and olm.managed labels from aggregation ClusterRoles + 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, + }) + } + + // Delete the OperatorGroup + 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..-). +// Returns the names of ClusterRoles that were updated. +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 +// but should be retained to avoid breaking existing RBAC. +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 CER. +func stripResource(obj unstructured.Unstructured) unstructured.Unstructured { + stripped := unstructured.Unstructured{Object: make(map[string]interface{})} + + // Keep only essential top-level fields + stripped.SetAPIVersion(obj.GetAPIVersion()) + stripped.SetKind(obj.GetKind()) + stripped.SetName(obj.GetName()) + if obj.GetNamespace() != "" { + stripped.SetNamespace(obj.GetNamespace()) + } + + // Keep labels + if labels := obj.GetLabels(); len(labels) > 0 { + stripped.SetLabels(labels) + } + + // Keep annotations, filtered + if annotations := obj.GetAnnotations(); len(annotations) > 0 { + filtered := filterAnnotations(annotations) + if len(filtered) > 0 { + stripped.SetAnnotations(filtered) + } + } + + // Keep spec + if spec, ok := obj.Object["spec"]; ok { + stripped.Object["spec"] = spec + // Strip nested annotations (e.g., in Deployment pod template) + stripNestedAnnotations(&stripped) + } + + // Keep data/stringData for ConfigMaps/Secrets + if data, ok := obj.Object["data"]; ok { + stripped.Object["data"] = data + } + if stringData, ok := obj.Object["stringData"]; ok { + stripped.Object["stringData"] = stringData + } + + // Keep rules for ClusterRole/Role + if rules, ok := obj.Object["rules"]; ok { + stripped.Object["rules"] = rules + } + + // Keep roleRef and subjects for bindings + if roleRef, ok := obj.Object["roleRef"]; ok { + stripped.Object["roleRef"] = roleRef + } + if subjects, ok := obj.Object["subjects"]; ok { + stripped.Object["subjects"] = subjects + } + + // Keep webhooks for webhook configurations + 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 deployment.kubernetes.io/ and kubectl.kubernetes.io/ +// annotations from nested metadata (e.g., Deployment spec.template.metadata.annotations). +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/internal/operator-controller/migration/migration_test.go b/internal/operator-controller/migration/migration_test.go new file mode 100644 index 000000000..bb0d8fd1f --- /dev/null +++ b/internal/operator-controller/migration/migration_test.go @@ -0,0 +1,148 @@ +package migration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestStripResource(t *testing.T) { + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + "namespace": "test-ns", + "uid": "abc-123", + "resourceVersion": "12345", + "generation": int64(3), + "creationTimestamp": "2024-01-01T00:00:00Z", + "ownerReferences": []interface{}{}, + "managedFields": []interface{}{}, + "labels": map[string]interface{}{ + "app": "test", + }, + "annotations": map[string]interface{}{ + "custom-annotation": "keep-this", + "kubectl.kubernetes.io/last-applied": "remove-this", + "deployment.kubernetes.io/revision": "remove-this", + "olm.operatorframework.io/installed-alongside-abc": "remove-this", + }, + }, + "spec": map[string]interface{}{ + "replicas": int64(1), + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "keep-me": "yes", + "kubectl.kubernetes.io/restartedAt": "remove-nested", + "deployment.kubernetes.io/something": "remove-nested", + }, + }, + }, + }, + "status": map[string]interface{}{ + "replicas": int64(1), + }, + }, + } + + stripped := stripResource(obj) + + // Should keep essential fields + assert.Equal(t, "apps/v1", stripped.GetAPIVersion()) + assert.Equal(t, "Deployment", stripped.GetKind()) + assert.Equal(t, "test-deployment", stripped.GetName()) + assert.Equal(t, "test-ns", stripped.GetNamespace()) + + // Should keep labels + assert.Equal(t, map[string]string{"app": "test"}, stripped.GetLabels()) + + // Should filter annotations + annotations := stripped.GetAnnotations() + assert.Contains(t, annotations, "custom-annotation") + assert.NotContains(t, annotations, "kubectl.kubernetes.io/last-applied") + assert.NotContains(t, annotations, "deployment.kubernetes.io/revision") + + // Should not have status, uid, resourceVersion, etc. + _, hasStatus := stripped.Object["status"] + assert.False(t, hasStatus) + assert.Empty(t, stripped.GetUID()) + assert.Empty(t, stripped.GetResourceVersion()) + assert.Empty(t, stripped.GetOwnerReferences()) + + // Should strip nested annotations + nestedAnnotations, found, _ := unstructured.NestedMap(stripped.Object, "spec", "template", "metadata", "annotations") + assert.True(t, found) + assert.Contains(t, nestedAnnotations, "keep-me") + assert.NotContains(t, nestedAnnotations, "kubectl.kubernetes.io/restartedAt") + assert.NotContains(t, nestedAnnotations, "deployment.kubernetes.io/something") +} + +func TestStripResourceClusterScoped(t *testing.T) { + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "test-role", + }, + "rules": []interface{}{ + map[string]interface{}{ + "apiGroups": []interface{}{""}, + "resources": []interface{}{"pods"}, + "verbs": []interface{}{"get", "list"}, + }, + }, + }, + } + + stripped := stripResource(obj) + assert.Equal(t, "ClusterRole", stripped.GetKind()) + assert.Equal(t, "test-role", stripped.GetName()) + assert.Empty(t, stripped.GetNamespace()) + assert.NotNil(t, stripped.Object["rules"]) +} + +func TestFilterAnnotations(t *testing.T) { + annotations := map[string]string{ + "custom": "keep", + "another.io/annotation": "keep", + "kubectl.kubernetes.io/last-applied": "remove", + "deployment.kubernetes.io/revision": "remove", + "olm.operatorframework.io/installed-alongside-x": "remove", + } + + filtered := filterAnnotations(annotations) + assert.Len(t, filtered, 2) + assert.Contains(t, filtered, "custom") + assert.Contains(t, filtered, "another.io/annotation") +} + +func TestOptionsApplyDefaults(t *testing.T) { + opts := Options{ + SubscriptionName: "my-operator", + SubscriptionNamespace: "operators", + } + opts.ApplyDefaults() + + assert.Equal(t, "my-operator", opts.ClusterExtensionName) + assert.Equal(t, "operators", opts.InstallNamespace) + assert.Equal(t, "my-operator-installer", opts.ServiceAccountName()) +} + +func TestOptionsApplyDefaultsWithOverrides(t *testing.T) { + opts := Options{ + SubscriptionName: "my-operator", + SubscriptionNamespace: "operators", + ClusterExtensionName: "custom-name", + InstallNamespace: "custom-ns", + } + opts.ApplyDefaults() + + assert.Equal(t, "custom-name", opts.ClusterExtensionName) + assert.Equal(t, "custom-ns", opts.InstallNamespace) + assert.Equal(t, "custom-name-installer", opts.ServiceAccountName()) +} diff --git a/internal/operator-controller/migration/readiness.go b/internal/operator-controller/migration/readiness.go new file mode 100644 index 000000000..924045ee8 --- /dev/null +++ b/internal/operator-controller/migration/readiness.go @@ -0,0 +1,91 @@ +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 that the Subscription is in a stable state with a successfully installed CSV. +// +// OLMv0 does NOT need to be scaled down. The migration safely coexists with running +// OLMv0 controllers because: +// - The csv-cleanup finalizer is removed before deleting the CSV, preventing OLMv0 cleanup logic +// - The Subscription is deleted first, stopping OLMv0 from reconciling this operator +// - All deletions use orphan cascading, preserving operator workloads +func (m *Migrator) CheckReadiness(ctx context.Context, opts Options) error { + if err := m.checkSubscriptionReady(ctx, opts); err != nil { + return fmt.Errorf("subscription readiness check failed: %w", err) + } + return nil +} + +func (m *Migrator) checkSubscriptionReady(ctx context.Context, opts Options) error { + var sub operatorsv1alpha1.Subscription + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: opts.SubscriptionName, + Namespace: opts.SubscriptionNamespace, + }, &sub); err != nil { + return fmt.Errorf("failed to get Subscription %s/%s: %w", opts.SubscriptionNamespace, opts.SubscriptionName, err) + } + + // RFC Step 2 - Subscription Criteria: status.state must be AtLatestKnown or UpgradePending + if sub.Status.State != operatorsv1alpha1.SubscriptionStateAtLatest && + sub.Status.State != operatorsv1alpha1.SubscriptionStateUpgradePending { + return fmt.Errorf("subscription state must be %q or %q, got %q", + operatorsv1alpha1.SubscriptionStateAtLatest, + operatorsv1alpha1.SubscriptionStateUpgradePending, + sub.Status.State) + } + + // Subscription must have an installedCSV — this is the CSV actually running on the cluster. + // currentCSV may differ (UpgradePending state) — that's fine, we migrate the installed version. + if sub.Status.InstalledCSV == "" { + return fmt.Errorf("subscription has no installedCSV set") + } + + // RFC Step 2 - Subscription Criteria: olm.generated-by must NOT be present + if _, ok := sub.Annotations["olm.generated-by"]; ok { + return fmt.Errorf("subscription has olm.generated-by annotation; this operator is a dependency of another operator and must be explicitly opted in for migration") + } + + // RFC Step 2 - Subscription Criteria: uniqueness — no other Subscription may reference the same package + var subList operatorsv1alpha1.SubscriptionList + if err := m.Client.List(ctx, &subList); err != nil { + return fmt.Errorf("failed to list Subscriptions: %w", err) + } + for _, other := range subList.Items { + if other.Name == sub.Name && other.Namespace == sub.Namespace { + continue + } + if other.Spec.Package == sub.Spec.Package { + return fmt.Errorf("another Subscription %s/%s references the same package %q; only one Subscription per package is allowed", + other.Namespace, other.Name, sub.Spec.Package) + } + } + + // RFC Step 2 - CSV Criteria: status.phase must be Succeeded, status.reason must be InstallSucceeded + csvName := sub.Status.InstalledCSV + var csv operatorsv1alpha1.ClusterServiceVersion + if err := m.Client.Get(ctx, types.NamespacedName{ + Name: csvName, + Namespace: opts.SubscriptionNamespace, + }, &csv); err != nil { + return fmt.Errorf("failed to get CSV %s: %w", csvName, err) + } + + if csv.Status.Phase != operatorsv1alpha1.CSVPhaseSucceeded { + return fmt.Errorf("csv %s phase must be %q, got %q", + csvName, operatorsv1alpha1.CSVPhaseSucceeded, csv.Status.Phase) + } + if csv.Status.Reason != operatorsv1alpha1.CSVReasonInstallSuccessful { + return fmt.Errorf("csv %s reason must be %q, got %q", + csvName, operatorsv1alpha1.CSVReasonInstallSuccessful, csv.Status.Reason) + } + + return nil +} diff --git a/internal/operator-controller/migration/scan.go b/internal/operator-controller/migration/scan.go new file mode 100644 index 000000000..9599ef92c --- /dev/null +++ b/internal/operator-controller/migration/scan.go @@ -0,0 +1,81 @@ +package migration + +import ( + "context" + "fmt" + + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +// 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 + Eligible bool + ReadinessError error + CompatibilityIssues []CompatibilityIssue +} + +// ScanAllSubscriptions discovers all Subscriptions on the cluster and checks each +// for migration eligibility (readiness + compatibility). +func (m *Migrator) ScanAllSubscriptions(ctx context.Context) ([]OperatorScanResult, error) { + var subList operatorsv1alpha1.SubscriptionList + if err := m.Client.List(ctx, &subList); err != nil { + return nil, fmt.Errorf("failed to list Subscriptions: %w", err) + } + + var results []OperatorScanResult + for _, sub := range subList.Items { + result := OperatorScanResult{ + SubscriptionName: sub.Name, + SubscriptionNamespace: sub.Namespace, + PackageName: sub.Spec.Package, + InstalledCSV: sub.Status.InstalledCSV, + State: string(sub.Status.State), + } + + 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)) + + // Check readiness + if err := m.CheckReadiness(ctx, opts); err != nil { + result.ReadinessError = err + results = append(results, result) + continue + } + + // Get CSV for compatibility checks + _, csv, _, err := m.GetCSVAndInstallPlan(ctx, opts) + if err != nil { + result.ReadinessError = fmt.Errorf("failed to get CSV: %w", err) + results = append(results, result) + continue + } + + result.Version = parseCSVVersion(csv) + + // Check compatibility + propsJSON := csv.Annotations["operatorframework.io/properties"] + issues, err := m.CheckCompatibility(ctx, opts, csv, propsJSON) + if err != nil { + result.ReadinessError = fmt.Errorf("compatibility check error: %w", err) + results = append(results, result) + continue + } + + result.CompatibilityIssues = issues + result.Eligible = len(issues) == 0 + results = append(results, result) + } + + return results, nil +} diff --git a/internal/operator-controller/migration/types.go b/internal/operator-controller/migration/types.go new file mode 100644 index 000000000..abe367099 --- /dev/null +++ b/internal/operator-controller/migration/types.go @@ -0,0 +1,67 @@ +package migration + +import ( + "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" +) + +// Options configures the migration process. +type Options struct { + SubscriptionName string + SubscriptionNamespace string + ClusterExtensionName string + InstallNamespace string +} + +// 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 + } +} + +// ServiceAccountName returns the generated installer service account name. +func (o *Options) ServiceAccountName() string { + return o.ClusterExtensionName + "-installer" +} + +// 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 (e.g., quay.io/org/catalog:latest) + ResolvedCatalogName string + CollectedObjects []unstructured.Unstructured +} + +// ProgressFunc is called periodically during wait operations to report status. +// The message describes the current state being waited on. +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) + } +}