Skip to content

Commit 1bf4039

Browse files
committed
feat: add clusterctl migrate command
- Experimental migration support focuses on v1beta1 to v1beta2 conversions for core Cluster API resources Signed-off-by: Satyam Bhardwaj <[email protected]>
1 parent 3f1bdf1 commit 1bf4039

File tree

8 files changed

+1377
-0
lines changed

8 files changed

+1377
-0
lines changed

cmd/clusterctl/cmd/migrate.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cmd
18+
19+
import (
20+
"fmt"
21+
"io"
22+
"os"
23+
24+
"github.com/spf13/cobra"
25+
26+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/migrate"
27+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
28+
)
29+
30+
type migrateOptions struct {
31+
output string
32+
toVersion string
33+
}
34+
35+
var migrateOpts = &migrateOptions{}
36+
37+
var migrateCmd = &cobra.Command{
38+
Use: "migrate [SOURCE]",
39+
Short: "EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions",
40+
Long: `EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions.
41+
42+
This command is EXPERIMENTAL and currently supports only cluster.x-k8s.io core resources
43+
(Cluster, MachineDeployment, etc.) with v1beta2 as the only supported target version.
44+
45+
SCOPE AND LIMITATIONS (Experimental Status):
46+
- Only cluster.x-k8s.io resources are converted
47+
- Other CAPI API groups are passed through unchanged
48+
- Currently only supports v1beta2 as target version (--to-version flag must be "v1beta2")
49+
- ClusterClass patches are not migrated
50+
- Field order may change and comments will be removed in output
51+
- API version references are dropped during conversion (except cluster class and external
52+
remediation references)
53+
54+
Examples:
55+
# Migrate from file to stdout
56+
clusterctl migrate cluster.yaml
57+
58+
# Migrate from stdin to stdout
59+
cat cluster.yaml | clusterctl migrate
60+
61+
# Migrate to output file
62+
clusterctl migrate cluster.yaml --output migrated-cluster.yaml
63+
64+
# Explicitly specify target version (currently only v1beta2 is supported)
65+
clusterctl migrate cluster.yaml --to-version v1beta2`,
66+
67+
Args: cobra.MaximumNArgs(1),
68+
RunE: func(cmd *cobra.Command, args []string) error {
69+
return runMigrate(args)
70+
},
71+
}
72+
73+
func init() {
74+
migrateCmd.Flags().StringVarP(&migrateOpts.output, "output", "o", "", "Output file path (default: stdout)")
75+
migrateCmd.Flags().StringVar(&migrateOpts.toVersion, "to-version", "v1beta2", "Target API version for migration (currently only v1beta2 is supported)")
76+
77+
RootCmd.AddCommand(migrateCmd)
78+
}
79+
80+
func runMigrate(args []string) error {
81+
if migrateOpts.toVersion != "v1beta2" {
82+
return fmt.Errorf("invalid --to-version value %q: currently only v1beta2 is supported", migrateOpts.toVersion)
83+
}
84+
85+
fmt.Fprintf(os.Stderr, "WARNING: This command is EXPERIMENTAL and currently supports only cluster.x-k8s.io resources.\n")
86+
fmt.Fprintf(os.Stderr, "Only v1beta2 is supported as target version. Other CAPI API groups are passed through unchanged.\n")
87+
fmt.Fprintf(os.Stderr, "See 'clusterctl migrate --help' for scope, limitations, and usage details.\n\n")
88+
89+
var input io.Reader
90+
var inputName string
91+
92+
if len(args) == 0 {
93+
input = os.Stdin
94+
inputName = "stdin"
95+
} else {
96+
sourceFile := args[0]
97+
file, err := os.Open(sourceFile)
98+
if err != nil {
99+
return fmt.Errorf("failed to open input file %q: %w", sourceFile, err)
100+
}
101+
defer file.Close()
102+
input = file
103+
inputName = sourceFile
104+
}
105+
106+
// Determine output destination
107+
var output io.Writer
108+
var outputFile *os.File
109+
var err error
110+
111+
if migrateOpts.output == "" {
112+
output = os.Stdout
113+
} else {
114+
outputFile, err = os.Create(migrateOpts.output)
115+
if err != nil {
116+
return fmt.Errorf("failed to create output file %q: %w", migrateOpts.output, err)
117+
}
118+
defer outputFile.Close()
119+
output = outputFile
120+
}
121+
122+
// Create migration engine components
123+
parser := migrate.NewYAMLParser(scheme.Scheme)
124+
converter, err := migrate.NewConverter()
125+
if err != nil {
126+
return fmt.Errorf("failed to create converter: %w", err)
127+
}
128+
129+
engine, err := migrate.NewEngine(parser, converter)
130+
if err != nil {
131+
return fmt.Errorf("failed to create migration engine: %w", err)
132+
}
133+
134+
opts := migrate.MigrationOptions{
135+
Input: input,
136+
Output: output,
137+
Errors: os.Stderr,
138+
ToVersion: migrateOpts.toVersion,
139+
}
140+
141+
result, err := engine.Migrate(opts)
142+
if err != nil {
143+
return fmt.Errorf("migration failed: %w", err)
144+
}
145+
146+
if result.TotalResources > 0 {
147+
fmt.Fprintf(os.Stderr, "\nMigration completed:\n")
148+
fmt.Fprintf(os.Stderr, " Total resources processed: %d\n", result.TotalResources)
149+
fmt.Fprintf(os.Stderr, " Resources converted: %d\n", result.ConvertedCount)
150+
fmt.Fprintf(os.Stderr, " Resources skipped: %d\n", result.SkippedCount)
151+
152+
if result.ErrorCount > 0 {
153+
fmt.Fprintf(os.Stderr, " Resources with errors: %d\n", result.ErrorCount)
154+
}
155+
156+
if len(result.Warnings) > 0 {
157+
fmt.Fprintf(os.Stderr, " Warnings: %d\n", len(result.Warnings))
158+
}
159+
160+
fmt.Fprintf(os.Stderr, "\nSource: %s\n", inputName)
161+
if migrateOpts.output != "" {
162+
fmt.Fprintf(os.Stderr, "Output: %s\n", migrateOpts.output)
163+
}
164+
}
165+
166+
if result.ErrorCount > 0 {
167+
return fmt.Errorf("migration completed with %d errors", result.ErrorCount)
168+
}
169+
170+
return nil
171+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package migrate
18+
19+
import (
20+
"fmt"
21+
22+
"k8s.io/apimachinery/pkg/runtime"
23+
"k8s.io/apimachinery/pkg/runtime/schema"
24+
"sigs.k8s.io/controller-runtime/pkg/conversion"
25+
26+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
27+
)
28+
29+
// Converter handles conversion of individual CAPI resources between API versions.
30+
type Converter struct {
31+
scheme *runtime.Scheme
32+
targetGV schema.GroupVersion
33+
targetGVKMap gvkConversionMap
34+
}
35+
36+
// gvkConversionMap caches conversions from a source GroupVersionKind to its target GroupVersionKind.
37+
type gvkConversionMap map[schema.GroupVersionKind]schema.GroupVersionKind
38+
39+
// ConversionResult represents the outcome of converting a single resource.
40+
type ConversionResult struct {
41+
Object runtime.Object
42+
// Converted indicates whether the object was actually converted
43+
Converted bool
44+
Error error
45+
Warnings []string
46+
}
47+
48+
// NewConverter creates a new resource converter using the clusterctl scheme.
49+
func NewConverter() (*Converter, error) {
50+
// Use the clusterctl scheme which has all CAPI versions registered
51+
targetGV := schema.GroupVersion{Group: "cluster.x-k8s.io", Version: "v1beta2"}
52+
53+
return &Converter{
54+
scheme: scheme.Scheme,
55+
targetGV: targetGV,
56+
targetGVKMap: make(gvkConversionMap),
57+
}, nil
58+
}
59+
60+
// CanConvert determines if a resource can be converted.
61+
func (c *Converter) CanConvert(info ResourceInfo, obj runtime.Object) bool {
62+
gvk := info.GroupVersionKind
63+
64+
if gvk.Group != "cluster.x-k8s.io" {
65+
return false
66+
}
67+
68+
if gvk.Version == c.targetGV.Version {
69+
return false
70+
}
71+
72+
return true
73+
}
74+
75+
// ConvertResource converts a single resource to the target version.
76+
// Returns the converted object, or the original if no conversion is needed.
77+
func (c *Converter) ConvertResource(info ResourceInfo, obj runtime.Object) ConversionResult {
78+
gvk := info.GroupVersionKind
79+
80+
if gvk.Group == "cluster.x-k8s.io" && gvk.Version == c.targetGV.Version {
81+
return ConversionResult{
82+
Object: obj,
83+
Converted: false,
84+
Warnings: []string{fmt.Sprintf("Resource %s/%s is already at version %s", gvk.Kind, info.Name, c.targetGV.Version)},
85+
}
86+
}
87+
88+
if gvk.Group != "cluster.x-k8s.io" {
89+
return ConversionResult{
90+
Object: obj,
91+
Converted: false,
92+
Warnings: []string{fmt.Sprintf("Skipping non-cluster.x-k8s.io resource: %s", gvk.String())},
93+
}
94+
}
95+
96+
targetGVK, err := c.getTargetGVK(gvk)
97+
if err != nil {
98+
return ConversionResult{
99+
Object: obj,
100+
Converted: false,
101+
Error: fmt.Errorf("failed to determine target GVK for %s: %w", gvk.String(), err),
102+
}
103+
}
104+
105+
// Check if the object is already typed
106+
// If it's typed and implements conversion.Convertible, use the custom ConvertTo method
107+
if convertible, ok := obj.(conversion.Convertible); ok {
108+
// Create a new instance of the target type
109+
targetObj, err := c.scheme.New(targetGVK)
110+
if err != nil {
111+
return ConversionResult{
112+
Object: obj,
113+
Converted: false,
114+
Error: fmt.Errorf("failed to create target object for %s: %w", targetGVK.String(), err),
115+
}
116+
}
117+
118+
// Check if the target object is a Hub
119+
if hub, ok := targetObj.(conversion.Hub); ok {
120+
if err := convertible.ConvertTo(hub); err != nil {
121+
return ConversionResult{
122+
Object: obj,
123+
Converted: false,
124+
Error: fmt.Errorf("failed to convert %s from %s to %s: %w", gvk.Kind, gvk.Version, c.targetGV.Version, err),
125+
}
126+
}
127+
128+
// Ensure the GVK is set on the converted object
129+
hubObj := hub.(runtime.Object)
130+
hubObj.GetObjectKind().SetGroupVersionKind(targetGVK)
131+
132+
return ConversionResult{
133+
Object: hubObj,
134+
Converted: true,
135+
Error: nil,
136+
Warnings: nil,
137+
}
138+
}
139+
}
140+
141+
// Use scheme-based conversion for all remaining cases
142+
convertedObj, err := c.scheme.ConvertToVersion(obj, targetGVK.GroupVersion())
143+
if err != nil {
144+
return ConversionResult{
145+
Object: obj,
146+
Converted: false,
147+
Error: fmt.Errorf("failed to convert %s from %s to %s: %w", gvk.Kind, gvk.Version, c.targetGV.Version, err),
148+
}
149+
}
150+
151+
return ConversionResult{
152+
Object: convertedObj,
153+
Converted: true,
154+
Error: nil,
155+
Warnings: nil,
156+
}
157+
}
158+
159+
// getTargetGVK returns the target GroupVersionKind for a given source GVK.
160+
func (c *Converter) getTargetGVK(sourceGVK schema.GroupVersionKind) (schema.GroupVersionKind, error) {
161+
// Check cache first
162+
if targetGVK, ok := c.targetGVKMap[sourceGVK]; ok {
163+
return targetGVK, nil
164+
}
165+
166+
// Create target GVK with same kind but target version
167+
targetGVK := schema.GroupVersionKind{
168+
Group: c.targetGV.Group,
169+
Version: c.targetGV.Version,
170+
Kind: sourceGVK.Kind,
171+
}
172+
173+
// Verify the target type exists in the scheme
174+
if !c.scheme.Recognizes(targetGVK) {
175+
return schema.GroupVersionKind{}, fmt.Errorf("target GVK %s not recognized by scheme", targetGVK.String())
176+
}
177+
178+
// Cache for future use
179+
c.targetGVKMap[sourceGVK] = targetGVK
180+
181+
return targetGVK, nil
182+
}

0 commit comments

Comments
 (0)