Skip to content
Merged
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
38 changes: 38 additions & 0 deletions cmd/kswitch/merge.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion cmd/kswitch/switcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
178 changes: 178 additions & 0 deletions pkg/subcommands/merge-to-default/merge.go
Original file line number Diff line number Diff line change
@@ -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
}
131 changes: 131 additions & 0 deletions pkg/subcommands/merge-to-default/merge_test.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions pkg/util/kubectx_copied/kubeconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down