diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0954d98..1d351e474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Features - storage: add `move` command for moving objects within and across buckets #838 +- sks: add `name` flag to kubeconfig command to set custom cluster and context name - Re-introduce ARM64 builds for Windows #844 ### Improvements diff --git a/cmd/compute/sks/sks_kubeconfig.go b/cmd/compute/sks/sks_kubeconfig.go index 10c3b4dd6..f3b07be88 100644 --- a/cmd/compute/sks/sks_kubeconfig.go +++ b/cmd/compute/sks/sks_kubeconfig.go @@ -6,7 +6,7 @@ import ( "fmt" "github.com/spf13/cobra" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" exocmd "github.com/exoscale/cli/cmd" "github.com/exoscale/cli/pkg/globalstate" @@ -23,6 +23,7 @@ type sksKubeconfigCmd struct { ExecCredential bool `cli-short:"x" cli-usage:"output an ExecCredential object to use with a kubeconfig user.exec mode"` Groups []string `cli-flag:"group" cli-short:"g" cli-usage:"client certificate group. Can be specified multiple times. Defaults to system:masters"` + Name string `cli-short:"n" cli-usage:"cluster name to use in the generated kubeconfig"` TTL int64 `cli-short:"t" cli-usage:"client certificate validity duration in seconds"` Zone v3.ZoneName `cli-short:"z" cli-usage:"SKS cluster zone"` } @@ -111,6 +112,10 @@ func (c *sksKubeconfigCmd) CmdPreRun(cmd *cobra.Command, args []string) error { } func (c *sksKubeconfigCmd) CmdRun(_ *cobra.Command, _ []string) error { + if c.ExecCredential && c.Name != "" { + return fmt.Errorf("--name cannot be used with --exec-credential") + } + ctx := exocmd.GContext client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) if err != nil { @@ -150,6 +155,13 @@ func (c *sksKubeconfigCmd) CmdRun(_ *cobra.Command, _ []string) error { } if !c.ExecCredential { + if c.Name != "" { + kubeconfig, err = setSKSKubeconfigClusterName(kubeconfig, c.Name) + if err != nil { + return fmt.Errorf("error rewriting kubeconfig content: %w", err) + } + } + fmt.Print(string(kubeconfig)) return nil } @@ -190,6 +202,98 @@ func (c *sksKubeconfigCmd) CmdRun(_ *cobra.Command, _ []string) error { return nil } +func setSKSKubeconfigClusterName(kubeconfig []byte, name string) ([]byte, error) { + if name == "" { + return kubeconfig, nil + } + + var doc yaml.Node + if err := yaml.Unmarshal(kubeconfig, &doc); err != nil { + return nil, err + } + + root := yamlDocumentRoot(&doc) + clusters := yamlMappingValue(root, "clusters") + if clusters == nil || clusters.Kind != yaml.SequenceNode || len(clusters.Content) == 0 { + return nil, fmt.Errorf("cluster name not found") + } + + oldName := "" + for _, cluster := range clusters.Content { + clusterName := yamlMappingValue(cluster, "name") + if clusterName == nil { + continue + } + + if oldName == "" { + oldName = clusterName.Value + } + if clusterName.Value == oldName { + setYAMLDoubleQuotedString(clusterName, name) + } + } + if oldName == "" { + return nil, fmt.Errorf("cluster name not found") + } + + contexts := yamlMappingValue(root, "contexts") + if contexts != nil && contexts.Kind == yaml.SequenceNode { + for _, context := range contexts.Content { + contextName := yamlMappingValue(context, "name") + if contextName != nil && contextName.Value == oldName { + setYAMLDoubleQuotedString(contextName, name) + } + + contextValue := yamlMappingValue(context, "context") + contextCluster := yamlMappingValue(contextValue, "cluster") + if contextCluster != nil && contextCluster.Value == oldName { + setYAMLDoubleQuotedString(contextCluster, name) + } + } + } + + currentContext := yamlMappingValue(root, "current-context") + if currentContext != nil && currentContext.Value == oldName { + setYAMLDoubleQuotedString(currentContext, name) + } + + out, err := yaml.Marshal(&doc) + if err != nil { + return nil, err + } + + return out, nil +} + +func yamlDocumentRoot(node *yaml.Node) *yaml.Node { + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + + return node +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i < len(node.Content)-1; i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + + return nil +} + +func setYAMLDoubleQuotedString(node *yaml.Node, value string) { + node.Kind = yaml.ScalarNode + node.Tag = "!!str" + node.Value = value + node.Style = yaml.DoubleQuotedStyle +} + func init() { cobra.CheckErr(exocmd.RegisterCLICommand(sksCmd, &sksKubeconfigCmd{ CliCommandSettings: exocmd.DefaultCLICmdSettings(), diff --git a/cmd/compute/sks/sks_kubeconfig_test.go b/cmd/compute/sks/sks_kubeconfig_test.go new file mode 100644 index 000000000..d5380adbb --- /dev/null +++ b/cmd/compute/sks/sks_kubeconfig_test.go @@ -0,0 +1,104 @@ +package sks + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +const sksKubeconfigTestClusterID = "153fcc53-1197-46ae-a8e0-ccf6d09efcb0" + +const sksKubeconfigTestContent = `apiVersion: v1 +kind: Config +clusters: +- name: 153fcc53-1197-46ae-a8e0-ccf6d09efcb0 + cluster: + certificate-authority-data: authority + server: https://153fcc53-1197-46ae-a8e0-ccf6d09efcb0.sks-ch-gva-2.exo.io:443 +users: +- name: admin + user: + client-certificate-data: certificate + client-key-data: key +contexts: +- name: 153fcc53-1197-46ae-a8e0-ccf6d09efcb0 + context: + cluster: 153fcc53-1197-46ae-a8e0-ccf6d09efcb0 + user: admin +current-context: 153fcc53-1197-46ae-a8e0-ccf6d09efcb0 +preferences: {} +` + +func TestSetSKSKubeconfigClusterNameEmptyNamePreservesContent(t *testing.T) { + out, err := setSKSKubeconfigClusterName([]byte(sksKubeconfigTestContent), "") + require.NoError(t, err) + require.Equal(t, sksKubeconfigTestContent, string(out)) +} + +func TestSetSKSKubeconfigClusterNameRewritesClusterIdentity(t *testing.T) { + const name = "my cluster" + + out, err := setSKSKubeconfigClusterName([]byte(sksKubeconfigTestContent), name) + require.NoError(t, err) + + var kubeconfig struct { + Clusters []struct { + Name string `yaml:"name"` + Cluster struct { + Server string `yaml:"server"` + } `yaml:"cluster"` + } `yaml:"clusters"` + Users []struct { + Name string `yaml:"name"` + } `yaml:"users"` + Contexts []struct { + Name string `yaml:"name"` + Context struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` + } `yaml:"context"` + } `yaml:"contexts"` + CurrentContext string `yaml:"current-context"` + } + require.NoError(t, yaml.Unmarshal(out, &kubeconfig)) + + require.Len(t, kubeconfig.Clusters, 1) + require.Equal(t, name, kubeconfig.Clusters[0].Name) + require.Equal(t, "https://"+sksKubeconfigTestClusterID+".sks-ch-gva-2.exo.io:443", kubeconfig.Clusters[0].Cluster.Server) + require.Len(t, kubeconfig.Users, 1) + require.Equal(t, "admin", kubeconfig.Users[0].Name) + require.Len(t, kubeconfig.Contexts, 1) + require.Equal(t, name, kubeconfig.Contexts[0].Name) + require.Equal(t, name, kubeconfig.Contexts[0].Context.Cluster) + require.Equal(t, "admin", kubeconfig.Contexts[0].Context.User) + require.Equal(t, name, kubeconfig.CurrentContext) + + outString := string(out) + require.Contains(t, outString, `name: "my cluster"`) + require.Contains(t, outString, `cluster: "my cluster"`) + require.Contains(t, outString, `current-context: "my cluster"`) + require.Contains(t, outString, "server: https://"+sksKubeconfigTestClusterID+".sks-ch-gva-2.exo.io:443") + require.NotContains(t, outString, "server: https://my cluster") +} + +func TestSKSKubeconfigCmdRejectsNameWithExecCredential(t *testing.T) { + cmd := &sksKubeconfigCmd{ + ExecCredential: true, + Name: "my cluster", + } + + err := cmd.CmdRun(nil, nil) + require.EqualError(t, err, "--name cannot be used with --exec-credential") +} + +func TestSetSKSKubeconfigClusterNameEscapesDoubleQuotedName(t *testing.T) { + const name = `team "a" cluster` + + out, err := setSKSKubeconfigClusterName([]byte(sksKubeconfigTestContent), name) + require.NoError(t, err) + + outString := string(out) + require.Contains(t, outString, `name: "team \"a\" cluster"`) + require.Contains(t, outString, `current-context: "team \"a\" cluster"`) +} diff --git a/go.mod b/go.mod index d83c30ad2..4f51c27ba 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( golang.org/x/term v0.37.0 golang.org/x/text v0.31.0 gopkg.in/h2non/gentleman.v2 v2.0.4 - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -106,5 +106,5 @@ require ( gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect )