Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/clusterctl/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/clusterctl/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions cmd/clusterctl/client/convert.go
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions cmd/clusterctl/client/convert/convert.go
Original file line number Diff line number Diff line change
@@ -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
}
147 changes: 147 additions & 0 deletions cmd/clusterctl/client/convert/convert_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading