diff --git a/cmd/kswitch/merge.go b/cmd/kswitch/merge.go new file mode 100644 index 00000000..4a4c7de1 --- /dev/null +++ b/cmd/kswitch/merge.go @@ -0,0 +1,38 @@ +package kswitch + +import ( + "fmt" + + merge_to_default "github.com/MichaelSp/kswitch/pkg/subcommands/merge-to-default" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var mergeToDefaultCmd = &cobra.Command{ + Use: "merge-to-default-kubeconfig", + Short: "Merge current KUBECONFIG into ~/.kube/config", + Long: `Merge the currently selected KUBECONFIG file into ~/.kube/config. + +WARNING: Existing contexts, clusters, and users with the same name will be +overwritten. The current-context in ~/.kube/config will be set to the +context from the selected KUBECONFIG.`, + Args: cobra.NoArgs, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := merge_to_default.MergeToDefault() + if err != nil { + return err + } + green := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + fmt.Printf("✅ Merged context %s into %s\n", + green.Render(result.Context), + dim.Render(result.Destination), + ) + return nil + }, +} + +func init() { + rootCommand.AddCommand(mergeToDefaultCmd) +} diff --git a/cmd/kswitch/switcher.go b/cmd/kswitch/switcher.go index c54105fb..ca551dcb 100644 --- a/cmd/kswitch/switcher.go +++ b/cmd/kswitch/switcher.go @@ -78,7 +78,7 @@ var ( Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Commands that make sense without the shell wrapper (bootstrap/introspection) - exempt := map[string]bool{"init": true, "version": true, "__complete": true, "__completeNoDesc": true} + exempt := map[string]bool{"init": true, "version": true, "merge-to-default-kubeconfig": true, "__complete": true, "__completeNoDesc": true} if !exempt[cmd.Name()] && os.Getenv("KSWITCH_SHELL_WRAPPER") != "1" { fmt.Fprintln(os.Stderr, "kswitch must be invoked via the kswitch shell function, not directly.") fmt.Fprintln(os.Stderr, "") diff --git a/pkg/subcommands/merge-to-default/merge.go b/pkg/subcommands/merge-to-default/merge.go new file mode 100644 index 00000000..dcd63a4a --- /dev/null +++ b/pkg/subcommands/merge-to-default/merge.go @@ -0,0 +1,178 @@ +// Copyright 2025 The Kswitch 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 merge_to_default + +import ( + "fmt" + "os" + "path/filepath" + + kubeconfigutil "github.com/MichaelSp/kswitch/pkg/util/kubectx_copied" + "gopkg.in/yaml.v3" +) + +// MergeResult holds information about a successful merge for display purposes. +type MergeResult struct { + Context string + Destination string +} + +// MergeToDefault merges the currently selected KUBECONFIG into ~/.kube/config. +// Contexts, clusters, and users with the same name are overwritten. +// current-context is set to the context from the selected KUBECONFIG. +func MergeToDefault() (*MergeResult, error) { + src, err := kubeconfigutil.LoadCurrentKubeconfig() + if err != nil { + return nil, fmt.Errorf("failed to load current KUBECONFIG: %w", err) + } + + defaultPath := defaultKubeconfigPath() + if err := ensureDefaultExists(defaultPath); err != nil { + return nil, err + } + + srcBytes, err := src.GetBytes() + if err != nil { + return nil, err + } + dstBytes, err := os.ReadFile(defaultPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", defaultPath, err) + } + + merged, err := mergeKubeconfigs(dstBytes, srcBytes) + if err != nil { + return nil, fmt.Errorf("failed to merge kubeconfigs: %w", err) + } + + if err := os.WriteFile(defaultPath, merged, 0600); err != nil { + return nil, fmt.Errorf("failed to write %s: %w", defaultPath, err) + } + + var srcDoc yaml.Node + _ = yaml.Unmarshal(srcBytes, &srcDoc) + ctx := "" + if len(srcDoc.Content) > 0 { + ctx = scalarValue(srcDoc.Content[0], "current-context") + } + return &MergeResult{Context: ctx, Destination: defaultPath}, nil +} + +func defaultKubeconfigPath() string { + home := os.Getenv("HOME") + return filepath.Join(home, ".kube", "config") +} + +func ensureDefaultExists(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + empty := []byte("apiVersion: v1\nkind: Config\nclusters: []\ncontexts: []\nusers: []\n") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + return os.WriteFile(path, empty, 0600) + } + return nil +} + +// mergeKubeconfigs merges src into dst YAML bytes, returning the merged result. +// Entries in clusters/contexts/users with matching names are replaced by src's version. +// current-context is set to src's current-context. +func mergeKubeconfigs(dstBytes, srcBytes []byte) ([]byte, error) { + var dstDoc, srcDoc yaml.Node + if err := yaml.Unmarshal(dstBytes, &dstDoc); err != nil || len(dstDoc.Content) == 0 { + return nil, fmt.Errorf("invalid dst kubeconfig YAML") + } + if err := yaml.Unmarshal(srcBytes, &srcDoc); err != nil || len(srcDoc.Content) == 0 { + return nil, fmt.Errorf("invalid src kubeconfig YAML") + } + dstRoot := dstDoc.Content[0] + srcRoot := srcDoc.Content[0] + + for _, section := range []string{"clusters", "contexts", "users"} { + mergeSection(dstRoot, srcRoot, section) + } + + if cc := scalarValue(srcRoot, "current-context"); cc != "" { + setScalar(dstRoot, "current-context", cc) + } + + return yaml.Marshal(&dstDoc) +} + +// mergeSection upserts named entries from srcRoot into dstRoot for the given section. +func mergeSection(dstRoot, srcRoot *yaml.Node, section string) { + srcSeq := nodeValue(srcRoot, section) + if srcSeq == nil || srcSeq.Kind != yaml.SequenceNode { + return + } + + dstSeq := nodeValue(dstRoot, section) + if dstSeq == nil { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: section, Tag: "!!str"} + seqNode := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + dstRoot.Content = append(dstRoot.Content, keyNode, seqNode) + dstSeq = seqNode + } + + for _, srcEntry := range srcSeq.Content { + srcName := nodeValue(srcEntry, "name") + if srcName == nil { + continue + } + replaced := false + for i, dstEntry := range dstSeq.Content { + if dstName := nodeValue(dstEntry, "name"); dstName != nil && dstName.Value == srcName.Value { + dstSeq.Content[i] = srcEntry + replaced = true + break + } + } + if !replaced { + dstSeq.Content = append(dstSeq.Content, srcEntry) + } + } +} + +func setScalar(mapNode *yaml.Node, key, value string) { + for i, ch := range mapNode.Content { + if i%2 == 0 && ch.Kind == yaml.ScalarNode && ch.Value == key { + mapNode.Content[i+1].Value = value + return + } + } + mapNode.Content = append(mapNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: key, Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: value, Tag: "!!str"}, + ) +} + +func scalarValue(mapNode *yaml.Node, key string) string { + n := nodeValue(mapNode, key) + if n == nil { + return "" + } + return n.Value +} + +func nodeValue(mapNode *yaml.Node, key string) *yaml.Node { + if mapNode.Kind != yaml.MappingNode { + return nil + } + for i, ch := range mapNode.Content { + if i%2 == 0 && ch.Kind == yaml.ScalarNode && ch.Value == key { + return mapNode.Content[i+1] + } + } + return nil +} diff --git a/pkg/subcommands/merge-to-default/merge_test.go b/pkg/subcommands/merge-to-default/merge_test.go new file mode 100644 index 00000000..4e87dfd2 --- /dev/null +++ b/pkg/subcommands/merge-to-default/merge_test.go @@ -0,0 +1,131 @@ +// Copyright 2025 The Kswitch 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 merge_to_default + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const dstKubeconfig = `apiVersion: v1 +kind: Config +current-context: old-ctx +clusters: +- cluster: + server: https://old-server + name: old-cluster +contexts: +- context: + cluster: old-cluster + user: old-user + name: old-ctx +users: +- name: old-user + user: + token: old-token +` + +const srcKubeconfig = `apiVersion: v1 +kind: Config +current-context: new-ctx +clusters: +- cluster: + server: https://new-server + name: new-cluster +- cluster: + server: https://updated-server + name: old-cluster +contexts: +- context: + cluster: new-cluster + user: new-user + name: new-ctx +users: +- name: new-user + user: + token: new-token +` + +func TestMergeKubeconfigs_AddsNewEntries(t *testing.T) { + result, err := mergeKubeconfigs([]byte(dstKubeconfig), []byte(srcKubeconfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(result, &doc); err != nil || len(doc.Content) == 0 { + t.Fatal("result is not valid YAML") + } + root := doc.Content[0] + + // current-context should be updated to src's + if cc := scalarValue(root, "current-context"); cc != "new-ctx" { + t.Errorf("current-context = %q, want %q", cc, "new-ctx") + } + + // new-ctx context should exist + if !containsNamedEntry(root, "contexts", "new-ctx") { + t.Error("expected new-ctx in contexts") + } + // old-ctx should still exist + if !containsNamedEntry(root, "contexts", "old-ctx") { + t.Error("expected old-ctx preserved in contexts") + } + // new-cluster should exist + if !containsNamedEntry(root, "clusters", "new-cluster") { + t.Error("expected new-cluster in clusters") + } +} + +func TestMergeKubeconfigs_OverwritesExistingCluster(t *testing.T) { + result, err := mergeKubeconfigs([]byte(dstKubeconfig), []byte(srcKubeconfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // old-cluster should have updated server URL + if !strings.Contains(string(result), "https://updated-server") { + t.Error("expected old-cluster server to be updated to https://updated-server") + } + if strings.Contains(string(result), "https://old-server") { + t.Error("expected old-cluster old server URL to be replaced") + } +} + +func TestMergeKubeconfigs_EmptyDst(t *testing.T) { + empty := []byte("apiVersion: v1\nkind: Config\nclusters: []\ncontexts: []\nusers: []\n") + result, err := mergeKubeconfigs(empty, []byte(srcKubeconfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(string(result), "new-ctx") { + t.Error("expected new-ctx in merged result") + } +} + +func containsNamedEntry(root *yaml.Node, section, name string) bool { + seq := nodeValue(root, section) + if seq == nil { + return false + } + for _, entry := range seq.Content { + if n := nodeValue(entry, "name"); n != nil && n.Value == name { + return true + } + } + return false +} diff --git a/pkg/util/kubectx_copied/kubeconfig.go b/pkg/util/kubectx_copied/kubeconfig.go index dbeba2dc..046fab6b 100644 --- a/pkg/util/kubectx_copied/kubeconfig.go +++ b/pkg/util/kubectx_copied/kubeconfig.go @@ -194,6 +194,11 @@ func (k *Kubeconfig) GetBytes() ([]byte, error) { return yaml.Marshal(k.rootNode) } +// Path returns the filesystem path of this kubeconfig file. +func (k *Kubeconfig) Path() string { + return k.path +} + func kubeconfigPath() (string, error) { // KUBECONFIG env var if v := os.Getenv("KUBECONFIG"); v != "" {