Skip to content

Commit 8df084d

Browse files
tmshortclaude
andcommitted
Add example CLIs exercising the migration library (Phase 4)
Adds two example CLI binaries under migration/examples/cmd/ that demonstrate the migration library API. Per the design, these are prototype consumers — not production delivery artifacts. Production CLIs (oc plugin, Console) will import migration/pkg/* directly. migrate-operators-v0-to-v1: check <operator|-all> [-n ns] — readiness + compat + catalog check convert <operator|-all> [-n ns] — full migration; --dry-run, --backup, --delete-operatorgroup, --continue- on-error, --acknowledge-* flags rollback <ce|-all> — restore to OLMv0 management cleanup <ce|-all> — finish partial migration (Conflict) migrate-catalogs-v0-to-v1: [flags] — --dry-run, --delete-catalogsource, --acknowledge-priority-overflow Both CLIs use the standard clientcmd kubeconfig chain (explicit flag → KUBECONFIG env → ~/.kube/config → in-cluster) for HCP compatibility. Built with: make build Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Todd Short <tshort@redhat.com>
1 parent 42b01da commit 8df084d

9 files changed

Lines changed: 1044 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.26.5
55
require (
66
github.com/operator-framework/api v0.45.0
77
github.com/operator-framework/operator-controller v1.11.0
8+
github.com/spf13/cobra v1.10.2
89
k8s.io/api v0.36.2
910
k8s.io/apiextensions-apiserver v0.36.2
1011
k8s.io/apimachinery v0.36.2
@@ -37,12 +38,14 @@ require (
3738
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
3839
github.com/google/gnostic-models v0.7.1 // indirect
3940
github.com/google/uuid v1.6.0 // indirect
41+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4042
github.com/json-iterator/go v1.1.12 // indirect
4143
github.com/kr/text v0.2.0 // indirect
4244
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4345
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
4446
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
4547
github.com/sirupsen/logrus v1.9.4 // indirect
48+
github.com/spf13/pflag v1.0.10 // indirect
4649
github.com/x448/float16 v0.8.4 // indirect
4750
go.yaml.in/yaml/v2 v2.4.4 // indirect
4851
go.yaml.in/yaml/v3 v3.0.4 // indirect

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
1010
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
1111
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
1212
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
13+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1314
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
1415
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1516
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -109,10 +110,12 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
109110
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
110111
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
111112
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
113+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
112114
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
113115
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
114116
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
115117
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
118+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
116119
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
117120
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
118121
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// migrate-catalogs-v0-to-v1 migrates OLMv0 CatalogSources to OLMv1 ClusterCatalogs.
2+
// Run this before migrate-operators-v0-to-v1.
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os"
8+
9+
"github.com/spf13/cobra"
10+
"k8s.io/apimachinery/pkg/runtime"
11+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
12+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
13+
"k8s.io/client-go/tools/clientcmd"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
15+
16+
operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1"
17+
ocv1 "github.com/operator-framework/operator-controller/api/v1"
18+
19+
"github.com/operator-framework/library-olm/migration/pkg/catalogmigration"
20+
)
21+
22+
var scheme = runtime.NewScheme()
23+
24+
func init() {
25+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
26+
utilruntime.Must(ocv1.AddToScheme(scheme))
27+
utilruntime.Must(operatorsv1alpha1.AddToScheme(scheme))
28+
}
29+
30+
var (
31+
kubeconfig string
32+
dryRun bool
33+
deleteCatalogSource bool
34+
acknowledgePriorityOverflow bool
35+
)
36+
37+
var rootCmd = &cobra.Command{
38+
Use: "migrate-catalogs-v0-to-v1",
39+
Short: "Migrate OLMv0 CatalogSources to OLMv1 ClusterCatalogs",
40+
Long: `Scans all CatalogSources across all namespaces and creates
41+
corresponding OLMv1 ClusterCatalogs.
42+
43+
Only grpc-type CatalogSources with a spec.image are migratable.
44+
configmap, internal, and address-only CatalogSources are reported as not migratable.
45+
46+
Run this before migrate-operators-v0-to-v1.
47+
48+
Examples:
49+
migrate-catalogs-v0-to-v1
50+
migrate-catalogs-v0-to-v1 --dry-run
51+
migrate-catalogs-v0-to-v1 --delete-catalogsource`,
52+
RunE: runMigrateCatalogs,
53+
}
54+
55+
func init() {
56+
rootCmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file")
57+
rootCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be created without modifying the cluster")
58+
rootCmd.Flags().BoolVar(&deleteCatalogSource, "delete-catalogsource", false, "Delete source CatalogSource after migration (only when no Subscription references it)")
59+
rootCmd.Flags().BoolVar(&acknowledgePriorityOverflow, "acknowledge-priority-overflow", false, "Cap out-of-range priority at MaxInt32/MinInt32 and proceed")
60+
}
61+
62+
func main() {
63+
if err := rootCmd.Execute(); err != nil {
64+
os.Exit(1)
65+
}
66+
}
67+
68+
func newClient() (client.Client, error) {
69+
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
70+
if kubeconfig != "" {
71+
loadingRules.ExplicitPath = kubeconfig
72+
}
73+
74+
kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
75+
loadingRules,
76+
&clientcmd.ConfigOverrides{},
77+
)
78+
79+
restConfig, err := kubeConfig.ClientConfig()
80+
if err != nil {
81+
return nil, fmt.Errorf("failed to get REST config: %w", err)
82+
}
83+
84+
c, err := client.New(restConfig, client.Options{Scheme: scheme})
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to create client: %w", err)
87+
}
88+
return c, nil
89+
}
90+
91+
func runMigrateCatalogs(cmd *cobra.Command, _ []string) error {
92+
c, err := newClient()
93+
if err != nil {
94+
return err
95+
}
96+
97+
cm := catalogmigration.NewCatalogMigrator(c)
98+
opts := catalogmigration.CatalogMigratorOptions{
99+
DryRun: dryRun,
100+
DeleteCatalogSource: deleteCatalogSource,
101+
AcknowledgePriorityOverflow: acknowledgePriorityOverflow,
102+
}
103+
104+
if dryRun {
105+
fmt.Printf("\n🔍 Dry run — no cluster changes will be made.\n\n")
106+
} else {
107+
fmt.Printf("\n🔄 Migrating CatalogSources to ClusterCatalogs...\n\n")
108+
}
109+
110+
results, err := cm.MigrateCatalogs(cmd.Context(), opts)
111+
if err != nil {
112+
return fmt.Errorf("catalog migration failed: %w", err)
113+
}
114+
115+
// Print results grouped by status
116+
var created, adopted, skipped, errored, dryResults []catalogmigration.CatalogMigrationResult
117+
for _, r := range results {
118+
switch r.Status {
119+
case "created":
120+
created = append(created, r)
121+
case "adopted":
122+
adopted = append(adopted, r)
123+
case "skipped":
124+
skipped = append(skipped, r)
125+
case "error":
126+
errored = append(errored, r)
127+
case "dry-run":
128+
dryResults = append(dryResults, r)
129+
}
130+
}
131+
132+
if len(dryResults) > 0 {
133+
fmt.Printf("Would migrate:\n")
134+
for _, r := range dryResults {
135+
fmt.Printf(" %-40s → %s\n %s\n",
136+
r.CatalogSourceNamespace+"/"+r.CatalogSourceName,
137+
r.ClusterCatalogName, r.Reason)
138+
}
139+
}
140+
141+
if len(created) > 0 {
142+
fmt.Printf("\n✅ Created (%d):\n", len(created))
143+
for _, r := range created {
144+
fmt.Printf(" ✓ %s/%s → ClusterCatalog/%s\n",
145+
r.CatalogSourceNamespace, r.CatalogSourceName, r.ClusterCatalogName)
146+
}
147+
}
148+
149+
if len(adopted) > 0 {
150+
fmt.Printf("\n✅ Adopted existing (%d):\n", len(adopted))
151+
for _, r := range adopted {
152+
fmt.Printf(" ✓ %s/%s → ClusterCatalog/%s\n",
153+
r.CatalogSourceNamespace, r.CatalogSourceName, r.ClusterCatalogName)
154+
}
155+
}
156+
157+
if len(skipped) > 0 {
158+
fmt.Printf("\n⏭ Skipped (%d):\n", len(skipped))
159+
for _, r := range skipped {
160+
fmt.Printf(" - %s/%s: %s\n",
161+
r.CatalogSourceNamespace, r.CatalogSourceName, r.Reason)
162+
}
163+
}
164+
165+
if len(errored) > 0 {
166+
fmt.Printf("\n❌ Errors (%d):\n", len(errored))
167+
for _, r := range errored {
168+
fmt.Printf(" ✗ %s/%s: %s\n",
169+
r.CatalogSourceNamespace, r.CatalogSourceName, r.Reason)
170+
}
171+
return fmt.Errorf("%d catalog source(s) failed to migrate", len(errored))
172+
}
173+
174+
total := len(created) + len(adopted)
175+
if total > 0 {
176+
fmt.Printf("\n✅ Done: %d ClusterCatalog(s) ready\n\n", total)
177+
} else if dryRun {
178+
fmt.Printf("\nDry run complete.\n\n")
179+
} else {
180+
fmt.Printf("\nNo migratable CatalogSources found.\n\n")
181+
}
182+
183+
return nil
184+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/operator-framework/library-olm/migration/pkg/migration"
10+
)
11+
12+
var (
13+
checkSubscriptionNamespace string
14+
checkAll bool
15+
)
16+
17+
var checkCmd = &cobra.Command{
18+
Use: "check [operator-name]",
19+
Short: "Check readiness and compatibility without performing migration",
20+
Long: `Runs all pre-migration checks (readiness and compatibility) and reports
21+
any issues that would prevent migration. Does not modify any cluster resources.
22+
23+
Target is a Subscription name (with -n namespace), or --all to scan the cluster.
24+
25+
Examples:
26+
migrate-operators-v0-to-v1 check my-operator -n operators
27+
migrate-operators-v0-to-v1 check --all`,
28+
Args: cobra.MaximumNArgs(1),
29+
RunE: runCheck,
30+
}
31+
32+
func init() {
33+
checkCmd.Flags().StringVarP(&checkSubscriptionNamespace, "namespace", "n", "", "Subscription namespace (required without --all)")
34+
checkCmd.Flags().BoolVar(&checkAll, "all", false, "Check all Subscriptions on the cluster")
35+
}
36+
37+
func runCheck(cmd *cobra.Command, args []string) error { //nolint:nestif
38+
if checkAll && len(args) > 0 {
39+
return fmt.Errorf("cannot specify both an operator name and --all")
40+
}
41+
if !checkAll && len(args) == 0 {
42+
return fmt.Errorf("specify an operator name or --all")
43+
}
44+
45+
c, restCfg, err := newClient()
46+
if err != nil {
47+
return err
48+
}
49+
50+
m := migration.NewMigrator(c, restCfg)
51+
m.Progress = progressFunc
52+
ctx := cmd.Context()
53+
54+
if checkAll {
55+
fmt.Printf("\n%s%s🔎 Scanning all Subscriptions...%s\n", colorBold, colorCyan, colorReset)
56+
startProgress()
57+
results, err := m.ScanAllSubscriptions(ctx)
58+
clearProgress()
59+
if err != nil {
60+
return fmt.Errorf("scan failed: %w", err)
61+
}
62+
migration.PrintScanSummary(results, func(format string, a ...interface{}) {
63+
fmt.Printf(format, a...)
64+
})
65+
return nil
66+
}
67+
68+
operatorName := args[0]
69+
if checkSubscriptionNamespace == "" {
70+
return fmt.Errorf("-n/--namespace is required")
71+
}
72+
73+
fmt.Printf("\n%s%s🔍 Pre-migration checks for %s/%s%s\n", colorBold, colorCyan, checkSubscriptionNamespace, operatorName, colorReset)
74+
75+
opts := migration.Options{
76+
SubscriptionName: operatorName,
77+
SubscriptionNamespace: checkSubscriptionNamespace,
78+
}
79+
opts.ApplyDefaults()
80+
81+
sectionHeader("Readiness Checks")
82+
readiness, readinessErr := m.CheckReadiness(ctx, opts)
83+
if readinessErr != nil {
84+
fail(fmt.Sprintf("Could not run readiness checks: %v", readinessErr))
85+
} else {
86+
printCheckResults(readiness.Checks)
87+
}
88+
89+
sectionHeader("Compatibility Checks")
90+
_, csv, _, profileErr := m.GetCSVAndInstallPlan(ctx, opts)
91+
if profileErr != nil { //nolint:nestif
92+
fail(fmt.Sprintf("Could not profile operator: %v", profileErr))
93+
} else {
94+
propsJSON := csv.Annotations["operatorframework.io/properties"]
95+
compat, compatErr := m.CheckCompatibility(ctx, opts, csv, propsJSON)
96+
if compatErr != nil {
97+
fail(fmt.Sprintf("Could not run compatibility checks: %v", compatErr))
98+
} else {
99+
printCheckResults(compat.Checks)
100+
}
101+
102+
sectionHeader("ClusterCatalog Availability")
103+
bundleInfo, _ := m.GetBundleInfo(ctx, opts, csv, nil)
104+
if bundleInfo != nil {
105+
catalogName, catalogErr := m.ResolveClusterCatalog(ctx, bundleInfo, restCfg)
106+
if catalogErr != nil {
107+
var notFound *migration.PackageNotFoundError
108+
if errors.As(catalogErr, &notFound) {
109+
warn(fmt.Sprintf("No ClusterCatalog found for package %q — run migrate-catalogs-v0-to-v1 first", bundleInfo.PackageName))
110+
} else {
111+
warn(fmt.Sprintf("Catalog resolution error: %v", catalogErr))
112+
}
113+
} else {
114+
success(fmt.Sprintf("ClusterCatalog available: %s", catalogName))
115+
}
116+
}
117+
}
118+
119+
fmt.Println()
120+
return nil
121+
}

0 commit comments

Comments
 (0)