From f0dd1205e51bdd59b1e2398ba5cd29f57e629a11 Mon Sep 17 00:00:00 2001 From: Satyam Bhardwaj Date: Fri, 10 Oct 2025 00:04:09 +0530 Subject: [PATCH 1/2] 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 --- cmd/clusterctl/cmd/migrate.go | 189 ++++++++++++++ cmd/clusterctl/internal/migrate/converter.go | 166 +++++++++++++ .../internal/migrate/converter_test.go | 135 ++++++++++ cmd/clusterctl/internal/migrate/doc.go | 19 ++ cmd/clusterctl/internal/migrate/engine.go | 182 ++++++++++++++ .../internal/migrate/engine_test.go | 233 ++++++++++++++++++ cmd/clusterctl/internal/migrate/parser.go | 216 ++++++++++++++++ .../internal/migrate/parser_test.go | 172 +++++++++++++ docs/book/src/SUMMARY.md | 1 + docs/book/src/clusterctl/commands/commands.md | 1 + docs/book/src/clusterctl/commands/migrate.md | 41 +++ 11 files changed, 1355 insertions(+) create mode 100644 cmd/clusterctl/cmd/migrate.go create mode 100644 cmd/clusterctl/internal/migrate/converter.go create mode 100644 cmd/clusterctl/internal/migrate/converter_test.go create mode 100644 cmd/clusterctl/internal/migrate/doc.go create mode 100644 cmd/clusterctl/internal/migrate/engine.go create mode 100644 cmd/clusterctl/internal/migrate/engine_test.go create mode 100644 cmd/clusterctl/internal/migrate/parser.go create mode 100644 cmd/clusterctl/internal/migrate/parser_test.go create mode 100644 docs/book/src/clusterctl/commands/migrate.md diff --git a/cmd/clusterctl/cmd/migrate.go b/cmd/clusterctl/cmd/migrate.go new file mode 100644 index 000000000000..d7be2884cc9e --- /dev/null +++ b/cmd/clusterctl/cmd/migrate.go @@ -0,0 +1,189 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/migrate" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" +) + +type migrateOptions struct { + output string + toVersion string +} + +var migrateOpts = &migrateOptions{} + +var supportedTargetVersions = []string{ + clusterv1.GroupVersion.Version, +} + +var migrateCmd = &cobra.Command{ + Use: "migrate [SOURCE]", + Short: "EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions", + Long: `EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions. + +This command is EXPERIMENTAL and may be removed in a future release! + +Scope and limitations: +- Only cluster.x-k8s.io resources are converted +- Other CAPI API groups are passed through unchanged +- ClusterClass patches are not migrated +- Field order may change and comments will be removed in output +- API version references are dropped during conversion (except ClusterClass and external + remediation references) + +Examples: + # Migrate from file to stdout + clusterctl migrate cluster.yaml + + # Migrate from stdin to stdout + cat cluster.yaml | clusterctl migrate + + # Explicitly specify target + clusterctl migrate cluster.yaml --to-version --output migrated-cluster.yaml`, + + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runMigrate(args) + }, +} + +func init() { + migrateCmd.Flags().StringVarP(&migrateOpts.output, "output", "o", "", "Output file path (default: stdout)") + migrateCmd.Flags().StringVar(&migrateOpts.toVersion, "to-version", clusterv1.GroupVersion.Version, fmt.Sprintf("Target API version for migration (supported: %s)", strings.Join(supportedTargetVersions, ", "))) + + RootCmd.AddCommand(migrateCmd) +} + +func isSupportedTargetVersion(version string) bool { + for _, v := range supportedTargetVersions { + if v == version { + return true + } + } + return false +} + +func runMigrate(args []string) error { + if !isSupportedTargetVersion(migrateOpts.toVersion) { + return errors.Errorf("invalid --to-version value %q: supported versions are %s", migrateOpts.toVersion, strings.Join(supportedTargetVersions, ", ")) + } + + fmt.Fprint(os.Stderr, "WARNING: This command is EXPERIMENTAL and may be removed in a future release!") + + var input io.Reader + var inputName string + + if len(args) == 0 { + input = os.Stdin + inputName = "stdin" + } else { + sourceFile := args[0] + // #nosec G304 + // command accepts user-provided file path by design + file, err := os.Open(sourceFile) + if err != nil { + return errors.Wrapf(err, "failed to open input file %q", sourceFile) + } + defer file.Close() + input = file + inputName = sourceFile + } + + // Determine output destination + var output io.Writer + var outputFile *os.File + var err error + + if migrateOpts.output == "" { + output = os.Stdout + } else { + outputFile, err = os.Create(migrateOpts.output) + if err != nil { + return errors.Wrapf(err, "failed to create output file %q", migrateOpts.output) + } + defer outputFile.Close() + output = outputFile + } + + // Create migration engine components + parser := migrate.NewYAMLParser(scheme.Scheme) + + targetGV := schema.GroupVersion{ + Group: clusterv1.GroupVersion.Group, + Version: migrateOpts.toVersion, + } + + converter, err := migrate.NewConverter(targetGV) + if err != nil { + return errors.Wrap(err, "failed to create converter") + } + + engine, err := migrate.NewEngine(parser, converter) + if err != nil { + return errors.Wrap(err, "failed to create migration engine") + } + + opts := migrate.MigrationOptions{ + Input: input, + Output: output, + Errors: os.Stderr, + ToVersion: migrateOpts.toVersion, + } + + result, err := engine.Migrate(opts) + if err != nil { + return errors.Wrap(err, "migration failed") + } + + if result.TotalResources > 0 { + fmt.Fprintf(os.Stderr, "\nMigration completed:\n") + fmt.Fprintf(os.Stderr, " Total resources processed: %d\n", result.TotalResources) + fmt.Fprintf(os.Stderr, " Resources converted: %d\n", result.ConvertedCount) + fmt.Fprintf(os.Stderr, " Resources skipped: %d\n", result.SkippedCount) + + if result.ErrorCount > 0 { + fmt.Fprintf(os.Stderr, " Resources with errors: %d\n", result.ErrorCount) + } + + if len(result.Warnings) > 0 { + fmt.Fprintf(os.Stderr, " Warnings: %d\n", len(result.Warnings)) + } + + fmt.Fprintf(os.Stderr, "\nSource: %s\n", inputName) + if migrateOpts.output != "" { + fmt.Fprintf(os.Stderr, "Output: %s\n", migrateOpts.output) + } + } + + if result.ErrorCount > 0 { + return errors.Errorf("migration completed with %d errors", result.ErrorCount) + } + + return nil +} diff --git a/cmd/clusterctl/internal/migrate/converter.go b/cmd/clusterctl/internal/migrate/converter.go new file mode 100644 index 000000000000..8c56c2a80c46 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/converter.go @@ -0,0 +1,166 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrate + +import ( + "fmt" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/conversion" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" +) + +// Converter handles conversion of individual CAPI resources between API versions. +type Converter struct { + scheme *runtime.Scheme + targetGV schema.GroupVersion + targetGVKMap gvkConversionMap +} + +// gvkConversionMap caches conversions from a source GroupVersionKind to its target GroupVersionKind. +type gvkConversionMap map[schema.GroupVersionKind]schema.GroupVersionKind + +// ConversionResult represents the outcome of converting a single resource. +type ConversionResult struct { + Object runtime.Object + // Converted indicates whether the object was actually converted + Converted bool + Error error + Warnings []string +} + +// NewConverter creates a new resource converter using the clusterctl scheme. +func NewConverter(targetGV schema.GroupVersion) (*Converter, error) { + return &Converter{ + scheme: scheme.Scheme, + targetGV: targetGV, + targetGVKMap: make(gvkConversionMap), + }, nil +} + +// ConvertResource converts a single resource to the target version. +// Returns the converted object, or the original if no conversion is needed. +func (c *Converter) ConvertResource(info ResourceInfo, obj runtime.Object) ConversionResult { + gvk := info.GroupVersionKind + + if gvk.Group == clusterv1.GroupVersion.Group && gvk.Version == c.targetGV.Version { + return ConversionResult{ + Object: obj, + Converted: false, + Warnings: []string{fmt.Sprintf("Resource %s/%s is already at version %s", gvk.Kind, info.Name, c.targetGV.Version)}, + } + } + + if gvk.Group != clusterv1.GroupVersion.Group { + return ConversionResult{ + Object: obj, + Converted: false, + Warnings: []string{fmt.Sprintf("Skipping non-%s resource: %s", clusterv1.GroupVersion.Group, gvk.String())}, + } + } + + targetGVK, err := c.getTargetGVK(gvk) + if err != nil { + return ConversionResult{ + Object: obj, + Converted: false, + Error: errors.Wrapf(err, "failed to determine target GVK for %s", gvk.String()), + } + } + + // Check if the object is already typed + // If it's typed and implements conversion.Convertible, use the custom ConvertTo method + if convertible, ok := obj.(conversion.Convertible); ok { + // Create a new instance of the target type + targetObj, err := c.scheme.New(targetGVK) + if err != nil { + return ConversionResult{ + Object: obj, + Converted: false, + Error: errors.Wrapf(err, "failed to create target object for %s", targetGVK.String()), + } + } + + // Check if the target object is a Hub + if hub, ok := targetObj.(conversion.Hub); ok { + if err := convertible.ConvertTo(hub); err != nil { + return ConversionResult{ + Object: obj, + Converted: false, + Error: errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, c.targetGV.Version), + } + } + + // Ensure the GVK is set on the converted object + hubObj := hub.(runtime.Object) + hubObj.GetObjectKind().SetGroupVersionKind(targetGVK) + + return ConversionResult{ + Object: hubObj, + Converted: true, + Error: nil, + Warnings: nil, + } + } + } + + // Use scheme-based conversion for all remaining cases + convertedObj, err := c.scheme.ConvertToVersion(obj, targetGVK.GroupVersion()) + if err != nil { + return ConversionResult{ + Object: obj, + Converted: false, + Error: errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, c.targetGV.Version), + } + } + + return ConversionResult{ + Object: convertedObj, + Converted: true, + Error: nil, + Warnings: nil, + } +} + +// getTargetGVK returns the target GroupVersionKind for a given source GVK. +func (c *Converter) getTargetGVK(sourceGVK schema.GroupVersionKind) (schema.GroupVersionKind, error) { + // Check cache first + if targetGVK, ok := c.targetGVKMap[sourceGVK]; ok { + return targetGVK, nil + } + + // Create target GVK with same kind but target version + targetGVK := schema.GroupVersionKind{ + Group: c.targetGV.Group, + Version: c.targetGV.Version, + Kind: sourceGVK.Kind, + } + + // Verify the target type exists in the scheme + if !c.scheme.Recognizes(targetGVK) { + return schema.GroupVersionKind{}, errors.Errorf("target GVK %s not recognized by scheme", targetGVK.String()) + } + + // Cache for future use + c.targetGVKMap[sourceGVK] = targetGVK + + return targetGVK, nil +} diff --git a/cmd/clusterctl/internal/migrate/converter_test.go b/cmd/clusterctl/internal/migrate/converter_test.go new file mode 100644 index 000000000000..1cbfe2e3cf24 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/converter_test.go @@ -0,0 +1,135 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrate + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" +) + +func TestConverter_ConvertResource(t *testing.T) { + converter, err := NewConverter(clusterv1.GroupVersion) + if err != nil { + t.Fatalf("NewConverter() failed: %v", err) + } + + t.Run("convert v1beta1 Cluster to v1beta2", func(t *testing.T) { + cluster := &clusterv1beta1.Cluster{} + cluster.SetName("test-cluster") + cluster.SetNamespace("default") + + info := ResourceInfo{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "Cluster", + }, + Name: "test-cluster", + Namespace: "default", + } + + result := converter.ConvertResource(info, cluster) + + if result.Error != nil { + t.Fatalf("ConvertResource() failed: %v", result.Error) + } + if !result.Converted { + t.Error("Expected resource to be converted") + } + if result.Object == nil { + t.Fatal("Converted object is nil") + } + + // Verify the converted object is v1beta2 + convertedCluster, ok := result.Object.(*clusterv1.Cluster) + if !ok { + t.Fatalf("Expected *clusterv1beta2.Cluster, got %T", result.Object) + } + if convertedCluster.Name != "test-cluster" { + t.Errorf("Expected name test-cluster, got %s", convertedCluster.Name) + } + }) + + t.Run("no-op for v1beta2 resource", func(t *testing.T) { + cluster := &clusterv1.Cluster{} + cluster.SetName("test-cluster") + cluster.SetNamespace("default") + + info := ResourceInfo{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta2", + Kind: "Cluster", + }, + Name: "test-cluster", + Namespace: "default", + } + + result := converter.ConvertResource(info, cluster) + + if result.Error != nil { + t.Fatalf("ConvertResource() failed: %v", result.Error) + } + if result.Converted { + t.Error("Expected resource not to be converted") + } + if len(result.Warnings) == 0 { + t.Error("Expected warning for already-converted resource") + } + }) + + t.Run("convert v1beta1 MachineDeployment to v1beta2", func(t *testing.T) { + md := &clusterv1beta1.MachineDeployment{} + md.SetName("test-md") + md.SetNamespace("default") + + info := ResourceInfo{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "MachineDeployment", + }, + Name: "test-md", + Namespace: "default", + } + + result := converter.ConvertResource(info, md) + + if result.Error != nil { + t.Fatalf("ConvertResource() failed: %v", result.Error) + } + if !result.Converted { + t.Error("Expected resource to be converted") + } + if result.Object == nil { + t.Fatal("Converted object is nil") + } + + // Verify the converted object is v1beta2 + convertedMD, ok := result.Object.(*clusterv1.MachineDeployment) + if !ok { + t.Fatalf("Expected *clusterv1beta2.MachineDeployment, got %T", result.Object) + } + if convertedMD.Name != "test-md" { + t.Errorf("Expected name test-md, got %s", convertedMD.Name) + } + }) +} diff --git a/cmd/clusterctl/internal/migrate/doc.go b/cmd/clusterctl/internal/migrate/doc.go new file mode 100644 index 000000000000..174500309051 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package migrate implements mechanical conversion of CAPI core resources +// between API versions. +package migrate diff --git a/cmd/clusterctl/internal/migrate/engine.go b/cmd/clusterctl/internal/migrate/engine.go new file mode 100644 index 000000000000..d8517c2d6411 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/engine.go @@ -0,0 +1,182 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrate + +import ( + "fmt" + "io" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + kerrors "k8s.io/apimachinery/pkg/util/errors" +) + +// Engine orchestrates the migration process by combining a parser and converter. +type Engine struct { + parser *YAMLParser + converter *Converter + + errs []error + warnings []string +} + +// MigrationOptions contains configuration for a migration run. +type MigrationOptions struct { + Input io.Reader + Output io.Writer + Errors io.Writer + ToVersion string +} + +// MigrationResult contains the outcome of a migration operation. +type MigrationResult struct { + TotalResources int + ConvertedCount int + SkippedCount int + ErrorCount int + Warnings []string + Errors []error +} + +// ResourceInfo contains metadata about a resource being processed. +type ResourceInfo struct { + GroupVersionKind schema.GroupVersionKind + Name string + Namespace string + // Index in the YAML stream + Index int +} + +// NewEngine creates a new migration engine with parser and converter. +func NewEngine(parser *YAMLParser, converter *Converter) (*Engine, error) { + if parser == nil || converter == nil { + return nil, errors.New("parser and converter must be provided") + } + return &Engine{ + parser: parser, + converter: converter, + errs: make([]error, 0), + warnings: make([]string, 0), + }, nil +} + +// appendError records an error for later aggregation. +func (e *Engine) appendError(err error) { + if err == nil { + return + } + e.errs = append(e.errs, err) +} + +// appendWarning records a warning message. +func (e *Engine) appendWarning(msg string) { + if msg == "" { + return + } + e.warnings = append(e.warnings, msg) +} + +// aggregateErrors returns an aggregated error from collected errors. +func (e *Engine) aggregateErrors() error { + return kerrors.NewAggregate(e.errs) +} + +// Migrate handles multi-document YAML streams, error collection, and reporting. +func (e *Engine) Migrate(opts MigrationOptions) (*MigrationResult, error) { + e.errs = make([]error, 0) + e.warnings = make([]string, 0) + + // Parse input YAML stream + documents, err := e.parser.ParseYAMLStream(opts.Input) + if err != nil { + return nil, errors.Wrap(err, "failed to parse YAML stream") + } + + result := &MigrationResult{ + TotalResources: len(documents), + ConvertedCount: 0, + SkippedCount: 0, + ErrorCount: 0, + } + + for i := range documents { + doc := &documents[i] + + switch doc.Type { + case ResourceTypeCoreV1Beta1: + info := ResourceInfo{ + GroupVersionKind: doc.GVK, + Index: doc.Index, + } + + conversionResult := e.converter.ConvertResource(info, doc.Object) + + if conversionResult.Error != nil { + e.appendError(errors.Wrapf(conversionResult.Error, "failed to convert document at index %d (%s)", doc.Index, doc.GVK.String())) + result.ErrorCount++ + } else if conversionResult.Converted { + doc.Object = conversionResult.Object + result.ConvertedCount++ + } else { + result.SkippedCount++ + for _, warning := range conversionResult.Warnings { + e.appendWarning(warning) + if opts.Errors != nil { + fmt.Fprintf(opts.Errors, "INFO: %s\n", warning) + } + } + } + + case ResourceTypeOtherCAPI: + // ResourceTypeOtherCAPI means cluster.x-k8s.io resources at versions other than v1beta1 + // (e.g., already at v1beta2) - pass through unchanged + result.SkippedCount++ + if opts.Errors != nil { + fmt.Fprintf(opts.Errors, "INFO: Resource %s is already at version %s, no conversion needed\n", doc.GVK.Kind, doc.GVK.Version) + } + + case ResourceTypeNonCAPI: + // Pass through non-CAPI resources unchanged + result.SkippedCount++ + if opts.Errors != nil { + fmt.Fprintf(opts.Errors, "INFO: Passing through non-CAPI resource: %s\n", doc.GVK.String()) + } + + case ResourceTypeUnsupported: + // Pass through unsupported resources with warning + result.SkippedCount++ + warning := fmt.Sprintf("Unable to parse document at index %d, passing through unchanged", doc.Index) + e.appendWarning(warning) + if opts.Errors != nil { + fmt.Fprintf(opts.Errors, "WARNING: %s\n", warning) + } + } + } + + if err := e.parser.SerializeYAMLStream(documents, opts.Output); err != nil { + return nil, errors.Wrap(err, "failed to serialize output") + } + + result.Warnings = e.warnings + result.Errors = e.errs + + if len(e.errs) > 0 { + return result, e.aggregateErrors() + } + + return result, nil +} diff --git a/cmd/clusterctl/internal/migrate/engine_test.go b/cmd/clusterctl/internal/migrate/engine_test.go new file mode 100644 index 000000000000..c51096c31d89 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/engine_test.go @@ -0,0 +1,233 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrate + +import ( + "bytes" + "strings" + "testing" + + . "github.com/onsi/gomega" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" +) + +func TestEngine_Migrate(t *testing.T) { + g := NewWithT(t) + + parser := NewYAMLParser(scheme.Scheme) + converter, err := NewConverter(clusterv1.GroupVersion) + g.Expect(err).ToNot(HaveOccurred()) + + engine, err := NewEngine(parser, converter) + g.Expect(err).ToNot(HaveOccurred()) + + t.Run("should convert v1beta1 cluster.x-k8s.io resources", func(t *testing.T) { + g := NewWithT(t) + + input := `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 +` + + inputReader := strings.NewReader(input) + outputBuffer := &bytes.Buffer{} + errorsBuffer := &bytes.Buffer{} + + result, err := engine.Migrate(MigrationOptions{ + Input: inputReader, + Output: outputBuffer, + Errors: errorsBuffer, + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.TotalResources).To(Equal(1)) + g.Expect(result.ConvertedCount).To(Equal(1)) + g.Expect(result.SkippedCount).To(Equal(0)) + g.Expect(result.ErrorCount).To(Equal(0)) + + output := outputBuffer.String() + g.Expect(output).To(ContainSubstring("apiVersion: cluster.x-k8s.io/v1beta2")) + g.Expect(output).To(ContainSubstring("kind: Cluster")) + g.Expect(output).To(ContainSubstring("name: test-cluster")) + }) + + t.Run("should pass through non-CAPI resources", func(t *testing.T) { + g := NewWithT(t) + + input := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +` + + inputReader := strings.NewReader(input) + outputBuffer := &bytes.Buffer{} + errorsBuffer := &bytes.Buffer{} + + result, err := engine.Migrate(MigrationOptions{ + Input: inputReader, + Output: outputBuffer, + Errors: errorsBuffer, + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.TotalResources).To(Equal(1)) + g.Expect(result.ConvertedCount).To(Equal(0)) + g.Expect(result.SkippedCount).To(Equal(1)) + g.Expect(result.ErrorCount).To(Equal(0)) + + output := outputBuffer.String() + g.Expect(output).To(ContainSubstring("apiVersion: v1")) + g.Expect(output).To(ContainSubstring("kind: ConfigMap")) + + errorsOutput := errorsBuffer.String() + g.Expect(errorsOutput).To(ContainSubstring("INFO: Passing through non-CAPI resource")) + }) + + t.Run("should handle multi-document YAML with mixed resources", func(t *testing.T) { + g := NewWithT(t) + + input := `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace +--- +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Machine +metadata: + name: test-machine + namespace: default +spec: + clusterName: test-cluster + bootstrap: + dataSecretName: test-bootstrap +` + + inputReader := strings.NewReader(input) + outputBuffer := &bytes.Buffer{} + errorsBuffer := &bytes.Buffer{} + + result, err := engine.Migrate(MigrationOptions{ + Input: inputReader, + Output: outputBuffer, + Errors: errorsBuffer, + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.TotalResources).To(Equal(3)) + g.Expect(result.ConvertedCount).To(Equal(2)) + g.Expect(result.SkippedCount).To(Equal(1)) + g.Expect(result.ErrorCount).To(Equal(0)) + + output := outputBuffer.String() + // Check that converted resources are at v1beta2 + g.Expect(strings.Count(output, "apiVersion: cluster.x-k8s.io/v1beta2")).To(Equal(2)) + // Check that non-CAPI resource is unchanged + g.Expect(output).To(ContainSubstring("apiVersion: v1")) + g.Expect(output).To(ContainSubstring("kind: Namespace")) + // Check document separators are present + g.Expect(strings.Count(output, "---")).To(Equal(2)) + }) + + t.Run("should skip resources already at target version", func(t *testing.T) { + g := NewWithT(t) + + input := `apiVersion: cluster.x-k8s.io/v1beta2 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + topology: + class: test-class + version: v1.28.0 +` + + inputReader := strings.NewReader(input) + outputBuffer := &bytes.Buffer{} + errorsBuffer := &bytes.Buffer{} + + result, err := engine.Migrate(MigrationOptions{ + Input: inputReader, + Output: outputBuffer, + Errors: errorsBuffer, + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.TotalResources).To(Equal(1)) + g.Expect(result.ConvertedCount).To(Equal(0)) + g.Expect(result.SkippedCount).To(Equal(1)) + g.Expect(result.ErrorCount).To(Equal(0)) + + output := outputBuffer.String() + g.Expect(output).To(ContainSubstring("apiVersion: cluster.x-k8s.io/v1beta2")) + + errorsOutput := errorsBuffer.String() + g.Expect(errorsOutput).To(ContainSubstring("INFO:")) + g.Expect(errorsOutput).To(ContainSubstring("already at version")) + g.Expect(errorsOutput).To(ContainSubstring("v1beta2")) + }) + + t.Run("should handle empty input", func(t *testing.T) { + g := NewWithT(t) + + input := "" + inputReader := strings.NewReader(input) + outputBuffer := &bytes.Buffer{} + errorsBuffer := &bytes.Buffer{} + + result, err := engine.Migrate(MigrationOptions{ + Input: inputReader, + Output: outputBuffer, + Errors: errorsBuffer, + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.TotalResources).To(Equal(0)) + g.Expect(result.ConvertedCount).To(Equal(0)) + g.Expect(result.SkippedCount).To(Equal(0)) + g.Expect(result.ErrorCount).To(Equal(0)) + }) +} diff --git a/cmd/clusterctl/internal/migrate/parser.go b/cmd/clusterctl/internal/migrate/parser.go new file mode 100644 index 000000000000..ac722ebb5158 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/parser.go @@ -0,0 +1,216 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrate + +import ( + "bufio" + "bytes" + "io" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json" + yamlserializer "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" +) + +// Document represents a single YAML document with associated metadata. +type Document struct { + Object runtime.Object + Raw []byte + GVK schema.GroupVersionKind + Type ResourceType + // Index indicates the document's position in the original stream. + Index int +} + +// ResourceType classifies the type of Kubernetes resource. +type ResourceType int + +const ( + // ResourceTypeCoreV1Beta1 identifies v1beta1 core ClusterAPI resources. + ResourceTypeCoreV1Beta1 ResourceType = iota + // ResourceTypeOtherCAPI identifies other cluster.x-k8s.io resources (non-v1beta1). + ResourceTypeOtherCAPI + // ResourceTypeNonCAPI identifies Kubernetes objects outside of ClusterAPI groups. + ResourceTypeNonCAPI + // ResourceTypeUnsupported identifies documents that could not be parsed or classified. + ResourceTypeUnsupported +) + +// YAMLParser parses YAML documents into runtime objects using Kubernetes serializers. +type YAMLParser struct { + scheme *runtime.Scheme + codecFactory serializer.CodecFactory + unstructuredDecoder runtime.Decoder + typedDecoder runtime.Decoder + yamlSerializer runtime.Serializer +} + +// NewYAMLParser creates a new YAML parser with the given scheme. +func NewYAMLParser(scheme *runtime.Scheme) *YAMLParser { + codecFactory := serializer.NewCodecFactory(scheme) + + return &YAMLParser{ + scheme: scheme, + codecFactory: codecFactory, + unstructuredDecoder: yamlserializer.NewDecodingSerializer(unstructured.UnstructuredJSONScheme), + typedDecoder: codecFactory.UniversalDeserializer(), + yamlSerializer: jsonserializer.NewSerializerWithOptions( + jsonserializer.DefaultMetaFactory, + scheme, + scheme, + jsonserializer.SerializerOptions{Yaml: true, Pretty: false, Strict: false}, + ), + } +} + +// ParseYAMLStream parses a multi-document YAML stream into individual documents. +func (p *YAMLParser) ParseYAMLStream(reader io.Reader) ([]Document, error) { + yamlReader := yamlutil.NewYAMLReader(bufio.NewReader(reader)) + + var documents []Document + index := 0 + + for { + raw, err := yamlReader.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, errors.Wrap(err, "failed to read YAML document") + } + + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + continue + } + + doc, err := p.parseDocument(trimmed, raw, index) + if err != nil { + // Treat parsing failures as unsupported documents but keep raw bytes. + documents = append(documents, Document{ + Object: nil, + Raw: p.ensureTrailingNewline(raw), + GVK: schema.GroupVersionKind{}, + Type: ResourceTypeUnsupported, + Index: index, + }) + } else { + documents = append(documents, doc) + } + index++ + } + + return documents, nil +} + +// SerializeYAMLStream writes documents back out as a multi-document YAML stream. +func (p *YAMLParser) SerializeYAMLStream(docs []Document, writer io.Writer) error { + if len(docs) == 0 { + return nil + } + + for i, doc := range docs { + // Add document separator before each document except the first + if i > 0 { + if _, err := io.WriteString(writer, "---\n"); err != nil { + return errors.Wrap(err, "failed to write document separator") + } + } + + if doc.Object != nil { + buf := &bytes.Buffer{} + if err := p.yamlSerializer.Encode(doc.Object, buf); err != nil { + return errors.Wrapf(err, "failed to encode document at index %d", doc.Index) + } + if _, err := writer.Write(p.ensureTrailingNewline(buf.Bytes())); err != nil { + return errors.Wrapf(err, "failed to write document at index %d", doc.Index) + } + continue + } + + if _, err := writer.Write(p.ensureTrailingNewline(doc.Raw)); err != nil { + return errors.Wrapf(err, "failed to write raw document at index %d", doc.Index) + } + } + + return nil +} + +func (p *YAMLParser) parseDocument(trimmed []byte, original []byte, index int) (Document, error) { + obj := &unstructured.Unstructured{} + _, gvk, err := p.unstructuredDecoder.Decode(trimmed, nil, obj) + if err != nil || gvk == nil || gvk.Empty() { + return Document{}, errors.New("unable to decode document") + } + + resourceType := p.classifyResource(*gvk) + + var runtimeObj runtime.Object + if resourceType == ResourceTypeCoreV1Beta1 || resourceType == ResourceTypeOtherCAPI { + if typedObj, _, err := p.typedDecoder.Decode(trimmed, gvk, nil); err == nil { + runtimeObj = typedObj + } else { + runtimeObj = obj + } + } else { + runtimeObj = obj + } + + return Document{ + Object: runtimeObj, + Raw: p.ensureTrailingNewline(original), + GVK: *gvk, + Type: resourceType, + Index: index, + }, nil +} + +func (p *YAMLParser) ensureTrailingNewline(content []byte) []byte { + if len(content) == 0 { + return content + } + if content[len(content)-1] != '\n' { + content = append(content, '\n') + } + return content +} + +func (p *YAMLParser) classifyResource(gvk schema.GroupVersionKind) ResourceType { + if p.isCoreCAPIGroup(gvk.Group) { + if gvk.Version == "v1beta1" { + return ResourceTypeCoreV1Beta1 + } + return ResourceTypeOtherCAPI + } + return ResourceTypeNonCAPI +} + +func (p *YAMLParser) isCoreCAPIGroup(group string) bool { + _, ok := coreCapiGroups[group] + return ok +} + +var coreCapiGroups = map[string]struct{}{ + clusterv1.GroupVersion.Group: {}, +} diff --git a/cmd/clusterctl/internal/migrate/parser_test.go b/cmd/clusterctl/internal/migrate/parser_test.go new file mode 100644 index 000000000000..e8d2e81144e2 --- /dev/null +++ b/cmd/clusterctl/internal/migrate/parser_test.go @@ -0,0 +1,172 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrate + +import ( + "bytes" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestYAMLParser_ParseYAMLStream(t *testing.T) { + scheme := runtime.NewScheme() + parser := NewYAMLParser(scheme) + + testYAML := `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: ["192.168.0.0/16"] +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key: value +--- +apiVersion: cluster.x-k8s.io/v1beta2 +kind: Machine +metadata: + name: test-machine +spec: + clusterName: test-cluster` + + reader := strings.NewReader(testYAML) + docs, err := parser.ParseYAMLStream(reader) + if err != nil { + t.Fatalf("ParseYAMLStream failed: %v", err) + } + + if len(docs) != 3 { + t.Fatalf("Expected 3 documents, got %d", len(docs)) + } + + // Test first document (v1beta1 CAPI resource) + if docs[0].Type != ResourceTypeCoreV1Beta1 { + t.Errorf("Expected first document to be ResourceTypeCoreV1Beta1, got %v", docs[0].Type) + } + expectedGVK := schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "Cluster", + } + if docs[0].GVK != expectedGVK { + t.Errorf("Expected GVK %v, got %v", expectedGVK, docs[0].GVK) + } + + // Test second document (non-CAPI resource) + if docs[1].Type != ResourceTypeNonCAPI { + t.Errorf("Expected second document to be ResourceTypeNonCAPI, got %v", docs[1].Type) + } + + // Test third document (v1beta2 CAPI resource) + if docs[2].Type != ResourceTypeOtherCAPI { + t.Errorf("Expected third document to be ResourceTypeOtherCAPI, got %v", docs[2].Type) + } +} + +func TestYAMLParser_SerializeYAMLStream(t *testing.T) { + scheme := runtime.NewScheme() + parser := NewYAMLParser(scheme) + + // Create test documents + docs := []Document{ + { + Object: nil, + Raw: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\ndata:\n key: value"), + GVK: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + Type: ResourceTypeNonCAPI, + Index: 0, + }, + { + Object: nil, + Raw: []byte("apiVersion: cluster.x-k8s.io/v1beta1\nkind: Cluster\nmetadata:\n name: test-cluster"), + GVK: schema.GroupVersionKind{Group: "cluster.x-k8s.io", Version: "v1beta1", Kind: "Cluster"}, + Type: ResourceTypeCoreV1Beta1, + Index: 1, + }, + } + + var buf bytes.Buffer + err := parser.SerializeYAMLStream(docs, &buf) + if err != nil { + t.Fatalf("SerializeYAMLStream failed: %v", err) + } + + output := buf.String() + + if !strings.Contains(output, "---\n") { + t.Error("Expected document separator '---' in output") + } + + // Check that both documents are present + if !strings.Contains(output, "ConfigMap") { + t.Error("Expected ConfigMap in output") + } + if !strings.Contains(output, "Cluster") { + t.Error("Expected Cluster in output") + } +} + +func TestYAMLParser_EmptyInput(t *testing.T) { + scheme := runtime.NewScheme() + parser := NewYAMLParser(scheme) + + reader := strings.NewReader("") + docs, err := parser.ParseYAMLStream(reader) + if err != nil { + t.Fatalf("ParseYAMLStream failed on empty input: %v", err) + } + + if len(docs) != 0 { + t.Fatalf("Expected 0 documents for empty input, got %d", len(docs)) + } +} + +func TestYAMLParser_InvalidYAML(t *testing.T) { + scheme := runtime.NewScheme() + parser := NewYAMLParser(scheme) + + invalidYAML := `this is not valid yaml: [unclosed bracket` + reader := strings.NewReader(invalidYAML) + + docs, err := parser.ParseYAMLStream(reader) + if err != nil { + t.Fatalf("ParseYAMLStream should handle invalid YAML gracefully: %v", err) + } + + if len(docs) != 1 { + t.Fatalf("Expected 1 document for invalid YAML, got %d", len(docs)) + } + + if docs[0].Type != ResourceTypeUnsupported { + t.Errorf("Expected invalid YAML to be classified as ResourceTypeUnsupported, got %v", docs[0].Type) + } + + expectedRaw := invalidYAML + "\n" + if string(docs[0].Raw) != expectedRaw { + t.Errorf("Expected raw content to be preserved with newline, got %q", string(docs[0].Raw)) + } +} diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index d1f2fc3ea859..eaf1ef834e21 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -56,6 +56,7 @@ - [generate yaml](clusterctl/commands/generate-yaml.md) - [get kubeconfig](clusterctl/commands/get-kubeconfig.md) - [describe cluster](clusterctl/commands/describe-cluster.md) + - [migrate](clusterctl/commands/migrate.md) - [move](./clusterctl/commands/move.md) - [upgrade](clusterctl/commands/upgrade.md) - [delete](clusterctl/commands/delete.md) diff --git a/docs/book/src/clusterctl/commands/commands.md b/docs/book/src/clusterctl/commands/commands.md index 8cabf64b2927..7f0f1dcd89ae 100644 --- a/docs/book/src/clusterctl/commands/commands.md +++ b/docs/book/src/clusterctl/commands/commands.md @@ -14,6 +14,7 @@ | [`clusterctl help`](additional-commands.md#clusterctl-help) | Help about any command. | | [`clusterctl init`](init.md) | Initialize a management cluster. | | [`clusterctl init list-images`](additional-commands.md#clusterctl-init-list-images) | Lists the container images required for initializing the management cluster. | +| [`clusterctl migrate`](migrate.md) | **EXPERIMENTAL**: Migrate cluster.x-k8s.io resources between API versions. | | [`clusterctl move`](move.md) | Move Cluster API objects and all their dependencies between management clusters. | | [`clusterctl upgrade plan`](upgrade.md#upgrade-plan) | Provide a list of recommended target versions for upgrading Cluster API providers in a management cluster. | | [`clusterctl upgrade apply`](upgrade.md#upgrade-apply) | Apply new versions of Cluster API core and providers in a management cluster. | diff --git a/docs/book/src/clusterctl/commands/migrate.md b/docs/book/src/clusterctl/commands/migrate.md new file mode 100644 index 000000000000..d555303d3fce --- /dev/null +++ b/docs/book/src/clusterctl/commands/migrate.md @@ -0,0 +1,41 @@ +# clusterctl migrate + +**Warning**: This command is EXPERIMENTAL and may be removed in a future release! + +The `clusterctl migrate` command converts cluster.x-k8s.io resources between API versions. + +## Usage + +```bash +clusterctl migrate [SOURCE] [flags] +``` + +## Examples + +```bash +# Migrate from file to stdout +clusterctl migrate cluster.yaml + +# Migrate from stdin to stdout +cat cluster.yaml | clusterctl migrate + +# Save output to a file +clusterctl migrate cluster.yaml --output migrated-cluster.yaml + +# Explicitly specify target version +clusterctl migrate cluster.yaml --to-version v1beta2 --output migrated-cluster.yaml +``` + +## Flags + +- `--output, -o`: Output file path (default: stdout) +- `--to-version`: Target API version for migration (default: "v1beta2") + +## Scope and Limitations + +- **Only cluster.x-k8s.io resources are converted** - Core CAPI resources like Cluster, MachineDeployment, Machine, etc. +- **Other CAPI API groups are passed through unchanged** - Infrastructure, bootstrap, and control plane provider resources are not converted +- **ClusterClass patches are not migrated** - Manual intervention required for ClusterClass patch conversions +- **Field order may change** - YAML field ordering is not preserved in the output +- **Comments are removed** - YAML comments are stripped during conversion +- **API version references are dropped** - Except for ClusterClass and external remediation references From e2b91c12860d233a4153b82dc061513901165d2b Mon Sep 17 00:00:00 2001 From: Satyam Bhardwaj Date: Wed, 12 Nov 2025 10:34:32 +0530 Subject: [PATCH 2/2] address review comments - Move to new `Convert` method in clusterctl client interface from the proposed migrate. - Create new conversion package with parsing and conversion utilities - Remove deprecated `migrate` command and related internal migration code Signed-off-by: Satyam Bhardwaj --- cmd/clusterctl/client/client.go | 4 + cmd/clusterctl/client/client_test.go | 4 + cmd/clusterctl/client/convert.go | 76 +++++ cmd/clusterctl/client/convert/convert.go | 112 +++++++ cmd/clusterctl/client/convert/convert_test.go | 147 ++++++++++ cmd/clusterctl/client/convert/parser.go | 219 ++++++++++++++ cmd/clusterctl/client/convert/parser_test.go | 275 ++++++++++++++++++ cmd/clusterctl/client/convert/resource.go | 99 +++++++ .../client/convert/resource_test.go | 248 ++++++++++++++++ cmd/clusterctl/cmd/convert.go | 158 ++++++++++ cmd/clusterctl/cmd/migrate.go | 189 ------------ cmd/clusterctl/internal/migrate/converter.go | 166 ----------- .../internal/migrate/converter_test.go | 135 --------- cmd/clusterctl/internal/migrate/doc.go | 19 -- cmd/clusterctl/internal/migrate/engine.go | 182 ------------ .../internal/migrate/engine_test.go | 233 --------------- cmd/clusterctl/internal/migrate/parser.go | 216 -------------- .../internal/migrate/parser_test.go | 172 ----------- docs/book/src/SUMMARY.md | 2 +- docs/book/src/clusterctl/commands/commands.md | 2 +- .../commands/{migrate.md => convert.md} | 22 +- 21 files changed, 1355 insertions(+), 1325 deletions(-) create mode 100644 cmd/clusterctl/client/convert.go create mode 100644 cmd/clusterctl/client/convert/convert.go create mode 100644 cmd/clusterctl/client/convert/convert_test.go create mode 100644 cmd/clusterctl/client/convert/parser.go create mode 100644 cmd/clusterctl/client/convert/parser_test.go create mode 100644 cmd/clusterctl/client/convert/resource.go create mode 100644 cmd/clusterctl/client/convert/resource_test.go create mode 100644 cmd/clusterctl/cmd/convert.go delete mode 100644 cmd/clusterctl/cmd/migrate.go delete mode 100644 cmd/clusterctl/internal/migrate/converter.go delete mode 100644 cmd/clusterctl/internal/migrate/converter_test.go delete mode 100644 cmd/clusterctl/internal/migrate/doc.go delete mode 100644 cmd/clusterctl/internal/migrate/engine.go delete mode 100644 cmd/clusterctl/internal/migrate/engine_test.go delete mode 100644 cmd/clusterctl/internal/migrate/parser.go delete mode 100644 cmd/clusterctl/internal/migrate/parser_test.go rename docs/book/src/clusterctl/commands/{migrate.md => convert.md} (57%) diff --git a/cmd/clusterctl/client/client.go b/cmd/clusterctl/client/client.go index 9c6c1b500d82..ddd5141f403c 100644 --- a/cmd/clusterctl/client/client.go +++ b/cmd/clusterctl/client/client.go @@ -75,6 +75,10 @@ type Client interface { // DescribeCluster returns the object tree representing the status of a Cluster API cluster. DescribeCluster(ctx context.Context, options DescribeClusterOptions) (*tree.ObjectTree, error) + // Convert converts CAPI core resources between API versions. + // EXPERIMENTAL: This method is experimental and may be removed in a future release. + Convert(ctx context.Context, options ConvertOptions) (ConvertResult, error) + // AlphaClient is an Interface for alpha features in clusterctl AlphaClient } diff --git a/cmd/clusterctl/client/client_test.go b/cmd/clusterctl/client/client_test.go index 671bb9663c89..e8b3faab5362 100644 --- a/cmd/clusterctl/client/client_test.go +++ b/cmd/clusterctl/client/client_test.go @@ -145,6 +145,10 @@ func (f fakeClient) RolloutResume(ctx context.Context, options RolloutResumeOpti return f.internalClient.RolloutResume(ctx, options) } +func (f fakeClient) Convert(ctx context.Context, options ConvertOptions) (ConvertResult, error) { + return f.internalClient.Convert(ctx, options) +} + // newFakeClient returns a clusterctl client that allows to execute tests on a set of fake config, fake repositories and fake clusters. // you can use WithCluster and WithRepository to prepare for the test case. func newFakeClient(ctx context.Context, configClient config.Client) *fakeClient { diff --git a/cmd/clusterctl/client/convert.go b/cmd/clusterctl/client/convert.go new file mode 100644 index 000000000000..e1f634ee57af --- /dev/null +++ b/cmd/clusterctl/client/convert.go @@ -0,0 +1,76 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package client + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/convert" +) + +var ( + // sourceGroupVersions defines the source GroupVersions that should be converted. + sourceGroupVersions = []schema.GroupVersion{ + clusterv1.GroupVersion, + } + + // knownAPIGroups defines all known API groups for resource classification. + knownAPIGroups = []string{ + clusterv1.GroupVersion.Group, + } +) + +// ConvertOptions carries the options supported by Convert. +type ConvertOptions struct { + // Input is the YAML content to convert. + Input []byte + + // ToVersion is the target API version to convert to (e.g., "v1beta2"). + ToVersion string +} + +// ConvertResult contains the result of a conversion operation. +type ConvertResult struct { + // Output is the converted YAML content. + Output []byte + + // Messages contains informational messages from the conversion. + Messages []string +} + +// Convert converts CAPI core resources between API versions. +func (c *clusterctlClient) Convert(_ context.Context, options ConvertOptions) (ConvertResult, error) { + converter := convert.NewConverter( + clusterv1.GroupVersion.Group, // targetAPIGroup: "cluster.x-k8s.io" + clusterv1.GroupVersion, // targetGV: schema.GroupVersion{Group: "cluster.x-k8s.io", Version: "v1beta2"} + sourceGroupVersions, // sourceGroupVersions + knownAPIGroups, // knownAPIGroups + ) + + output, msgs, err := converter.Convert(options.Input, options.ToVersion) + if err != nil { + return ConvertResult{}, err + } + + return ConvertResult{ + Output: output, + Messages: msgs, + }, nil +} diff --git a/cmd/clusterctl/client/convert/convert.go b/cmd/clusterctl/client/convert/convert.go new file mode 100644 index 000000000000..85419ccf40b2 --- /dev/null +++ b/cmd/clusterctl/client/convert/convert.go @@ -0,0 +1,112 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package convert provides a converter for CAPI core resources between API versions. +package convert + +import ( + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" +) + +// SupportedTargetVersions defines all supported target API versions for conversion. +var SupportedTargetVersions = []string{ + clusterv1.GroupVersion.Version, +} + +// Converter handles the conversion of CAPI core resources between API versions. +type Converter struct { + scheme *runtime.Scheme + targetAPIGroup string + targetGV schema.GroupVersion + sourceGroupVersions []schema.GroupVersion + knownAPIGroups []string +} + +// NewConverter creates a new Converter instance. +func NewConverter(targetAPIGroup string, targetGV schema.GroupVersion, sourceGroupVersions []schema.GroupVersion, knownAPIGroups []string) *Converter { + return &Converter{ + scheme: scheme.Scheme, + targetAPIGroup: targetAPIGroup, + targetGV: targetGV, + sourceGroupVersions: sourceGroupVersions, + knownAPIGroups: knownAPIGroups, + } +} + +// Convert processes multi-document YAML streams and converts resources to the target version. +func (c *Converter) Convert(input []byte, toVersion string) (output []byte, messages []string, err error) { + messages = make([]string, 0) + + targetGV := schema.GroupVersion{ + Group: c.targetAPIGroup, + Version: toVersion, + } + + // Create GVK matcher for resource classification. + matcher := newGVKMatcher(c.sourceGroupVersions, c.knownAPIGroups) + + // Parse input YAML stream. + docs, err := parseYAMLStream(input, c.scheme, matcher) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to parse YAML stream") + } + + for i := range docs { + doc := &docs[i] + + switch doc.typ { + case resourceTypeConvertible: + convertedObj, wasConverted, convErr := convertResource(doc.object, targetGV, c.scheme, c.targetAPIGroup) + if convErr != nil { + return nil, nil, errors.Wrapf(convErr, "failed to convert resource %s at index %d", doc.gvk.String(), doc.index) + } + + if wasConverted { + doc.object = convertedObj + } else { + // Resource that are already at target version. + if msg := getInfoMessage(doc.gvk, toVersion, c.targetAPIGroup); msg != "" { + messages = append(messages, msg) + } + } + + case resourceTypeKnown: + // Pass through unchanged with info message. + if msg := getInfoMessage(doc.gvk, toVersion, c.targetAPIGroup); msg != "" { + messages = append(messages, msg) + } + + case resourceTypePassThrough: + // Non-target API group resource - pass through unchanged with info message. + if msg := getInfoMessage(doc.gvk, toVersion, c.targetAPIGroup); msg != "" { + messages = append(messages, msg) + } + } + } + + // Serialize documents back to YAML. + output, err = serializeYAMLStream(docs, c.scheme) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to serialize output") + } + + return output, messages, nil +} diff --git a/cmd/clusterctl/client/convert/convert_test.go b/cmd/clusterctl/client/convert/convert_test.go new file mode 100644 index 000000000000..88bd9b08c027 --- /dev/null +++ b/cmd/clusterctl/client/convert/convert_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package convert + +import ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" +) + +func TestConverter_Convert(t *testing.T) { + tests := []struct { + name string + input string + toVersion string + wantErr bool + wantConverted bool + }{ + { + name: "convert v1beta1 cluster to v1beta2", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 +`, + toVersion: "v1beta2", + wantErr: false, + wantConverted: true, + }, + { + name: "pass through v1beta2 cluster unchanged", + input: `apiVersion: cluster.x-k8s.io/v1beta2 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 +`, + toVersion: "v1beta2", + wantErr: false, + wantConverted: false, + }, + { + name: "pass through non-CAPI resource", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +`, + toVersion: "v1beta2", + wantErr: false, + wantConverted: false, + }, + { + name: "convert multi-document YAML", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +--- +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Machine +metadata: + name: test-machine + namespace: default +`, + toVersion: "v1beta2", + wantErr: false, + wantConverted: true, + }, + { + name: "invalid YAML", + input: `this is not valid yaml +kind: Cluster +`, + toVersion: "v1beta2", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sourceGroupVersions := []schema.GroupVersion{clusterv1beta1.GroupVersion} + knownAPIGroups := []string{clusterv1.GroupVersion.Group} + converter := NewConverter("cluster.x-k8s.io", clusterv1.GroupVersion, sourceGroupVersions, knownAPIGroups) + output, messages, err := converter.Convert([]byte(tt.input), tt.toVersion) + + if (err != nil) != tt.wantErr { + t.Errorf("Convert() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if len(output) == 0 { + t.Error("Convert() returned empty output") + } + + // Verify output contains expected version if conversion happened. + if tt.wantConverted { + outputStr := string(output) + if !strings.Contains(outputStr, "cluster.x-k8s.io/v1beta2") { + t.Errorf("Convert() output does not contain v1beta2 version: %s", outputStr) + } + } + + // Messages should be non-nil (even if empty). + if messages == nil { + t.Error("Convert() returned nil messages slice") + } + }) + } +} diff --git a/cmd/clusterctl/client/convert/parser.go b/cmd/clusterctl/client/convert/parser.go new file mode 100644 index 000000000000..b82a765000c0 --- /dev/null +++ b/cmd/clusterctl/client/convert/parser.go @@ -0,0 +1,219 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package convert + +import ( + "bufio" + "bytes" + "io" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json" + yamlserializer "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" +) + +// document represents a single YAML document with associated metadata. +type document struct { + object runtime.Object + raw []byte + gvk schema.GroupVersionKind + typ resourceType + index int +} + +// resourceType classifies the type of Kubernetes resource. +type resourceType int + +const ( + // resourceTypeConvertible identifies resources that can be converted (match source GVKs). + resourceTypeConvertible resourceType = iota + // resourceTypeKnown identifies resources in known groups but not convertible. + resourceTypeKnown + // resourceTypePassThrough identifies resources that should pass through unchanged. + resourceTypePassThrough +) + +// gvkMatcher provides GVK matching logic for resource classification. +type gvkMatcher struct { + // sourceGroupVersions are GroupVersions where all kinds should be converted. + sourceGroupVersions map[schema.GroupVersion]bool + // knownGroups are API groups that are known to the scheme. + knownGroups map[string]bool +} + +// parseYAMLStream parses a multi-document YAML stream from a byte slice into individual documents. +func parseYAMLStream(input []byte, scheme *runtime.Scheme, matcher *gvkMatcher) ([]document, error) { + reader := bytes.NewReader(input) + yamlReader := yamlutil.NewYAMLReader(bufio.NewReader(reader)) + + codecFactory := serializer.NewCodecFactory(scheme) + unstructuredDecoder := yamlserializer.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + typedDecoder := codecFactory.UniversalDeserializer() + + var documents []document + index := 0 + + for { + raw, err := yamlReader.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, errors.Wrap(err, "failed to read YAML document") + } + + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + continue + } + + doc, err := parseDocument(trimmed, raw, index, unstructuredDecoder, typedDecoder, matcher) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse document at index %d", index) + } + documents = append(documents, doc) + index++ + } + + return documents, nil +} + +// serializeYAMLStream writes documents back out as a multi-document YAML stream. +func serializeYAMLStream(docs []document, scheme *runtime.Scheme) ([]byte, error) { + if len(docs) == 0 { + return []byte{}, nil + } + + yamlSerializer := jsonserializer.NewSerializerWithOptions( + jsonserializer.DefaultMetaFactory, + scheme, + scheme, + jsonserializer.SerializerOptions{Yaml: true, Pretty: false, Strict: false}, + ) + + buf := &bytes.Buffer{} + + for i, doc := range docs { + // Add document separator before each document except the first. + if i > 0 { + if _, err := buf.WriteString("---\n"); err != nil { + return nil, errors.Wrap(err, "failed to write document separator") + } + } + + if doc.object != nil { + docBuf := &bytes.Buffer{} + if err := yamlSerializer.Encode(doc.object, docBuf); err != nil { + return nil, errors.Wrapf(err, "failed to encode document at index %d", doc.index) + } + if _, err := buf.Write(ensureTrailingNewline(docBuf.Bytes())); err != nil { + return nil, errors.Wrapf(err, "failed to write document at index %d", doc.index) + } + continue + } + + if _, err := buf.Write(ensureTrailingNewline(doc.raw)); err != nil { + return nil, errors.Wrapf(err, "failed to write raw document at index %d", doc.index) + } + } + + return buf.Bytes(), nil +} + +// parseDocument parses a single YAML document and classifies it by resource type. +func parseDocument(trimmed []byte, original []byte, index int, unstructuredDecoder runtime.Decoder, typedDecoder runtime.Decoder, matcher *gvkMatcher) (document, error) { + obj := &unstructured.Unstructured{} + _, gvk, err := unstructuredDecoder.Decode(trimmed, nil, obj) + if err != nil { + return document{}, errors.Wrap(err, "unable to decode document: invalid YAML structure") + } + if gvk == nil || gvk.Empty() || gvk.Kind == "" || (gvk.Group == "" && gvk.Version == "") { + return document{}, errors.New("unable to decode document: missing or empty apiVersion/kind") + } + + resourceType := classifyResource(*gvk, matcher) + + var runtimeObj runtime.Object + if resourceType == resourceTypeConvertible || resourceType == resourceTypeKnown { + if typedObj, _, err := typedDecoder.Decode(trimmed, gvk, nil); err == nil { + runtimeObj = typedObj + } else { + runtimeObj = obj + } + } else { + runtimeObj = obj + } + + return document{ + object: runtimeObj, + raw: ensureTrailingNewline(original), + gvk: *gvk, + typ: resourceType, + index: index, + }, nil +} + +// classifyResource determines the resource type based on its GroupVersionKind and the provided matcher. +func classifyResource(gvk schema.GroupVersionKind, matcher *gvkMatcher) resourceType { + // Check if this GroupVersion should be converted + gv := schema.GroupVersion{Group: gvk.Group, Version: gvk.Version} + if matcher.sourceGroupVersions[gv] { + return resourceTypeConvertible + } + + // Check if this is in a known group but not a source GroupVersion. + if matcher.knownGroups[gvk.Group] { + return resourceTypeKnown + } + + // Everything else passes through + return resourceTypePassThrough +} + +// newGVKMatcher creates a new GVK matcher from source GroupVersions and known groups. +func newGVKMatcher(sourceGVs []schema.GroupVersion, knownGroups []string) *gvkMatcher { + matcher := &gvkMatcher{ + sourceGroupVersions: make(map[schema.GroupVersion]bool), + knownGroups: make(map[string]bool), + } + + for _, gv := range sourceGVs { + matcher.sourceGroupVersions[gv] = true + } + + for _, group := range knownGroups { + matcher.knownGroups[group] = true + } + + return matcher +} + +// ensureTrailingNewline ensures that the content ends with a newline character. +func ensureTrailingNewline(content []byte) []byte { + if len(content) == 0 { + return content + } + if content[len(content)-1] != '\n' { + content = append(content, '\n') + } + return content +} diff --git a/cmd/clusterctl/client/convert/parser_test.go b/cmd/clusterctl/client/convert/parser_test.go new file mode 100644 index 000000000000..5b2ba3bfaad2 --- /dev/null +++ b/cmd/clusterctl/client/convert/parser_test.go @@ -0,0 +1,275 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package convert + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" +) + +func TestParseYAMLStream(t *testing.T) { + sourceGVs := []schema.GroupVersion{ + {Group: "cluster.x-k8s.io", Version: "v1beta1"}, + } + + knownGroups := []string{"cluster.x-k8s.io"} + + matcher := newGVKMatcher(sourceGVs, knownGroups) + + tests := []struct { + name string + input string + wantDocCount int + wantFirstType resourceType + wantErr bool + }{ + { + name: "single v1beta1 cluster", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 +`, + wantDocCount: 1, + wantFirstType: resourceTypeConvertible, + wantErr: false, + }, + { + name: "multi-document YAML", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +--- +apiVersion: cluster.x-k8s.io/v1beta2 +kind: Machine +metadata: + name: test-machine + namespace: default +`, + wantDocCount: 2, + wantFirstType: resourceTypeConvertible, + wantErr: false, + }, + { + name: "non-CAPI resource", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +`, + wantDocCount: 1, + wantFirstType: resourceTypePassThrough, + wantErr: false, + }, + { + name: "empty document", + input: ` + + +`, + wantDocCount: 0, + wantErr: false, + }, + { + name: "invalid YAML - missing apiVersion", + input: `kind: Cluster +metadata: + name: test-cluster +`, + wantDocCount: 0, + wantFirstType: resourceTypePassThrough, + wantErr: true, + }, + { + name: "invalid YAML - missing kind", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +metadata: + name: test-cluster +`, + wantDocCount: 0, + wantErr: true, + }, + { + name: "invalid YAML - not a kubernetes object", + input: `just: some +random: yaml +that: is +not: a k8s object +`, + wantDocCount: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + docs, err := parseYAMLStream([]byte(tt.input), scheme.Scheme, matcher) + if (err != nil) != tt.wantErr { + t.Errorf("parseYAMLStream() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(docs) != tt.wantDocCount { + t.Errorf("parseYAMLStream() got %d documents, want %d", len(docs), tt.wantDocCount) + return + } + if tt.wantDocCount > 0 && docs[0].typ != tt.wantFirstType { + t.Errorf("parseYAMLStream() first doc type = %v, want %v", docs[0].typ, tt.wantFirstType) + } + }) + } +} + +func TestSerializeYAMLStream(t *testing.T) { + sourceGVs := []schema.GroupVersion{ + {Group: "cluster.x-k8s.io", Version: "v1beta1"}, + } + knownGroups := []string{"cluster.x-k8s.io"} + matcher := newGVKMatcher(sourceGVs, knownGroups) + + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "single document", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 +`, + wantErr: false, + }, + { + name: "multi-document YAML", + input: `apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: test-cluster + namespace: default +--- +apiVersion: cluster.x-k8s.io/v1beta2 +kind: Machine +metadata: + name: test-machine + namespace: default +`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse the input. + docs, err := parseYAMLStream([]byte(tt.input), scheme.Scheme, matcher) + if err != nil { + t.Fatalf("parseYAMLStream() error = %v", err) + } + + // Serialize back. + output, err := serializeYAMLStream(docs, scheme.Scheme) + if (err != nil) != tt.wantErr { + t.Errorf("serializeYAMLStream() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && len(output) == 0 { + t.Error("serializeYAMLStream() returned empty output") + } + }) + } +} + +func TestClassifyResource(t *testing.T) { + sourceGVs := []schema.GroupVersion{ + {Group: "cluster.x-k8s.io", Version: "v1beta1"}, + } + knownGroups := []string{"cluster.x-k8s.io"} + matcher := newGVKMatcher(sourceGVs, knownGroups) + + tests := []struct { + name string + gvk schema.GroupVersionKind + want resourceType + }{ + { + name: "v1beta1 cluster - convertible", + gvk: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "Cluster", + }, + want: resourceTypeConvertible, + }, + { + name: "v1beta2 cluster - known but not convertible", + gvk: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta2", + Kind: "Cluster", + }, + want: resourceTypeKnown, + }, + { + name: "non-CAPI resource - pass through", + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: resourceTypePassThrough, + }, + { + name: "bootstrap CAPI resource - pass through", + gvk: schema.GroupVersionKind{ + Group: "bootstrap.cluster.x-k8s.io", + Version: "v1beta1", + Kind: "KubeadmConfig", + }, + want: resourceTypePassThrough, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyResource(tt.gvk, matcher) + if got != tt.want { + t.Errorf("classifyResource() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/clusterctl/client/convert/resource.go b/cmd/clusterctl/client/convert/resource.go new file mode 100644 index 000000000000..6035fa200a51 --- /dev/null +++ b/cmd/clusterctl/client/convert/resource.go @@ -0,0 +1,99 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package convert + +import ( + "fmt" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/conversion" +) + +// convertResource converts a single resource to the target GroupVersion. +func convertResource(obj runtime.Object, targetGV schema.GroupVersion, scheme *runtime.Scheme, targetAPIGroup string) (runtime.Object, bool, error) { + gvk := obj.GetObjectKind().GroupVersionKind() + + if !shouldConvert(gvk, targetGV.Version, targetAPIGroup) { + return obj, false, nil + } + + targetGVK := schema.GroupVersionKind{ + Group: targetGV.Group, + Version: targetGV.Version, + Kind: gvk.Kind, + } + + // Verify the target type exists in the scheme. + if !scheme.Recognizes(targetGVK) { + return nil, false, errors.Errorf("target GVK %s not recognized by scheme", targetGVK.String()) + } + + if convertible, ok := obj.(conversion.Convertible); ok { + // Create a new instance of the target type. + targetObj, err := scheme.New(targetGVK) + if err != nil { + return nil, false, errors.Wrapf(err, "failed to create target object for %s", targetGVK.String()) + } + + // Check if the target object is a Hub. + if hub, ok := targetObj.(conversion.Hub); ok { + if err := convertible.ConvertTo(hub); err != nil { + return nil, false, errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, targetGV.Version) + } + + // Ensure the GVK is set on the converted object. + hubObj := hub.(runtime.Object) + hubObj.GetObjectKind().SetGroupVersionKind(targetGVK) + + return hubObj, true, nil + } + } + + convertedObj, err := scheme.ConvertToVersion(obj, targetGVK.GroupVersion()) + if err != nil { + return nil, false, errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, targetGV.Version) + } + + return convertedObj, true, nil +} + +// shouldConvert determines if a resource needs conversion based on its GVK and target version. +func shouldConvert(gvk schema.GroupVersionKind, targetVersion string, targetAPIGroup string) bool { + // Only convert resources from the target API group. + if gvk.Group != targetAPIGroup { + return false + } + + // Don't convert if already at target version. + if gvk.Version == targetVersion { + return false + } + + return true +} + +// getInfoMessage returns an informational message for resources that don't need conversion. +func getInfoMessage(gvk schema.GroupVersionKind, targetVersion string, targetAPIGroup string) string { + if gvk.Group == targetAPIGroup && gvk.Version == targetVersion { + return fmt.Sprintf("Resource %s is already at version %s", gvk.Kind, targetVersion) + } + + // All other resources are from different API groups (pass-through). + return fmt.Sprintf("Skipping non-%s resource: %s", targetAPIGroup, gvk.String()) +} diff --git a/cmd/clusterctl/client/convert/resource_test.go b/cmd/clusterctl/client/convert/resource_test.go new file mode 100644 index 000000000000..1cf45268caae --- /dev/null +++ b/cmd/clusterctl/client/convert/resource_test.go @@ -0,0 +1,248 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package convert + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" +) + +func TestConvertResource(t *testing.T) { + targetGV := clusterv1.GroupVersion + + t.Run("convert v1beta1 Cluster to v1beta2", func(t *testing.T) { + cluster := &clusterv1beta1.Cluster{} + cluster.SetName("test-cluster") + cluster.SetNamespace("default") + cluster.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "Cluster", + }) + + convertedObj, wasConverted, err := convertResource(cluster, targetGV, scheme.Scheme, "cluster.x-k8s.io") + + if err != nil { + t.Fatalf("convertResource() failed: %v", err) + } + if !wasConverted { + t.Error("Expected resource to be converted") + } + if convertedObj == nil { + t.Fatal("Converted object is nil") + } + + // Verify the converted object is v1beta2. + convertedCluster, ok := convertedObj.(*clusterv1.Cluster) + if !ok { + t.Fatalf("Expected *clusterv1.Cluster, got %T", convertedObj) + } + if convertedCluster.Name != "test-cluster" { + t.Errorf("Expected name test-cluster, got %s", convertedCluster.Name) + } + + // Verify GVK is set correctly. + gvk := convertedCluster.GetObjectKind().GroupVersionKind() + if gvk.Version != "v1beta2" { + t.Errorf("Expected version v1beta2, got %s", gvk.Version) + } + }) + + t.Run("no-op for v1beta2 resource", func(t *testing.T) { + cluster := &clusterv1.Cluster{} + cluster.SetName("test-cluster") + cluster.SetNamespace("default") + cluster.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta2", + Kind: "Cluster", + }) + + convertedObj, wasConverted, err := convertResource(cluster, targetGV, scheme.Scheme, "cluster.x-k8s.io") + + if err != nil { + t.Fatalf("convertResource() failed: %v", err) + } + if wasConverted { + t.Error("Expected resource not to be converted") + } + if convertedObj != cluster { + t.Error("Expected original object to be returned") + } + }) + + t.Run("convert v1beta1 MachineDeployment to v1beta2", func(t *testing.T) { + md := &clusterv1beta1.MachineDeployment{} + md.SetName("test-md") + md.SetNamespace("default") + md.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "MachineDeployment", + }) + + convertedObj, wasConverted, err := convertResource(md, targetGV, scheme.Scheme, "cluster.x-k8s.io") + + if err != nil { + t.Fatalf("convertResource() failed: %v", err) + } + if !wasConverted { + t.Error("Expected resource to be converted") + } + if convertedObj == nil { + t.Fatal("Converted object is nil") + } + + // Verify the converted object is v1beta2. + convertedMD, ok := convertedObj.(*clusterv1.MachineDeployment) + if !ok { + t.Fatalf("Expected *clusterv1.MachineDeployment, got %T", convertedObj) + } + if convertedMD.Name != "test-md" { + t.Errorf("Expected name test-md, got %s", convertedMD.Name) + } + }) +} + +func TestShouldConvert(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + targetVersion string + want bool + }{ + { + name: "should convert v1beta1 to v1beta2", + gvk: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta1", + Kind: "Cluster", + }, + targetVersion: "v1beta2", + want: true, + }, + { + name: "should not convert v1beta2 to v1beta2", + gvk: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta2", + Kind: "Cluster", + }, + targetVersion: "v1beta2", + want: false, + }, + { + name: "should not convert non-CAPI resource", + gvk: schema.GroupVersionKind{ + Group: "infrastructure.cluster.x-k8s.io", + Version: "v1beta1", + Kind: "DockerCluster", + }, + targetVersion: "v1beta2", + want: false, + }, + { + name: "should not convert bootstrap resource", + gvk: schema.GroupVersionKind{ + Group: "bootstrap.cluster.x-k8s.io", + Version: "v1beta1", + Kind: "KubeadmConfig", + }, + targetVersion: "v1beta2", + want: false, + }, + { + name: "should not convert controlplane resource", + gvk: schema.GroupVersionKind{ + Group: "controlplane.cluster.x-k8s.io", + Version: "v1beta1", + Kind: "KubeadmControlPlane", + }, + targetVersion: "v1beta2", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldConvert(tt.gvk, tt.targetVersion, "cluster.x-k8s.io") + if got != tt.want { + t.Errorf("shouldConvert() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetInfoMessage(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + targetVersion string + wantContains string + }{ + { + name: "already at target version", + gvk: schema.GroupVersionKind{ + Group: "cluster.x-k8s.io", + Version: "v1beta2", + Kind: "Cluster", + }, + targetVersion: "v1beta2", + wantContains: "already at version", + }, + { + name: "non-CAPI resource", + gvk: schema.GroupVersionKind{ + Group: "infrastructure.cluster.x-k8s.io", + Version: "v1beta1", + Kind: "DockerCluster", + }, + targetVersion: "v1beta2", + wantContains: "Skipping non-cluster.x-k8s.io", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getInfoMessage(tt.gvk, tt.targetVersion, "cluster.x-k8s.io") + if got == "" { + t.Error("Expected non-empty info message") + } + if tt.wantContains != "" && !contains(got, tt.wantContains) { + t.Errorf("getInfoMessage() = %q, want to contain %q", got, tt.wantContains) + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/clusterctl/cmd/convert.go b/cmd/clusterctl/cmd/convert.go new file mode 100644 index 000000000000..7bd333ce6b20 --- /dev/null +++ b/cmd/clusterctl/cmd/convert.go @@ -0,0 +1,158 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/convert" +) + +type convertOptions struct { + output string + toVersion string +} + +var convertOpts = &convertOptions{} + +var convertCmd = &cobra.Command{ + Use: "convert [SOURCE]", + Short: "EXPERIMENTAL: Convert Cluster API resources between API versions", + Long: `EXPERIMENTAL: Convert Cluster API resources between API versions. + +This command is EXPERIMENTAL and may be removed in a future release! + +Scope and limitations: +- Only cluster.x-k8s.io resources are converted +- Other CAPI API groups are passed through unchanged +- ClusterClass patches are not converted +- Field order may change and comments will be removed in output +- API version references are dropped during conversion (except ClusterClass and external + remediation references) + +Examples: + # Convert from file to stdout + clusterctl convert cluster.yaml + + # Convert from stdin to stdout + cat cluster.yaml | clusterctl convert + + # Explicitly specify target + clusterctl convert cluster.yaml --to-version --output converted-cluster.yaml`, + + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runConvert(args) + }, +} + +func init() { + convertCmd.Flags().StringVarP(&convertOpts.output, "output", "o", "", "Output file path (default: stdout)") + convertCmd.Flags().StringVar(&convertOpts.toVersion, "to-version", clusterv1.GroupVersion.Version, fmt.Sprintf("Target API version for conversion. (Supported versions are: %s)", strings.Join(convert.SupportedTargetVersions, ", "))) + + RootCmd.AddCommand(convertCmd) +} + +func isSupportedTargetVersion(version string) bool { + for _, v := range convert.SupportedTargetVersions { + if v == version { + return true + } + } + return false +} + +func runConvert(args []string) error { + if !isSupportedTargetVersion(convertOpts.toVersion) { + return errors.Errorf("invalid --to-version value %q. Supported versions are %s", convertOpts.toVersion, strings.Join(convert.SupportedTargetVersions, ", ")) + } + + fmt.Fprintln(os.Stderr, "WARNING: This command is EXPERIMENTAL and may be removed in a future release!") + + var inputBytes []byte + var inputName string + var err error + + if len(args) == 0 { + inputBytes, err = io.ReadAll(os.Stdin) + if err != nil { + return errors.Wrap(err, "failed to read from stdin") + } + inputName = "stdin" + } else { + sourceFile := args[0] + // #nosec G304 + // command accepts user-provided file path by design. + inputBytes, err = os.ReadFile(sourceFile) + if err != nil { + return errors.Wrapf(err, "failed to read input file %q", sourceFile) + } + inputName = sourceFile + } + + ctx := context.Background() + c, err := client.New(ctx, "") + if err != nil { + return errors.Wrap(err, "failed to create clusterctl client") + } + + result, err := c.Convert(ctx, client.ConvertOptions{ + Input: inputBytes, + ToVersion: convertOpts.toVersion, + }) + if err != nil { + return errors.Wrap(err, "conversion failed") + } + + if convertOpts.output == "" { + _, err = os.Stdout.Write(result.Output) + if err != nil { + return errors.Wrap(err, "failed to write to stdout") + } + } else { + err = os.WriteFile(convertOpts.output, result.Output, 0600) + if err != nil { + return errors.Wrapf(err, "failed to write output file %q", convertOpts.output) + } + } + + if len(result.Messages) > 0 { + fmt.Fprintln(os.Stderr, "\nConversion messages:") + for _, msg := range result.Messages { + fmt.Fprintln(os.Stderr, " ", msg) + } + } + + fmt.Fprintf(os.Stderr, "\nConversion completed successfully\n") + fmt.Fprintf(os.Stderr, "Source: %s\n", inputName) + if convertOpts.output != "" { + fmt.Fprintf(os.Stderr, "Output: %s\n", convertOpts.output) + } else { + fmt.Fprintf(os.Stderr, "Output: stdout\n") + } + + return nil +} diff --git a/cmd/clusterctl/cmd/migrate.go b/cmd/clusterctl/cmd/migrate.go deleted file mode 100644 index d7be2884cc9e..000000000000 --- a/cmd/clusterctl/cmd/migrate.go +++ /dev/null @@ -1,189 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package cmd - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/pkg/errors" - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/runtime/schema" - - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" - "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/migrate" - "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" -) - -type migrateOptions struct { - output string - toVersion string -} - -var migrateOpts = &migrateOptions{} - -var supportedTargetVersions = []string{ - clusterv1.GroupVersion.Version, -} - -var migrateCmd = &cobra.Command{ - Use: "migrate [SOURCE]", - Short: "EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions", - Long: `EXPERIMENTAL: Migrate cluster.x-k8s.io resources between API versions. - -This command is EXPERIMENTAL and may be removed in a future release! - -Scope and limitations: -- Only cluster.x-k8s.io resources are converted -- Other CAPI API groups are passed through unchanged -- ClusterClass patches are not migrated -- Field order may change and comments will be removed in output -- API version references are dropped during conversion (except ClusterClass and external - remediation references) - -Examples: - # Migrate from file to stdout - clusterctl migrate cluster.yaml - - # Migrate from stdin to stdout - cat cluster.yaml | clusterctl migrate - - # Explicitly specify target - clusterctl migrate cluster.yaml --to-version --output migrated-cluster.yaml`, - - Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runMigrate(args) - }, -} - -func init() { - migrateCmd.Flags().StringVarP(&migrateOpts.output, "output", "o", "", "Output file path (default: stdout)") - migrateCmd.Flags().StringVar(&migrateOpts.toVersion, "to-version", clusterv1.GroupVersion.Version, fmt.Sprintf("Target API version for migration (supported: %s)", strings.Join(supportedTargetVersions, ", "))) - - RootCmd.AddCommand(migrateCmd) -} - -func isSupportedTargetVersion(version string) bool { - for _, v := range supportedTargetVersions { - if v == version { - return true - } - } - return false -} - -func runMigrate(args []string) error { - if !isSupportedTargetVersion(migrateOpts.toVersion) { - return errors.Errorf("invalid --to-version value %q: supported versions are %s", migrateOpts.toVersion, strings.Join(supportedTargetVersions, ", ")) - } - - fmt.Fprint(os.Stderr, "WARNING: This command is EXPERIMENTAL and may be removed in a future release!") - - var input io.Reader - var inputName string - - if len(args) == 0 { - input = os.Stdin - inputName = "stdin" - } else { - sourceFile := args[0] - // #nosec G304 - // command accepts user-provided file path by design - file, err := os.Open(sourceFile) - if err != nil { - return errors.Wrapf(err, "failed to open input file %q", sourceFile) - } - defer file.Close() - input = file - inputName = sourceFile - } - - // Determine output destination - var output io.Writer - var outputFile *os.File - var err error - - if migrateOpts.output == "" { - output = os.Stdout - } else { - outputFile, err = os.Create(migrateOpts.output) - if err != nil { - return errors.Wrapf(err, "failed to create output file %q", migrateOpts.output) - } - defer outputFile.Close() - output = outputFile - } - - // Create migration engine components - parser := migrate.NewYAMLParser(scheme.Scheme) - - targetGV := schema.GroupVersion{ - Group: clusterv1.GroupVersion.Group, - Version: migrateOpts.toVersion, - } - - converter, err := migrate.NewConverter(targetGV) - if err != nil { - return errors.Wrap(err, "failed to create converter") - } - - engine, err := migrate.NewEngine(parser, converter) - if err != nil { - return errors.Wrap(err, "failed to create migration engine") - } - - opts := migrate.MigrationOptions{ - Input: input, - Output: output, - Errors: os.Stderr, - ToVersion: migrateOpts.toVersion, - } - - result, err := engine.Migrate(opts) - if err != nil { - return errors.Wrap(err, "migration failed") - } - - if result.TotalResources > 0 { - fmt.Fprintf(os.Stderr, "\nMigration completed:\n") - fmt.Fprintf(os.Stderr, " Total resources processed: %d\n", result.TotalResources) - fmt.Fprintf(os.Stderr, " Resources converted: %d\n", result.ConvertedCount) - fmt.Fprintf(os.Stderr, " Resources skipped: %d\n", result.SkippedCount) - - if result.ErrorCount > 0 { - fmt.Fprintf(os.Stderr, " Resources with errors: %d\n", result.ErrorCount) - } - - if len(result.Warnings) > 0 { - fmt.Fprintf(os.Stderr, " Warnings: %d\n", len(result.Warnings)) - } - - fmt.Fprintf(os.Stderr, "\nSource: %s\n", inputName) - if migrateOpts.output != "" { - fmt.Fprintf(os.Stderr, "Output: %s\n", migrateOpts.output) - } - } - - if result.ErrorCount > 0 { - return errors.Errorf("migration completed with %d errors", result.ErrorCount) - } - - return nil -} diff --git a/cmd/clusterctl/internal/migrate/converter.go b/cmd/clusterctl/internal/migrate/converter.go deleted file mode 100644 index 8c56c2a80c46..000000000000 --- a/cmd/clusterctl/internal/migrate/converter.go +++ /dev/null @@ -1,166 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package migrate - -import ( - "fmt" - - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/conversion" - - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" - "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" -) - -// Converter handles conversion of individual CAPI resources between API versions. -type Converter struct { - scheme *runtime.Scheme - targetGV schema.GroupVersion - targetGVKMap gvkConversionMap -} - -// gvkConversionMap caches conversions from a source GroupVersionKind to its target GroupVersionKind. -type gvkConversionMap map[schema.GroupVersionKind]schema.GroupVersionKind - -// ConversionResult represents the outcome of converting a single resource. -type ConversionResult struct { - Object runtime.Object - // Converted indicates whether the object was actually converted - Converted bool - Error error - Warnings []string -} - -// NewConverter creates a new resource converter using the clusterctl scheme. -func NewConverter(targetGV schema.GroupVersion) (*Converter, error) { - return &Converter{ - scheme: scheme.Scheme, - targetGV: targetGV, - targetGVKMap: make(gvkConversionMap), - }, nil -} - -// ConvertResource converts a single resource to the target version. -// Returns the converted object, or the original if no conversion is needed. -func (c *Converter) ConvertResource(info ResourceInfo, obj runtime.Object) ConversionResult { - gvk := info.GroupVersionKind - - if gvk.Group == clusterv1.GroupVersion.Group && gvk.Version == c.targetGV.Version { - return ConversionResult{ - Object: obj, - Converted: false, - Warnings: []string{fmt.Sprintf("Resource %s/%s is already at version %s", gvk.Kind, info.Name, c.targetGV.Version)}, - } - } - - if gvk.Group != clusterv1.GroupVersion.Group { - return ConversionResult{ - Object: obj, - Converted: false, - Warnings: []string{fmt.Sprintf("Skipping non-%s resource: %s", clusterv1.GroupVersion.Group, gvk.String())}, - } - } - - targetGVK, err := c.getTargetGVK(gvk) - if err != nil { - return ConversionResult{ - Object: obj, - Converted: false, - Error: errors.Wrapf(err, "failed to determine target GVK for %s", gvk.String()), - } - } - - // Check if the object is already typed - // If it's typed and implements conversion.Convertible, use the custom ConvertTo method - if convertible, ok := obj.(conversion.Convertible); ok { - // Create a new instance of the target type - targetObj, err := c.scheme.New(targetGVK) - if err != nil { - return ConversionResult{ - Object: obj, - Converted: false, - Error: errors.Wrapf(err, "failed to create target object for %s", targetGVK.String()), - } - } - - // Check if the target object is a Hub - if hub, ok := targetObj.(conversion.Hub); ok { - if err := convertible.ConvertTo(hub); err != nil { - return ConversionResult{ - Object: obj, - Converted: false, - Error: errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, c.targetGV.Version), - } - } - - // Ensure the GVK is set on the converted object - hubObj := hub.(runtime.Object) - hubObj.GetObjectKind().SetGroupVersionKind(targetGVK) - - return ConversionResult{ - Object: hubObj, - Converted: true, - Error: nil, - Warnings: nil, - } - } - } - - // Use scheme-based conversion for all remaining cases - convertedObj, err := c.scheme.ConvertToVersion(obj, targetGVK.GroupVersion()) - if err != nil { - return ConversionResult{ - Object: obj, - Converted: false, - Error: errors.Wrapf(err, "failed to convert %s from %s to %s", gvk.Kind, gvk.Version, c.targetGV.Version), - } - } - - return ConversionResult{ - Object: convertedObj, - Converted: true, - Error: nil, - Warnings: nil, - } -} - -// getTargetGVK returns the target GroupVersionKind for a given source GVK. -func (c *Converter) getTargetGVK(sourceGVK schema.GroupVersionKind) (schema.GroupVersionKind, error) { - // Check cache first - if targetGVK, ok := c.targetGVKMap[sourceGVK]; ok { - return targetGVK, nil - } - - // Create target GVK with same kind but target version - targetGVK := schema.GroupVersionKind{ - Group: c.targetGV.Group, - Version: c.targetGV.Version, - Kind: sourceGVK.Kind, - } - - // Verify the target type exists in the scheme - if !c.scheme.Recognizes(targetGVK) { - return schema.GroupVersionKind{}, errors.Errorf("target GVK %s not recognized by scheme", targetGVK.String()) - } - - // Cache for future use - c.targetGVKMap[sourceGVK] = targetGVK - - return targetGVK, nil -} diff --git a/cmd/clusterctl/internal/migrate/converter_test.go b/cmd/clusterctl/internal/migrate/converter_test.go deleted file mode 100644 index 1cbfe2e3cf24..000000000000 --- a/cmd/clusterctl/internal/migrate/converter_test.go +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package migrate - -import ( - "testing" - - "k8s.io/apimachinery/pkg/runtime/schema" - - clusterv1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1" - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" -) - -func TestConverter_ConvertResource(t *testing.T) { - converter, err := NewConverter(clusterv1.GroupVersion) - if err != nil { - t.Fatalf("NewConverter() failed: %v", err) - } - - t.Run("convert v1beta1 Cluster to v1beta2", func(t *testing.T) { - cluster := &clusterv1beta1.Cluster{} - cluster.SetName("test-cluster") - cluster.SetNamespace("default") - - info := ResourceInfo{ - GroupVersionKind: schema.GroupVersionKind{ - Group: "cluster.x-k8s.io", - Version: "v1beta1", - Kind: "Cluster", - }, - Name: "test-cluster", - Namespace: "default", - } - - result := converter.ConvertResource(info, cluster) - - if result.Error != nil { - t.Fatalf("ConvertResource() failed: %v", result.Error) - } - if !result.Converted { - t.Error("Expected resource to be converted") - } - if result.Object == nil { - t.Fatal("Converted object is nil") - } - - // Verify the converted object is v1beta2 - convertedCluster, ok := result.Object.(*clusterv1.Cluster) - if !ok { - t.Fatalf("Expected *clusterv1beta2.Cluster, got %T", result.Object) - } - if convertedCluster.Name != "test-cluster" { - t.Errorf("Expected name test-cluster, got %s", convertedCluster.Name) - } - }) - - t.Run("no-op for v1beta2 resource", func(t *testing.T) { - cluster := &clusterv1.Cluster{} - cluster.SetName("test-cluster") - cluster.SetNamespace("default") - - info := ResourceInfo{ - GroupVersionKind: schema.GroupVersionKind{ - Group: "cluster.x-k8s.io", - Version: "v1beta2", - Kind: "Cluster", - }, - Name: "test-cluster", - Namespace: "default", - } - - result := converter.ConvertResource(info, cluster) - - if result.Error != nil { - t.Fatalf("ConvertResource() failed: %v", result.Error) - } - if result.Converted { - t.Error("Expected resource not to be converted") - } - if len(result.Warnings) == 0 { - t.Error("Expected warning for already-converted resource") - } - }) - - t.Run("convert v1beta1 MachineDeployment to v1beta2", func(t *testing.T) { - md := &clusterv1beta1.MachineDeployment{} - md.SetName("test-md") - md.SetNamespace("default") - - info := ResourceInfo{ - GroupVersionKind: schema.GroupVersionKind{ - Group: "cluster.x-k8s.io", - Version: "v1beta1", - Kind: "MachineDeployment", - }, - Name: "test-md", - Namespace: "default", - } - - result := converter.ConvertResource(info, md) - - if result.Error != nil { - t.Fatalf("ConvertResource() failed: %v", result.Error) - } - if !result.Converted { - t.Error("Expected resource to be converted") - } - if result.Object == nil { - t.Fatal("Converted object is nil") - } - - // Verify the converted object is v1beta2 - convertedMD, ok := result.Object.(*clusterv1.MachineDeployment) - if !ok { - t.Fatalf("Expected *clusterv1beta2.MachineDeployment, got %T", result.Object) - } - if convertedMD.Name != "test-md" { - t.Errorf("Expected name test-md, got %s", convertedMD.Name) - } - }) -} diff --git a/cmd/clusterctl/internal/migrate/doc.go b/cmd/clusterctl/internal/migrate/doc.go deleted file mode 100644 index 174500309051..000000000000 --- a/cmd/clusterctl/internal/migrate/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Package migrate implements mechanical conversion of CAPI core resources -// between API versions. -package migrate diff --git a/cmd/clusterctl/internal/migrate/engine.go b/cmd/clusterctl/internal/migrate/engine.go deleted file mode 100644 index d8517c2d6411..000000000000 --- a/cmd/clusterctl/internal/migrate/engine.go +++ /dev/null @@ -1,182 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package migrate - -import ( - "fmt" - "io" - - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/runtime/schema" - kerrors "k8s.io/apimachinery/pkg/util/errors" -) - -// Engine orchestrates the migration process by combining a parser and converter. -type Engine struct { - parser *YAMLParser - converter *Converter - - errs []error - warnings []string -} - -// MigrationOptions contains configuration for a migration run. -type MigrationOptions struct { - Input io.Reader - Output io.Writer - Errors io.Writer - ToVersion string -} - -// MigrationResult contains the outcome of a migration operation. -type MigrationResult struct { - TotalResources int - ConvertedCount int - SkippedCount int - ErrorCount int - Warnings []string - Errors []error -} - -// ResourceInfo contains metadata about a resource being processed. -type ResourceInfo struct { - GroupVersionKind schema.GroupVersionKind - Name string - Namespace string - // Index in the YAML stream - Index int -} - -// NewEngine creates a new migration engine with parser and converter. -func NewEngine(parser *YAMLParser, converter *Converter) (*Engine, error) { - if parser == nil || converter == nil { - return nil, errors.New("parser and converter must be provided") - } - return &Engine{ - parser: parser, - converter: converter, - errs: make([]error, 0), - warnings: make([]string, 0), - }, nil -} - -// appendError records an error for later aggregation. -func (e *Engine) appendError(err error) { - if err == nil { - return - } - e.errs = append(e.errs, err) -} - -// appendWarning records a warning message. -func (e *Engine) appendWarning(msg string) { - if msg == "" { - return - } - e.warnings = append(e.warnings, msg) -} - -// aggregateErrors returns an aggregated error from collected errors. -func (e *Engine) aggregateErrors() error { - return kerrors.NewAggregate(e.errs) -} - -// Migrate handles multi-document YAML streams, error collection, and reporting. -func (e *Engine) Migrate(opts MigrationOptions) (*MigrationResult, error) { - e.errs = make([]error, 0) - e.warnings = make([]string, 0) - - // Parse input YAML stream - documents, err := e.parser.ParseYAMLStream(opts.Input) - if err != nil { - return nil, errors.Wrap(err, "failed to parse YAML stream") - } - - result := &MigrationResult{ - TotalResources: len(documents), - ConvertedCount: 0, - SkippedCount: 0, - ErrorCount: 0, - } - - for i := range documents { - doc := &documents[i] - - switch doc.Type { - case ResourceTypeCoreV1Beta1: - info := ResourceInfo{ - GroupVersionKind: doc.GVK, - Index: doc.Index, - } - - conversionResult := e.converter.ConvertResource(info, doc.Object) - - if conversionResult.Error != nil { - e.appendError(errors.Wrapf(conversionResult.Error, "failed to convert document at index %d (%s)", doc.Index, doc.GVK.String())) - result.ErrorCount++ - } else if conversionResult.Converted { - doc.Object = conversionResult.Object - result.ConvertedCount++ - } else { - result.SkippedCount++ - for _, warning := range conversionResult.Warnings { - e.appendWarning(warning) - if opts.Errors != nil { - fmt.Fprintf(opts.Errors, "INFO: %s\n", warning) - } - } - } - - case ResourceTypeOtherCAPI: - // ResourceTypeOtherCAPI means cluster.x-k8s.io resources at versions other than v1beta1 - // (e.g., already at v1beta2) - pass through unchanged - result.SkippedCount++ - if opts.Errors != nil { - fmt.Fprintf(opts.Errors, "INFO: Resource %s is already at version %s, no conversion needed\n", doc.GVK.Kind, doc.GVK.Version) - } - - case ResourceTypeNonCAPI: - // Pass through non-CAPI resources unchanged - result.SkippedCount++ - if opts.Errors != nil { - fmt.Fprintf(opts.Errors, "INFO: Passing through non-CAPI resource: %s\n", doc.GVK.String()) - } - - case ResourceTypeUnsupported: - // Pass through unsupported resources with warning - result.SkippedCount++ - warning := fmt.Sprintf("Unable to parse document at index %d, passing through unchanged", doc.Index) - e.appendWarning(warning) - if opts.Errors != nil { - fmt.Fprintf(opts.Errors, "WARNING: %s\n", warning) - } - } - } - - if err := e.parser.SerializeYAMLStream(documents, opts.Output); err != nil { - return nil, errors.Wrap(err, "failed to serialize output") - } - - result.Warnings = e.warnings - result.Errors = e.errs - - if len(e.errs) > 0 { - return result, e.aggregateErrors() - } - - return result, nil -} diff --git a/cmd/clusterctl/internal/migrate/engine_test.go b/cmd/clusterctl/internal/migrate/engine_test.go deleted file mode 100644 index c51096c31d89..000000000000 --- a/cmd/clusterctl/internal/migrate/engine_test.go +++ /dev/null @@ -1,233 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package migrate - -import ( - "bytes" - "strings" - "testing" - - . "github.com/onsi/gomega" - - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" - "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" -) - -func TestEngine_Migrate(t *testing.T) { - g := NewWithT(t) - - parser := NewYAMLParser(scheme.Scheme) - converter, err := NewConverter(clusterv1.GroupVersion) - g.Expect(err).ToNot(HaveOccurred()) - - engine, err := NewEngine(parser, converter) - g.Expect(err).ToNot(HaveOccurred()) - - t.Run("should convert v1beta1 cluster.x-k8s.io resources", func(t *testing.T) { - g := NewWithT(t) - - input := `apiVersion: cluster.x-k8s.io/v1beta1 -kind: Cluster -metadata: - name: test-cluster - namespace: default -spec: - clusterNetwork: - pods: - cidrBlocks: - - 192.168.0.0/16 -` - - inputReader := strings.NewReader(input) - outputBuffer := &bytes.Buffer{} - errorsBuffer := &bytes.Buffer{} - - result, err := engine.Migrate(MigrationOptions{ - Input: inputReader, - Output: outputBuffer, - Errors: errorsBuffer, - }) - - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(result).ToNot(BeNil()) - g.Expect(result.TotalResources).To(Equal(1)) - g.Expect(result.ConvertedCount).To(Equal(1)) - g.Expect(result.SkippedCount).To(Equal(0)) - g.Expect(result.ErrorCount).To(Equal(0)) - - output := outputBuffer.String() - g.Expect(output).To(ContainSubstring("apiVersion: cluster.x-k8s.io/v1beta2")) - g.Expect(output).To(ContainSubstring("kind: Cluster")) - g.Expect(output).To(ContainSubstring("name: test-cluster")) - }) - - t.Run("should pass through non-CAPI resources", func(t *testing.T) { - g := NewWithT(t) - - input := `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: default -data: - key: value -` - - inputReader := strings.NewReader(input) - outputBuffer := &bytes.Buffer{} - errorsBuffer := &bytes.Buffer{} - - result, err := engine.Migrate(MigrationOptions{ - Input: inputReader, - Output: outputBuffer, - Errors: errorsBuffer, - }) - - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(result).ToNot(BeNil()) - g.Expect(result.TotalResources).To(Equal(1)) - g.Expect(result.ConvertedCount).To(Equal(0)) - g.Expect(result.SkippedCount).To(Equal(1)) - g.Expect(result.ErrorCount).To(Equal(0)) - - output := outputBuffer.String() - g.Expect(output).To(ContainSubstring("apiVersion: v1")) - g.Expect(output).To(ContainSubstring("kind: ConfigMap")) - - errorsOutput := errorsBuffer.String() - g.Expect(errorsOutput).To(ContainSubstring("INFO: Passing through non-CAPI resource")) - }) - - t.Run("should handle multi-document YAML with mixed resources", func(t *testing.T) { - g := NewWithT(t) - - input := `apiVersion: cluster.x-k8s.io/v1beta1 -kind: Cluster -metadata: - name: test-cluster - namespace: default -spec: - clusterNetwork: - pods: - cidrBlocks: - - 192.168.0.0/16 ---- -apiVersion: v1 -kind: Namespace -metadata: - name: test-namespace ---- -apiVersion: cluster.x-k8s.io/v1beta1 -kind: Machine -metadata: - name: test-machine - namespace: default -spec: - clusterName: test-cluster - bootstrap: - dataSecretName: test-bootstrap -` - - inputReader := strings.NewReader(input) - outputBuffer := &bytes.Buffer{} - errorsBuffer := &bytes.Buffer{} - - result, err := engine.Migrate(MigrationOptions{ - Input: inputReader, - Output: outputBuffer, - Errors: errorsBuffer, - }) - - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(result).ToNot(BeNil()) - g.Expect(result.TotalResources).To(Equal(3)) - g.Expect(result.ConvertedCount).To(Equal(2)) - g.Expect(result.SkippedCount).To(Equal(1)) - g.Expect(result.ErrorCount).To(Equal(0)) - - output := outputBuffer.String() - // Check that converted resources are at v1beta2 - g.Expect(strings.Count(output, "apiVersion: cluster.x-k8s.io/v1beta2")).To(Equal(2)) - // Check that non-CAPI resource is unchanged - g.Expect(output).To(ContainSubstring("apiVersion: v1")) - g.Expect(output).To(ContainSubstring("kind: Namespace")) - // Check document separators are present - g.Expect(strings.Count(output, "---")).To(Equal(2)) - }) - - t.Run("should skip resources already at target version", func(t *testing.T) { - g := NewWithT(t) - - input := `apiVersion: cluster.x-k8s.io/v1beta2 -kind: Cluster -metadata: - name: test-cluster - namespace: default -spec: - topology: - class: test-class - version: v1.28.0 -` - - inputReader := strings.NewReader(input) - outputBuffer := &bytes.Buffer{} - errorsBuffer := &bytes.Buffer{} - - result, err := engine.Migrate(MigrationOptions{ - Input: inputReader, - Output: outputBuffer, - Errors: errorsBuffer, - }) - - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(result).ToNot(BeNil()) - g.Expect(result.TotalResources).To(Equal(1)) - g.Expect(result.ConvertedCount).To(Equal(0)) - g.Expect(result.SkippedCount).To(Equal(1)) - g.Expect(result.ErrorCount).To(Equal(0)) - - output := outputBuffer.String() - g.Expect(output).To(ContainSubstring("apiVersion: cluster.x-k8s.io/v1beta2")) - - errorsOutput := errorsBuffer.String() - g.Expect(errorsOutput).To(ContainSubstring("INFO:")) - g.Expect(errorsOutput).To(ContainSubstring("already at version")) - g.Expect(errorsOutput).To(ContainSubstring("v1beta2")) - }) - - t.Run("should handle empty input", func(t *testing.T) { - g := NewWithT(t) - - input := "" - inputReader := strings.NewReader(input) - outputBuffer := &bytes.Buffer{} - errorsBuffer := &bytes.Buffer{} - - result, err := engine.Migrate(MigrationOptions{ - Input: inputReader, - Output: outputBuffer, - Errors: errorsBuffer, - }) - - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(result).ToNot(BeNil()) - g.Expect(result.TotalResources).To(Equal(0)) - g.Expect(result.ConvertedCount).To(Equal(0)) - g.Expect(result.SkippedCount).To(Equal(0)) - g.Expect(result.ErrorCount).To(Equal(0)) - }) -} diff --git a/cmd/clusterctl/internal/migrate/parser.go b/cmd/clusterctl/internal/migrate/parser.go deleted file mode 100644 index ac722ebb5158..000000000000 --- a/cmd/clusterctl/internal/migrate/parser.go +++ /dev/null @@ -1,216 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package migrate - -import ( - "bufio" - "bytes" - "io" - - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - serializer "k8s.io/apimachinery/pkg/runtime/serializer" - jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json" - yamlserializer "k8s.io/apimachinery/pkg/runtime/serializer/yaml" - yamlutil "k8s.io/apimachinery/pkg/util/yaml" - - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" -) - -// Document represents a single YAML document with associated metadata. -type Document struct { - Object runtime.Object - Raw []byte - GVK schema.GroupVersionKind - Type ResourceType - // Index indicates the document's position in the original stream. - Index int -} - -// ResourceType classifies the type of Kubernetes resource. -type ResourceType int - -const ( - // ResourceTypeCoreV1Beta1 identifies v1beta1 core ClusterAPI resources. - ResourceTypeCoreV1Beta1 ResourceType = iota - // ResourceTypeOtherCAPI identifies other cluster.x-k8s.io resources (non-v1beta1). - ResourceTypeOtherCAPI - // ResourceTypeNonCAPI identifies Kubernetes objects outside of ClusterAPI groups. - ResourceTypeNonCAPI - // ResourceTypeUnsupported identifies documents that could not be parsed or classified. - ResourceTypeUnsupported -) - -// YAMLParser parses YAML documents into runtime objects using Kubernetes serializers. -type YAMLParser struct { - scheme *runtime.Scheme - codecFactory serializer.CodecFactory - unstructuredDecoder runtime.Decoder - typedDecoder runtime.Decoder - yamlSerializer runtime.Serializer -} - -// NewYAMLParser creates a new YAML parser with the given scheme. -func NewYAMLParser(scheme *runtime.Scheme) *YAMLParser { - codecFactory := serializer.NewCodecFactory(scheme) - - return &YAMLParser{ - scheme: scheme, - codecFactory: codecFactory, - unstructuredDecoder: yamlserializer.NewDecodingSerializer(unstructured.UnstructuredJSONScheme), - typedDecoder: codecFactory.UniversalDeserializer(), - yamlSerializer: jsonserializer.NewSerializerWithOptions( - jsonserializer.DefaultMetaFactory, - scheme, - scheme, - jsonserializer.SerializerOptions{Yaml: true, Pretty: false, Strict: false}, - ), - } -} - -// ParseYAMLStream parses a multi-document YAML stream into individual documents. -func (p *YAMLParser) ParseYAMLStream(reader io.Reader) ([]Document, error) { - yamlReader := yamlutil.NewYAMLReader(bufio.NewReader(reader)) - - var documents []Document - index := 0 - - for { - raw, err := yamlReader.Read() - if err == io.EOF { - break - } - if err != nil { - return nil, errors.Wrap(err, "failed to read YAML document") - } - - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 { - continue - } - - doc, err := p.parseDocument(trimmed, raw, index) - if err != nil { - // Treat parsing failures as unsupported documents but keep raw bytes. - documents = append(documents, Document{ - Object: nil, - Raw: p.ensureTrailingNewline(raw), - GVK: schema.GroupVersionKind{}, - Type: ResourceTypeUnsupported, - Index: index, - }) - } else { - documents = append(documents, doc) - } - index++ - } - - return documents, nil -} - -// SerializeYAMLStream writes documents back out as a multi-document YAML stream. -func (p *YAMLParser) SerializeYAMLStream(docs []Document, writer io.Writer) error { - if len(docs) == 0 { - return nil - } - - for i, doc := range docs { - // Add document separator before each document except the first - if i > 0 { - if _, err := io.WriteString(writer, "---\n"); err != nil { - return errors.Wrap(err, "failed to write document separator") - } - } - - if doc.Object != nil { - buf := &bytes.Buffer{} - if err := p.yamlSerializer.Encode(doc.Object, buf); err != nil { - return errors.Wrapf(err, "failed to encode document at index %d", doc.Index) - } - if _, err := writer.Write(p.ensureTrailingNewline(buf.Bytes())); err != nil { - return errors.Wrapf(err, "failed to write document at index %d", doc.Index) - } - continue - } - - if _, err := writer.Write(p.ensureTrailingNewline(doc.Raw)); err != nil { - return errors.Wrapf(err, "failed to write raw document at index %d", doc.Index) - } - } - - return nil -} - -func (p *YAMLParser) parseDocument(trimmed []byte, original []byte, index int) (Document, error) { - obj := &unstructured.Unstructured{} - _, gvk, err := p.unstructuredDecoder.Decode(trimmed, nil, obj) - if err != nil || gvk == nil || gvk.Empty() { - return Document{}, errors.New("unable to decode document") - } - - resourceType := p.classifyResource(*gvk) - - var runtimeObj runtime.Object - if resourceType == ResourceTypeCoreV1Beta1 || resourceType == ResourceTypeOtherCAPI { - if typedObj, _, err := p.typedDecoder.Decode(trimmed, gvk, nil); err == nil { - runtimeObj = typedObj - } else { - runtimeObj = obj - } - } else { - runtimeObj = obj - } - - return Document{ - Object: runtimeObj, - Raw: p.ensureTrailingNewline(original), - GVK: *gvk, - Type: resourceType, - Index: index, - }, nil -} - -func (p *YAMLParser) ensureTrailingNewline(content []byte) []byte { - if len(content) == 0 { - return content - } - if content[len(content)-1] != '\n' { - content = append(content, '\n') - } - return content -} - -func (p *YAMLParser) classifyResource(gvk schema.GroupVersionKind) ResourceType { - if p.isCoreCAPIGroup(gvk.Group) { - if gvk.Version == "v1beta1" { - return ResourceTypeCoreV1Beta1 - } - return ResourceTypeOtherCAPI - } - return ResourceTypeNonCAPI -} - -func (p *YAMLParser) isCoreCAPIGroup(group string) bool { - _, ok := coreCapiGroups[group] - return ok -} - -var coreCapiGroups = map[string]struct{}{ - clusterv1.GroupVersion.Group: {}, -} diff --git a/cmd/clusterctl/internal/migrate/parser_test.go b/cmd/clusterctl/internal/migrate/parser_test.go deleted file mode 100644 index e8d2e81144e2..000000000000 --- a/cmd/clusterctl/internal/migrate/parser_test.go +++ /dev/null @@ -1,172 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package migrate - -import ( - "bytes" - "strings" - "testing" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" -) - -func TestYAMLParser_ParseYAMLStream(t *testing.T) { - scheme := runtime.NewScheme() - parser := NewYAMLParser(scheme) - - testYAML := `apiVersion: cluster.x-k8s.io/v1beta1 -kind: Cluster -metadata: - name: test-cluster - namespace: default -spec: - clusterNetwork: - pods: - cidrBlocks: ["192.168.0.0/16"] ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config -data: - key: value ---- -apiVersion: cluster.x-k8s.io/v1beta2 -kind: Machine -metadata: - name: test-machine -spec: - clusterName: test-cluster` - - reader := strings.NewReader(testYAML) - docs, err := parser.ParseYAMLStream(reader) - if err != nil { - t.Fatalf("ParseYAMLStream failed: %v", err) - } - - if len(docs) != 3 { - t.Fatalf("Expected 3 documents, got %d", len(docs)) - } - - // Test first document (v1beta1 CAPI resource) - if docs[0].Type != ResourceTypeCoreV1Beta1 { - t.Errorf("Expected first document to be ResourceTypeCoreV1Beta1, got %v", docs[0].Type) - } - expectedGVK := schema.GroupVersionKind{ - Group: "cluster.x-k8s.io", - Version: "v1beta1", - Kind: "Cluster", - } - if docs[0].GVK != expectedGVK { - t.Errorf("Expected GVK %v, got %v", expectedGVK, docs[0].GVK) - } - - // Test second document (non-CAPI resource) - if docs[1].Type != ResourceTypeNonCAPI { - t.Errorf("Expected second document to be ResourceTypeNonCAPI, got %v", docs[1].Type) - } - - // Test third document (v1beta2 CAPI resource) - if docs[2].Type != ResourceTypeOtherCAPI { - t.Errorf("Expected third document to be ResourceTypeOtherCAPI, got %v", docs[2].Type) - } -} - -func TestYAMLParser_SerializeYAMLStream(t *testing.T) { - scheme := runtime.NewScheme() - parser := NewYAMLParser(scheme) - - // Create test documents - docs := []Document{ - { - Object: nil, - Raw: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\ndata:\n key: value"), - GVK: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, - Type: ResourceTypeNonCAPI, - Index: 0, - }, - { - Object: nil, - Raw: []byte("apiVersion: cluster.x-k8s.io/v1beta1\nkind: Cluster\nmetadata:\n name: test-cluster"), - GVK: schema.GroupVersionKind{Group: "cluster.x-k8s.io", Version: "v1beta1", Kind: "Cluster"}, - Type: ResourceTypeCoreV1Beta1, - Index: 1, - }, - } - - var buf bytes.Buffer - err := parser.SerializeYAMLStream(docs, &buf) - if err != nil { - t.Fatalf("SerializeYAMLStream failed: %v", err) - } - - output := buf.String() - - if !strings.Contains(output, "---\n") { - t.Error("Expected document separator '---' in output") - } - - // Check that both documents are present - if !strings.Contains(output, "ConfigMap") { - t.Error("Expected ConfigMap in output") - } - if !strings.Contains(output, "Cluster") { - t.Error("Expected Cluster in output") - } -} - -func TestYAMLParser_EmptyInput(t *testing.T) { - scheme := runtime.NewScheme() - parser := NewYAMLParser(scheme) - - reader := strings.NewReader("") - docs, err := parser.ParseYAMLStream(reader) - if err != nil { - t.Fatalf("ParseYAMLStream failed on empty input: %v", err) - } - - if len(docs) != 0 { - t.Fatalf("Expected 0 documents for empty input, got %d", len(docs)) - } -} - -func TestYAMLParser_InvalidYAML(t *testing.T) { - scheme := runtime.NewScheme() - parser := NewYAMLParser(scheme) - - invalidYAML := `this is not valid yaml: [unclosed bracket` - reader := strings.NewReader(invalidYAML) - - docs, err := parser.ParseYAMLStream(reader) - if err != nil { - t.Fatalf("ParseYAMLStream should handle invalid YAML gracefully: %v", err) - } - - if len(docs) != 1 { - t.Fatalf("Expected 1 document for invalid YAML, got %d", len(docs)) - } - - if docs[0].Type != ResourceTypeUnsupported { - t.Errorf("Expected invalid YAML to be classified as ResourceTypeUnsupported, got %v", docs[0].Type) - } - - expectedRaw := invalidYAML + "\n" - if string(docs[0].Raw) != expectedRaw { - t.Errorf("Expected raw content to be preserved with newline, got %q", string(docs[0].Raw)) - } -} diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index eaf1ef834e21..5f7ee645a8de 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -56,7 +56,7 @@ - [generate yaml](clusterctl/commands/generate-yaml.md) - [get kubeconfig](clusterctl/commands/get-kubeconfig.md) - [describe cluster](clusterctl/commands/describe-cluster.md) - - [migrate](clusterctl/commands/migrate.md) + - [convert](clusterctl/commands/convert.md) - [move](./clusterctl/commands/move.md) - [upgrade](clusterctl/commands/upgrade.md) - [delete](clusterctl/commands/delete.md) diff --git a/docs/book/src/clusterctl/commands/commands.md b/docs/book/src/clusterctl/commands/commands.md index 7f0f1dcd89ae..8cbbe5832ba2 100644 --- a/docs/book/src/clusterctl/commands/commands.md +++ b/docs/book/src/clusterctl/commands/commands.md @@ -14,7 +14,7 @@ | [`clusterctl help`](additional-commands.md#clusterctl-help) | Help about any command. | | [`clusterctl init`](init.md) | Initialize a management cluster. | | [`clusterctl init list-images`](additional-commands.md#clusterctl-init-list-images) | Lists the container images required for initializing the management cluster. | -| [`clusterctl migrate`](migrate.md) | **EXPERIMENTAL**: Migrate cluster.x-k8s.io resources between API versions. | +| [`clusterctl convert`](convert.md) | **EXPERIMENTAL**: Convert Cluster API core resources (cluster.x-k8s.io) between API versions. | | [`clusterctl move`](move.md) | Move Cluster API objects and all their dependencies between management clusters. | | [`clusterctl upgrade plan`](upgrade.md#upgrade-plan) | Provide a list of recommended target versions for upgrading Cluster API providers in a management cluster. | | [`clusterctl upgrade apply`](upgrade.md#upgrade-apply) | Apply new versions of Cluster API core and providers in a management cluster. | diff --git a/docs/book/src/clusterctl/commands/migrate.md b/docs/book/src/clusterctl/commands/convert.md similarity index 57% rename from docs/book/src/clusterctl/commands/migrate.md rename to docs/book/src/clusterctl/commands/convert.md index d555303d3fce..5a260c7ff579 100644 --- a/docs/book/src/clusterctl/commands/migrate.md +++ b/docs/book/src/clusterctl/commands/convert.md @@ -1,41 +1,41 @@ -# clusterctl migrate +# clusterctl convert **Warning**: This command is EXPERIMENTAL and may be removed in a future release! -The `clusterctl migrate` command converts cluster.x-k8s.io resources between API versions. +The `clusterctl convert` command converts Cluster API resources between API versions. ## Usage ```bash -clusterctl migrate [SOURCE] [flags] +clusterctl convert [SOURCE] [flags] ``` ## Examples ```bash -# Migrate from file to stdout -clusterctl migrate cluster.yaml +# Convert from file to stdout +clusterctl convert cluster.yaml -# Migrate from stdin to stdout -cat cluster.yaml | clusterctl migrate +# Convert from stdin to stdout +cat cluster.yaml | clusterctl convert # Save output to a file -clusterctl migrate cluster.yaml --output migrated-cluster.yaml +clusterctl convert cluster.yaml --output converted-cluster.yaml # Explicitly specify target version -clusterctl migrate cluster.yaml --to-version v1beta2 --output migrated-cluster.yaml +clusterctl convert cluster.yaml --to-version v1beta2 --output converted-cluster.yaml ``` ## Flags - `--output, -o`: Output file path (default: stdout) -- `--to-version`: Target API version for migration (default: "v1beta2") +- `--to-version`: Target API version for conversion (default: "v1beta2") ## Scope and Limitations - **Only cluster.x-k8s.io resources are converted** - Core CAPI resources like Cluster, MachineDeployment, Machine, etc. - **Other CAPI API groups are passed through unchanged** - Infrastructure, bootstrap, and control plane provider resources are not converted -- **ClusterClass patches are not migrated** - Manual intervention required for ClusterClass patch conversions +- **ClusterClass patches are not converted** - Manual intervention required for ClusterClass patch conversions - **Field order may change** - YAML field ordering is not preserved in the output - **Comments are removed** - YAML comments are stripped during conversion - **API version references are dropped** - Except for ClusterClass and external remediation references