Skip to content

Commit f67227b

Browse files
committed
feat: add experimental clusterctl migrate command
- Experimental migration support focuses on v1beta1 to v1beta2 conversions for core Cluster API resources Signed-off-by: Satyam Bhardwaj <sbhardwaj@mirantis.com>
1 parent 3f1bdf1 commit f67227b

File tree

11 files changed

+1350
-0
lines changed

11 files changed

+1350
-0
lines changed

cmd/clusterctl/cmd/migrate.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
"strings"
24+
25+
"github.com/pkg/errors"
26+
"github.com/spf13/cobra"
27+
28+
"k8s.io/apimachinery/pkg/runtime/schema"
29+
30+
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
31+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/migrate"
32+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
33+
)
34+
35+
type migrateOptions struct {
36+
output string
37+
toVersion string
38+
}
39+
40+
var migrateOpts = &migrateOptions{}
41+
42+
var supportedTargetVersions = []string{
43+
clusterv1.GroupVersion.Version,
44+
}
45+
46+
var migrateCmd = &cobra.Command{
47+
Use: "migrate [SOURCE]",
48+
Short: "EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions",
49+
Long: `EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions.
50+
51+
This command is EXPERIMENTAL and may be removed in a future release!
52+
53+
Scope and limitations:
54+
- Only cluster.x-k8s.io resources are converted
55+
- Other CAPI API groups are passed through unchanged
56+
- ClusterClass patches are not migrated
57+
- Field order may change and comments will be removed in output
58+
- API version references are dropped during conversion (except ClusterClass and external
59+
remediation references)
60+
61+
Examples:
62+
# Migrate from file to stdout
63+
clusterctl migrate cluster.yaml
64+
65+
# Migrate from stdin to stdout
66+
cat cluster.yaml | clusterctl migrate
67+
68+
# Explicitly specify target <VERSION>
69+
clusterctl migrate cluster.yaml --to-version <VERSION> --output migrated-cluster.yaml`,
70+
71+
Args: cobra.MaximumNArgs(1),
72+
RunE: func(cmd *cobra.Command, args []string) error {
73+
return runMigrate(args)
74+
},
75+
}
76+
77+
func init() {
78+
migrateCmd.Flags().StringVarP(&migrateOpts.output, "output", "o", "", "Output file path (default: stdout)")
79+
migrateCmd.Flags().StringVar(&migrateOpts.toVersion, "to-version", clusterv1.GroupVersion.Version, fmt.Sprintf("Target API version for migration (supported: %s)", strings.Join(supportedTargetVersions, ", ")))
80+
81+
RootCmd.AddCommand(migrateCmd)
82+
}
83+
84+
func isSupportedTargetVersion(version string) bool {
85+
for _, v := range supportedTargetVersions {
86+
if v == version {
87+
return true
88+
}
89+
}
90+
return false
91+
}
92+
93+
func runMigrate(args []string) error {
94+
if !isSupportedTargetVersion(migrateOpts.toVersion) {
95+
return errors.Errorf("invalid --to-version value %q: supported versions are %s", migrateOpts.toVersion, strings.Join(supportedTargetVersions, ", "))
96+
}
97+
98+
fmt.Fprint(os.Stderr, "WARNING: This command is EXPERIMENTAL and may be removed in a future release!")
99+
100+
var input io.Reader
101+
var inputName string
102+
103+
if len(args) == 0 {
104+
input = os.Stdin
105+
inputName = "stdin"
106+
} else {
107+
sourceFile := args[0]
108+
file, err := os.Open(sourceFile)
109+
if err != nil {
110+
return errors.Wrapf(err, "failed to open input file %q", sourceFile)
111+
}
112+
defer file.Close()
113+
input = file
114+
inputName = sourceFile
115+
}
116+
117+
// Determine output destination
118+
var output io.Writer
119+
var outputFile *os.File
120+
var err error
121+
122+
if migrateOpts.output == "" {
123+
output = os.Stdout
124+
} else {
125+
outputFile, err = os.Create(migrateOpts.output)
126+
if err != nil {
127+
return errors.Wrapf(err, "failed to create output file %q", migrateOpts.output)
128+
}
129+
defer outputFile.Close()
130+
output = outputFile
131+
}
132+
133+
// Create migration engine components
134+
parser := migrate.NewYAMLParser(scheme.Scheme)
135+
136+
targetGV := schema.GroupVersion{
137+
Group: clusterv1.GroupVersion.Group,
138+
Version: migrateOpts.toVersion,
139+
}
140+
141+
converter, err := migrate.NewConverter(targetGV)
142+
if err != nil {
143+
return errors.Wrap(err, "failed to create converter")
144+
}
145+
146+
engine, err := migrate.NewEngine(parser, converter)
147+
if err != nil {
148+
return errors.Wrap(err, "failed to create migration engine")
149+
}
150+
151+
opts := migrate.MigrationOptions{
152+
Input: input,
153+
Output: output,
154+
Errors: os.Stderr,
155+
ToVersion: migrateOpts.toVersion,
156+
}
157+
158+
result, err := engine.Migrate(opts)
159+
if err != nil {
160+
return errors.Wrap(err, "migration failed")
161+
}
162+
163+
if result.TotalResources > 0 {
164+
fmt.Fprintf(os.Stderr, "\nMigration completed:\n")
165+
fmt.Fprintf(os.Stderr, " Total resources processed: %d\n", result.TotalResources)
166+
fmt.Fprintf(os.Stderr, " Resources converted: %d\n", result.ConvertedCount)
167+
fmt.Fprintf(os.Stderr, " Resources skipped: %d\n", result.SkippedCount)
168+
169+
if result.ErrorCount > 0 {
170+
fmt.Fprintf(os.Stderr, " Resources with errors: %d\n", result.ErrorCount)
171+
}
172+
173+
if len(result.Warnings) > 0 {
174+
fmt.Fprintf(os.Stderr, " Warnings: %d\n", len(result.Warnings))
175+
}
176+
177+
fmt.Fprintf(os.Stderr, "\nSource: %s\n", inputName)
178+
if migrateOpts.output != "" {
179+
fmt.Fprintf(os.Stderr, "Output: %s\n", migrateOpts.output)
180+
}
181+
}
182+
183+
if result.ErrorCount > 0 {
184+
return errors.Errorf("migration completed with %d errors", result.ErrorCount)
185+
}
186+
187+
return nil
188+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
"github.com/pkg/errors"
23+
24+
"k8s.io/apimachinery/pkg/runtime"
25+
"k8s.io/apimachinery/pkg/runtime/schema"
26+
27+
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
28+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
29+
"sigs.k8s.io/controller-runtime/pkg/conversion"
30+
)
31+
32+
// Converter handles conversion of individual CAPI resources between API versions.
33+
type Converter struct {
34+
scheme *runtime.Scheme
35+
targetGV schema.GroupVersion
36+
targetGVKMap gvkConversionMap
37+
}
38+
39+
// gvkConversionMap caches conversions from a source GroupVersionKind to its target GroupVersionKind.
40+
type gvkConversionMap map[schema.GroupVersionKind]schema.GroupVersionKind
41+
42+
// ConversionResult represents the outcome of converting a single resource.
43+
type ConversionResult struct {
44+
Object runtime.Object
45+
// Converted indicates whether the object was actually converted
46+
Converted bool
47+
Error error
48+
Warnings []string
49+
}
50+
51+
// NewConverter creates a new resource converter using the clusterctl scheme.
52+
func NewConverter(targetGV schema.GroupVersion) (*Converter, error) {
53+
return &Converter{
54+
scheme: scheme.Scheme,
55+
targetGV: targetGV,
56+
targetGVKMap: make(gvkConversionMap),
57+
}, nil
58+
}
59+
60+
// ConvertResource converts a single resource to the target version.
61+
// Returns the converted object, or the original if no conversion is needed.
62+
func (c *Converter) ConvertResource(info ResourceInfo, obj runtime.Object) ConversionResult {
63+
gvk := info.GroupVersionKind
64+
65+
if gvk.Group == clusterv1.GroupVersion.Group && gvk.Version == c.targetGV.Version {
66+
return ConversionResult{
67+
Object: obj,
68+
Converted: false,
69+
Warnings: []string{fmt.Sprintf("Resource %s/%s is already at version %s", gvk.Kind, info.Name, c.targetGV.Version)},
70+
}
71+
}
72+
73+
if gvk.Group != clusterv1.GroupVersion.Group {
74+
return ConversionResult{
75+
Object: obj,
76+
Converted: false,
77+
Warnings: []string{fmt.Sprintf("Skipping non-%s resource: %s", clusterv1.GroupVersion.Group, gvk.String())},
78+
}
79+
}
80+
81+
targetGVK, err := c.getTargetGVK(gvk)
82+
if err != nil {
83+
return ConversionResult{
84+
Object: obj,
85+
Converted: false,
86+
Error: errors.Wrapf(err, "failed to determine target GVK for %s", gvk.String()),
87+
}
88+
}
89+
90+
// Check if the object is already typed
91+
// If it's typed and implements conversion.Convertible, use the custom ConvertTo method
92+
if convertible, ok := obj.(conversion.Convertible); ok {
93+
// Create a new instance of the target type
94+
targetObj, err := c.scheme.New(targetGVK)
95+
if err != nil {
96+
return ConversionResult{
97+
Object: obj,
98+
Converted: false,
99+
Error: errors.Wrapf(err, "failed to create target object for %s", targetGVK.String()),
100+
}
101+
}
102+
103+
// Check if the target object is a Hub
104+
if hub, ok := targetObj.(conversion.Hub); ok {
105+
if err := convertible.ConvertTo(hub); err != nil {
106+
return ConversionResult{
107+
Object: obj,
108+
Converted: false,
109+
Error: errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, c.targetGV.Version),
110+
}
111+
}
112+
113+
// Ensure the GVK is set on the converted object
114+
hubObj := hub.(runtime.Object)
115+
hubObj.GetObjectKind().SetGroupVersionKind(targetGVK)
116+
117+
return ConversionResult{
118+
Object: hubObj,
119+
Converted: true,
120+
Error: nil,
121+
Warnings: nil,
122+
}
123+
}
124+
}
125+
126+
// Use scheme-based conversion for all remaining cases
127+
convertedObj, err := c.scheme.ConvertToVersion(obj, targetGVK.GroupVersion())
128+
if err != nil {
129+
return ConversionResult{
130+
Object: obj,
131+
Converted: false,
132+
Error: errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, c.targetGV.Version),
133+
}
134+
}
135+
136+
return ConversionResult{
137+
Object: convertedObj,
138+
Converted: true,
139+
Error: nil,
140+
Warnings: nil,
141+
}
142+
}
143+
144+
// getTargetGVK returns the target GroupVersionKind for a given source GVK.
145+
func (c *Converter) getTargetGVK(sourceGVK schema.GroupVersionKind) (schema.GroupVersionKind, error) {
146+
// Check cache first
147+
if targetGVK, ok := c.targetGVKMap[sourceGVK]; ok {
148+
return targetGVK, nil
149+
}
150+
151+
// Create target GVK with same kind but target version
152+
targetGVK := schema.GroupVersionKind{
153+
Group: c.targetGV.Group,
154+
Version: c.targetGV.Version,
155+
Kind: sourceGVK.Kind,
156+
}
157+
158+
// Verify the target type exists in the scheme
159+
if !c.scheme.Recognizes(targetGVK) {
160+
return schema.GroupVersionKind{}, errors.Errorf("target GVK %s not recognized by scheme", targetGVK.String())
161+
}
162+
163+
// Cache for future use
164+
c.targetGVKMap[sourceGVK] = targetGVK
165+
166+
return targetGVK, nil
167+
}

0 commit comments

Comments
 (0)