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/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index d1f2fc3ea859..5f7ee645a8de 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) + - [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 8cabf64b2927..8cbbe5832ba2 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 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/convert.md b/docs/book/src/clusterctl/commands/convert.md new file mode 100644 index 000000000000..5a260c7ff579 --- /dev/null +++ b/docs/book/src/clusterctl/commands/convert.md @@ -0,0 +1,41 @@ +# clusterctl convert + +**Warning**: This command is EXPERIMENTAL and may be removed in a future release! + +The `clusterctl convert` command converts Cluster API resources between API versions. + +## Usage + +```bash +clusterctl convert [SOURCE] [flags] +``` + +## Examples + +```bash +# Convert from file to stdout +clusterctl convert cluster.yaml + +# Convert from stdin to stdout +cat cluster.yaml | clusterctl convert + +# Save output to a file +clusterctl convert cluster.yaml --output converted-cluster.yaml + +# Explicitly specify target version +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 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 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