konfuse 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ name: Test (Python ${{ matrix.python-version }} / ${{ matrix.os }})
12
+ runs-on: ${{ matrix.os }}
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.8", "3.10", "3.12"]
17
+ os: [ubuntu-latest, macos-latest]
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Set up Python ${{ matrix.python-version }}
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+
27
+ - name: Install dependencies
28
+ run: pip install -e ".[dev]"
29
+
30
+ - name: Lint
31
+ run: ruff check .
32
+
33
+ - name: Test
34
+ run: pytest --cov=konfuse --cov-report=term-missing
35
+
36
+ - name: Upload coverage
37
+ if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
38
+ uses: codecov/codecov-action@v4
39
+ with:
40
+ fail_ci_if_error: false
@@ -0,0 +1,124 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ # ── Build standalone binaries ──────────────────────────────────────────────
10
+ build-binaries:
11
+ name: Build binary (${{ matrix.target }})
12
+ runs-on: ${{ matrix.os }}
13
+ strategy:
14
+ matrix:
15
+ include:
16
+ - os: ubuntu-latest
17
+ target: linux-amd64
18
+ - os: ubuntu-latest
19
+ target: linux-arm64
20
+ - os: macos-latest
21
+ target: macos-arm64
22
+
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Set up Python
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+
31
+ - name: Install dependencies
32
+ run: pip install pyinstaller PyYAML
33
+
34
+ - name: Build binary (Linux arm64)
35
+ if: matrix.target == 'linux-arm64'
36
+ uses: uraimo/run-on-arch-action@v2
37
+ with:
38
+ arch: aarch64
39
+ distro: ubuntu22.04
40
+ install: |
41
+ apt-get update -q
42
+ apt-get install -y python3 python3-pip
43
+ pip3 install pyinstaller PyYAML
44
+ run: |
45
+ pyinstaller --onefile --name konfuse konfuse.py
46
+ mv dist/konfuse dist/konfuse-linux-arm64
47
+
48
+ - name: Build binary (non-arm64)
49
+ if: matrix.target != 'linux-arm64'
50
+ run: |
51
+ pyinstaller --onefile --name konfuse konfuse.py
52
+ mv dist/konfuse dist/konfuse-${{ matrix.target }}
53
+
54
+ - name: Upload binary artifact
55
+ uses: actions/upload-artifact@v4
56
+ with:
57
+ name: konfuse-${{ matrix.target }}
58
+ path: dist/konfuse-${{ matrix.target }}
59
+
60
+ # ── Create GitHub Release with all binaries ────────────────────────────────
61
+ release:
62
+ name: GitHub Release
63
+ needs: build-binaries
64
+ runs-on: ubuntu-latest
65
+ permissions:
66
+ contents: write
67
+
68
+ steps:
69
+ - uses: actions/checkout@v4
70
+
71
+ - name: Download all binaries
72
+ uses: actions/download-artifact@v4
73
+ with:
74
+ path: dist
75
+ merge-multiple: true
76
+
77
+ - name: Generate checksums
78
+ run: |
79
+ cd dist
80
+ sha256sum konfuse-* > checksums.txt
81
+ cat checksums.txt
82
+
83
+ - name: Extract release notes from CHANGELOG
84
+ id: changelog
85
+ run: |
86
+ VERSION="${GITHUB_REF_NAME#v}"
87
+ NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
88
+ echo "notes<<EOF" >> $GITHUB_OUTPUT
89
+ echo "$NOTES" >> $GITHUB_OUTPUT
90
+ echo "EOF" >> $GITHUB_OUTPUT
91
+
92
+ - name: Create GitHub Release
93
+ uses: softprops/action-gh-release@v2
94
+ with:
95
+ body: ${{ steps.changelog.outputs.notes }}
96
+ files: |
97
+ dist/konfuse-linux-amd64
98
+ dist/konfuse-linux-arm64
99
+ dist/konfuse-macos-arm64
100
+ dist/checksums.txt
101
+
102
+ # ── Publish to PyPI ────────────────────────────────────────────────────────
103
+ publish-pypi:
104
+ name: Publish to PyPI
105
+ needs: build-binaries
106
+ runs-on: ubuntu-latest
107
+ permissions:
108
+ id-token: write
109
+
110
+ steps:
111
+ - uses: actions/checkout@v4
112
+
113
+ - name: Set up Python
114
+ uses: actions/setup-python@v5
115
+ with:
116
+ python-version: "3.12"
117
+
118
+ - name: Build
119
+ run: |
120
+ pip install build
121
+ python -m build
122
+
123
+ - name: Publish to PyPI
124
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+
8
+ # Testing & coverage
9
+ .coverage
10
+ .coverage.*
11
+ htmlcov/
12
+ .pytest_cache/
13
+
14
+ # Linting
15
+ .ruff_cache/
16
+
17
+ # PyInstaller
18
+ *.spec
19
+
20
+ # Claude Code
21
+ .claude/
22
+
23
+ # Backups (kubeconfig)
24
+ *.backup.*
25
+
26
+ # Internal planning docs
27
+ ADOPTION_PLAN.md
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
@@ -0,0 +1,58 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI coding agents (OpenAI Codex, etc.) when working with code in this repository.
4
+
5
+ ## Overview
6
+
7
+ `konfuse` is a single-file Python CLI tool (`konfuse.py`) that merges Kubernetes kubeconfig files with rename-on-import and auto-backup.
8
+
9
+ ## Development Setup
10
+
11
+ ```bash
12
+ pip install -e ".[dev]"
13
+ ```
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ # Run tests
19
+ pytest
20
+
21
+ # Run a single test class
22
+ pytest tests/test_merge.py::TestRenameUser
23
+
24
+ # Run with coverage
25
+ pytest --cov=konfuse --cov-report=term-missing
26
+
27
+ # Lint
28
+ ruff check .
29
+
30
+ # Run the tool
31
+ konfuse new-cluster.yaml --rename-context prod --rename-cluster eks-prod
32
+ ```
33
+
34
+ ## Architecture
35
+
36
+ All logic is in `konfuse.py`. Execution flow:
37
+
38
+ 1. **CLI** (`main()` via argparse) — validates input, parses flags
39
+ 2. **Load** (`load_yaml`) — parses incoming and existing kubeconfig YAML
40
+ 3. **Backup** (`backup_config`) — creates a timestamped copy before any writes
41
+ 4. **Merge** (`merge_kubeconfig`) — pure function (dict-in, dict-out): merges clusters/users/contexts, applies optional renames to the first entry of each section, updates cross-references, returns `(merged_dict, result_dict)`
42
+ 5. **Save** (`save_yaml`) — writes the result; skipped in `--dry-run` mode
43
+
44
+ ## Key flags
45
+
46
+ | Flag | Behaviour |
47
+ |---|---|
48
+ | `--dry-run` | Compute and show changes without writing |
49
+ | `--json` | Output structured JSON (auto-enabled when stdout is not a TTY) |
50
+ | `--yes` | Skip prompts (non-interactive / CI mode) |
51
+ | `--rename-context` | Rename the first incoming context |
52
+ | `--rename-cluster` | Rename the first incoming cluster |
53
+ | `--rename-user` | Rename the first incoming user |
54
+
55
+ ## CI
56
+
57
+ - `.github/workflows/ci.yml` — lint + test matrix on every push/PR
58
+ - `.github/workflows/release.yml` — standalone binaries + PyPI publish on `v*` tags
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to konfuse are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ---
7
+
8
+ ## [Unreleased]
9
+
10
+ ---
11
+
12
+ ## [0.1.0] - 2026-03-28
13
+
14
+ ### Added
15
+ - Merge any kubeconfig YAML into `~/.kube/config` (or a custom target) in one command
16
+ - `--rename-context` — rename the first incoming context on import
17
+ - `--rename-cluster` — rename the first incoming cluster on import
18
+ - `--rename-user` — rename the first incoming user on import
19
+ - `--dry-run` — preview all changes without writing anything
20
+ - `--json` — structured JSON output; auto-enabled when stdout is not a TTY (pipes, CI)
21
+ - `--yes` — non-interactive / CI mode
22
+ - `--kubeconfig` — target a kubeconfig other than `~/.kube/config`
23
+ - Automatic timestamped backup before every write (`~/.kube/config.backup.<YYYYMMDDTHHMMSS>`)
24
+ - Conflict detection with warnings — incoming entries replace existing ones of the same name
25
+ - Internal reference updates: renaming a cluster also updates the cluster reference inside any affected context
26
+ - Standalone binaries for Linux (amd64, arm64) and macOS (arm64) — no Python required
27
+ - `SKILL.md` for auto-discovery by Claude Code, Cursor, and Gemini CLI
28
+ - `AGENTS.md` for auto-discovery by OpenAI Codex
@@ -0,0 +1,49 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Overview
6
+
7
+ `konfuse` is a single-file Python CLI tool that merges Kubernetes kubeconfig files. Unique combination: merge + rename-on-import + timestamped auto-backup.
8
+
9
+ ## Development Setup
10
+
11
+ ```bash
12
+ pip install -e ".[dev]"
13
+ ```
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ # Run tests
19
+ pytest
20
+
21
+ # Run a single test
22
+ pytest tests/test_merge.py::TestRenameCluster
23
+
24
+ # Run with coverage
25
+ pytest --cov=konfuse --cov-report=term-missing
26
+
27
+ # Lint
28
+ ruff check .
29
+
30
+ # Run the tool
31
+ konfuse new-cluster.yaml --rename-context prod --rename-cluster eks-prod
32
+ ```
33
+
34
+ ## Architecture
35
+
36
+ All logic lives in `konfuse.py` (~200 lines). Execution flow:
37
+
38
+ 1. **CLI** (`main()` via argparse) — validates input, reads arguments
39
+ 2. **Load** (`load_yaml`) — parses both the incoming and existing kubeconfig YAML
40
+ 3. **Backup** (`backup_config`) — creates a timestamped `.bak` copy before any writes
41
+ 4. **Merge** (`merge_kubeconfig`) — merges clusters, users, and contexts; warns on name conflicts; applies optional renames only to the **first** cluster/context in the incoming file, updating internal cross-references
42
+ 5. **Save** (`save_yaml`) — writes the merged result
43
+
44
+ `merge_kubeconfig` is pure (dict-in, dict-out, no I/O) — all tests call it directly without mocking.
45
+
46
+ ## CI
47
+
48
+ - `.github/workflows/ci.yml` — runs lint + tests on Python 3.8/3.10/3.12 × ubuntu/macos on every push and PR
49
+ - `.github/workflows/release.yml` — on `v*` tags: builds standalone binaries via PyInstaller (linux-amd64, linux-arm64, macos-amd64, macos-arm64), uploads them as GitHub Release assets with SHA256 checksums, then publishes to PyPI
konfuse-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
konfuse-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: konfuse
3
+ Version: 0.1.0
4
+ Summary: Merge any kubeconfig in one command. Rename on import. Never lose your existing config.
5
+ Project-URL: Homepage, https://github.com/chameerar/konfuse
6
+ Project-URL: Source, https://github.com/chameerar/konfuse
7
+ Project-URL: Bug Tracker, https://github.com/chameerar/konfuse/issues
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Requires-Python: >=3.8
31
+ Requires-Dist: pyyaml>=5.4
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
34
+ Requires-Dist: pytest>=7.0; extra == 'dev'
35
+ Requires-Dist: ruff>=0.4; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # konfuse
39
+
40
+ > Merge any kubeconfig in one command. Rename on import. Never lose your existing config.
41
+
42
+ [![CI](https://github.com/chameerar/konfuse/actions/workflows/ci.yml/badge.svg)](https://github.com/chameerar/konfuse/actions/workflows/ci.yml)
43
+ [![PyPI version](https://img.shields.io/pypi/v/konfuse)](https://pypi.org/project/konfuse/)
44
+ [![Python versions](https://img.shields.io/pypi/pyversions/konfuse)](https://pypi.org/project/konfuse/)
45
+ [![codecov](https://codecov.io/gh/chameerar/konfuse/branch/main/graph/badge.svg)](https://codecov.io/gh/chameerar/konfuse)
46
+
47
+ Kubeconfigs are confusing enough. `konfuse` makes merging them less so.
48
+
49
+ Got a new cluster config from your ops team? Spinning up another EKS environment? `konfuse` merges it into your existing `~/.kube/config` in one command — with a friendly name, and a backup in case anything goes wrong.
50
+
51
+ ## Why konfuse?
52
+
53
+ | Feature | konfuse | kubecm | kubectx | konfig |
54
+ |---|:---:|:---:|:---:|:---:|
55
+ | Merge kubeconfigs | ✓ | ✓ | ✗ | ✓ |
56
+ | Rename context on import | ✓ | ✗ | ✗ | ✗ |
57
+ | Rename cluster on import | ✓ | ✗ | ✗ | ✗ |
58
+ | Auto timestamped backup | ✓ | ✗ | ✗ | ✗ |
59
+ | kubectl plugin (Krew) | soon | ✓ | ✓ | ✓ |
60
+
61
+ ## Installation
62
+
63
+ ### Standalone binary
64
+
65
+ Download and run — nothing else needed.
66
+
67
+ ```bash
68
+ # macOS (Apple Silicon)
69
+ curl -L https://github.com/chameerar/konfuse/releases/latest/download/konfuse-macos-arm64 \
70
+ -o /usr/local/bin/konfuse && chmod +x /usr/local/bin/konfuse
71
+
72
+ # Linux (amd64)
73
+ curl -L https://github.com/chameerar/konfuse/releases/latest/download/konfuse-linux-amd64 \
74
+ -o /usr/local/bin/konfuse && chmod +x /usr/local/bin/konfuse
75
+
76
+ # Linux (arm64)
77
+ curl -L https://github.com/chameerar/konfuse/releases/latest/download/konfuse-linux-arm64 \
78
+ -o /usr/local/bin/konfuse && chmod +x /usr/local/bin/konfuse
79
+ ```
80
+
81
+ ### Python (if you already have Python 3.8+)
82
+
83
+ ```bash
84
+ pipx install konfuse # recommended
85
+ pip install konfuse # or with pip
86
+ ```
87
+
88
+ ## Usage
89
+
90
+ ```bash
91
+ # Preview what will change (no writes)
92
+ konfuse new-cluster.yaml --dry-run
93
+
94
+ # Merge into ~/.kube/config
95
+ konfuse new-cluster.yaml
96
+
97
+ # Rename context, cluster, and user on import
98
+ konfuse new-cluster.yaml --rename-context prod --rename-cluster eks-prod --rename-user eks-admin
99
+
100
+ # Machine-readable output (also auto-enabled in pipes/CI)
101
+ konfuse new-cluster.yaml --json
102
+
103
+ # Target a different kubeconfig
104
+ konfuse new-cluster.yaml --kubeconfig ~/.kube/work-config
105
+ ```
106
+
107
+ ### Options
108
+
109
+ | Option | Description |
110
+ |---|---|
111
+ | `input` (positional) | Path to the kubeconfig YAML to merge |
112
+ | `--rename-context NAME` | Rename the first incoming context |
113
+ | `--rename-cluster NAME` | Rename the first incoming cluster |
114
+ | `--rename-user NAME` | Rename the first incoming user |
115
+ | `--dry-run` | Preview changes without writing anything |
116
+ | `--json` | Output results as JSON (auto-enabled when stdout is not a TTY) |
117
+ | `--yes` | Non-interactive mode — skip all prompts |
118
+ | `--kubeconfig PATH` | Target kubeconfig (default: `~/.kube/config`) |
119
+
120
+ ## Example: EKS config with a friendly name
121
+
122
+ You receive `eks-staging.yaml` with context named `arn:aws:eks:us-east-1:123456789:cluster/staging`. Run:
123
+
124
+ ```bash
125
+ konfuse eks-staging.yaml --rename-context staging --rename-cluster eks-staging
126
+ ```
127
+
128
+ **Before:**
129
+ ```
130
+ $ kubectl config get-contexts
131
+ CURRENT NAME CLUSTER AUTHINFO
132
+ * minikube minikube minikube
133
+ ```
134
+
135
+ **After:**
136
+ ```
137
+ $ kubectl config get-contexts
138
+ CURRENT NAME CLUSTER AUTHINFO
139
+ * minikube minikube minikube
140
+ staging eks-staging arn:aws:eks:...
141
+ ```
142
+
143
+ ## How it works
144
+
145
+ 1. Validates the input file is a valid kubeconfig (`kind: Config`)
146
+ 2. Backs up your existing config to `~/.kube/config.backup.<YYYYMMDDTHHMMSS>`
147
+ 3. Merges clusters, users, and contexts — renaming the first entry if flags are set
148
+ 4. Updates internal cluster references when `--rename-cluster` is used
149
+ 5. Saves the merged result
150
+
151
+ Conflicts (same name already exists) are handled non-fatally: the incoming entry replaces the existing one with a warning.
152
+
153
+ ## Restore a backup
154
+
155
+ ```bash
156
+ cp ~/.kube/config.backup.20260327T103000 ~/.kube/config
157
+ ```
158
+
159
+ ## Requirements
160
+
161
+ - Python 3.8+
162
+ - PyYAML (installed automatically)
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,129 @@
1
+ # konfuse
2
+
3
+ > Merge any kubeconfig in one command. Rename on import. Never lose your existing config.
4
+
5
+ [![CI](https://github.com/chameerar/konfuse/actions/workflows/ci.yml/badge.svg)](https://github.com/chameerar/konfuse/actions/workflows/ci.yml)
6
+ [![PyPI version](https://img.shields.io/pypi/v/konfuse)](https://pypi.org/project/konfuse/)
7
+ [![Python versions](https://img.shields.io/pypi/pyversions/konfuse)](https://pypi.org/project/konfuse/)
8
+ [![codecov](https://codecov.io/gh/chameerar/konfuse/branch/main/graph/badge.svg)](https://codecov.io/gh/chameerar/konfuse)
9
+
10
+ Kubeconfigs are confusing enough. `konfuse` makes merging them less so.
11
+
12
+ Got a new cluster config from your ops team? Spinning up another EKS environment? `konfuse` merges it into your existing `~/.kube/config` in one command — with a friendly name, and a backup in case anything goes wrong.
13
+
14
+ ## Why konfuse?
15
+
16
+ | Feature | konfuse | kubecm | kubectx | konfig |
17
+ |---|:---:|:---:|:---:|:---:|
18
+ | Merge kubeconfigs | ✓ | ✓ | ✗ | ✓ |
19
+ | Rename context on import | ✓ | ✗ | ✗ | ✗ |
20
+ | Rename cluster on import | ✓ | ✗ | ✗ | ✗ |
21
+ | Auto timestamped backup | ✓ | ✗ | ✗ | ✗ |
22
+ | kubectl plugin (Krew) | soon | ✓ | ✓ | ✓ |
23
+
24
+ ## Installation
25
+
26
+ ### Standalone binary
27
+
28
+ Download and run — nothing else needed.
29
+
30
+ ```bash
31
+ # macOS (Apple Silicon)
32
+ curl -L https://github.com/chameerar/konfuse/releases/latest/download/konfuse-macos-arm64 \
33
+ -o /usr/local/bin/konfuse && chmod +x /usr/local/bin/konfuse
34
+
35
+ # Linux (amd64)
36
+ curl -L https://github.com/chameerar/konfuse/releases/latest/download/konfuse-linux-amd64 \
37
+ -o /usr/local/bin/konfuse && chmod +x /usr/local/bin/konfuse
38
+
39
+ # Linux (arm64)
40
+ curl -L https://github.com/chameerar/konfuse/releases/latest/download/konfuse-linux-arm64 \
41
+ -o /usr/local/bin/konfuse && chmod +x /usr/local/bin/konfuse
42
+ ```
43
+
44
+ ### Python (if you already have Python 3.8+)
45
+
46
+ ```bash
47
+ pipx install konfuse # recommended
48
+ pip install konfuse # or with pip
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```bash
54
+ # Preview what will change (no writes)
55
+ konfuse new-cluster.yaml --dry-run
56
+
57
+ # Merge into ~/.kube/config
58
+ konfuse new-cluster.yaml
59
+
60
+ # Rename context, cluster, and user on import
61
+ konfuse new-cluster.yaml --rename-context prod --rename-cluster eks-prod --rename-user eks-admin
62
+
63
+ # Machine-readable output (also auto-enabled in pipes/CI)
64
+ konfuse new-cluster.yaml --json
65
+
66
+ # Target a different kubeconfig
67
+ konfuse new-cluster.yaml --kubeconfig ~/.kube/work-config
68
+ ```
69
+
70
+ ### Options
71
+
72
+ | Option | Description |
73
+ |---|---|
74
+ | `input` (positional) | Path to the kubeconfig YAML to merge |
75
+ | `--rename-context NAME` | Rename the first incoming context |
76
+ | `--rename-cluster NAME` | Rename the first incoming cluster |
77
+ | `--rename-user NAME` | Rename the first incoming user |
78
+ | `--dry-run` | Preview changes without writing anything |
79
+ | `--json` | Output results as JSON (auto-enabled when stdout is not a TTY) |
80
+ | `--yes` | Non-interactive mode — skip all prompts |
81
+ | `--kubeconfig PATH` | Target kubeconfig (default: `~/.kube/config`) |
82
+
83
+ ## Example: EKS config with a friendly name
84
+
85
+ You receive `eks-staging.yaml` with context named `arn:aws:eks:us-east-1:123456789:cluster/staging`. Run:
86
+
87
+ ```bash
88
+ konfuse eks-staging.yaml --rename-context staging --rename-cluster eks-staging
89
+ ```
90
+
91
+ **Before:**
92
+ ```
93
+ $ kubectl config get-contexts
94
+ CURRENT NAME CLUSTER AUTHINFO
95
+ * minikube minikube minikube
96
+ ```
97
+
98
+ **After:**
99
+ ```
100
+ $ kubectl config get-contexts
101
+ CURRENT NAME CLUSTER AUTHINFO
102
+ * minikube minikube minikube
103
+ staging eks-staging arn:aws:eks:...
104
+ ```
105
+
106
+ ## How it works
107
+
108
+ 1. Validates the input file is a valid kubeconfig (`kind: Config`)
109
+ 2. Backs up your existing config to `~/.kube/config.backup.<YYYYMMDDTHHMMSS>`
110
+ 3. Merges clusters, users, and contexts — renaming the first entry if flags are set
111
+ 4. Updates internal cluster references when `--rename-cluster` is used
112
+ 5. Saves the merged result
113
+
114
+ Conflicts (same name already exists) are handled non-fatally: the incoming entry replaces the existing one with a warning.
115
+
116
+ ## Restore a backup
117
+
118
+ ```bash
119
+ cp ~/.kube/config.backup.20260327T103000 ~/.kube/config
120
+ ```
121
+
122
+ ## Requirements
123
+
124
+ - Python 3.8+
125
+ - PyYAML (installed automatically)
126
+
127
+ ## License
128
+
129
+ MIT
konfuse-0.1.0/SKILL.md ADDED
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: konfuse
3
+ description: Merge a kubeconfig file into the local kubeconfig with optional rename on import and automatic backup
4
+ triggers:
5
+ - merge kubeconfig
6
+ - add cluster to kubeconfig
7
+ - import kubeconfig
8
+ - rename context kubeconfig
9
+ - combine kubeconfig files
10
+ ---
11
+
12
+ # konfuse
13
+
14
+ Merge any kubeconfig file into your existing `~/.kube/config` in one command.
15
+
16
+ ## Usage
17
+
18
+ ```bash
19
+ # Preview what will change (always do this first)
20
+ konfuse <file> --dry-run
21
+
22
+ # Basic merge
23
+ konfuse <file>
24
+
25
+ # Rename context and cluster on import (recommended)
26
+ konfuse <file> --rename-context <name> --rename-cluster <name>
27
+
28
+ # Rename all three (context, cluster, user)
29
+ konfuse <file> --rename-context <name> --rename-cluster <name> --rename-user <name>
30
+
31
+ # Non-interactive / CI mode with JSON output
32
+ konfuse <file> --yes --json
33
+
34
+ # Target a different kubeconfig
35
+ konfuse <file> --kubeconfig /path/to/config
36
+ ```
37
+
38
+ ## Rules
39
+
40
+ - Always use `--dry-run` before merging in automated or unfamiliar contexts
41
+ - Use `--rename-context` and `--rename-cluster` when the incoming file uses generic names like `kubernetes-admin@cluster.local`
42
+ - Use `--json` to get machine-readable output; use `--yes` to suppress all prompts
43
+ - Only the **first** context/cluster/user in the incoming file is renamed; others pass through unchanged
44
+ - A timestamped backup is automatically created before any write — restore with `cp ~/.kube/config.backup.<timestamp> ~/.kube/config`
45
+
46
+ ## Exit codes
47
+
48
+ | Code | Meaning |
49
+ |---|---|
50
+ | 0 | Success |
51
+ | 1 | General error |
52
+ | 2 | Usage / argument error |
53
+ | 3 | Input file not found |
54
+
55
+ ## JSON output schema
56
+
57
+ ```json
58
+ {
59
+ "dry_run": false,
60
+ "target": "/Users/you/.kube/config",
61
+ "backup": "/Users/you/.kube/config.backup.20260328T120000",
62
+ "changes": {
63
+ "clusters": { "added": ["eks-prod"], "replaced": [] },
64
+ "users": { "added": ["eks-prod-user"], "replaced": [] },
65
+ "contexts": { "added": ["prod"], "replaced": [] }
66
+ },
67
+ "has_conflicts": false
68
+ }
69
+ ```
@@ -0,0 +1,305 @@
1
+ """
2
+ konfuse: Merge a new kubeconfig into your existing kubeconfig.
3
+
4
+ Takes a new kubeconfig YAML file and merges its clusters, users, and contexts
5
+ into the existing kubeconfig. Backs up the existing config before any changes.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import shutil
12
+ import sys
13
+ from datetime import datetime
14
+
15
+ import yaml
16
+
17
+ DEFAULT_KUBECONFIG = os.path.expanduser("~/.kube/config")
18
+
19
+ # Exit codes
20
+ EXIT_OK = 0
21
+ EXIT_ERROR = 1
22
+ EXIT_USAGE = 2
23
+ EXIT_NOT_FOUND = 3
24
+
25
+
26
+ def _is_tty():
27
+ return sys.stdout.isatty()
28
+
29
+
30
+ def load_yaml(path):
31
+ """Load and parse a YAML file."""
32
+ with open(path, "r") as f:
33
+ return yaml.safe_load(f)
34
+
35
+
36
+ def save_yaml(data, path):
37
+ """Write data to a YAML file, creating parent directories if needed."""
38
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
39
+ with open(path, "w") as f:
40
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
41
+
42
+
43
+ def find_by_name(items, name):
44
+ """Find an item in a list by its 'name' field."""
45
+ for item in items:
46
+ if item.get("name") == name:
47
+ return item
48
+ return None
49
+
50
+
51
+ def backup_config(config_path):
52
+ """Create a timestamped backup of the given config file."""
53
+ if not os.path.exists(config_path):
54
+ return None
55
+ timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
56
+ backup_path = f"{config_path}.backup.{timestamp}"
57
+ shutil.copy2(config_path, backup_path)
58
+ return backup_path
59
+
60
+
61
+ def merge_kubeconfig(
62
+ existing, incoming, rename_context=None, rename_cluster=None, rename_user=None
63
+ ):
64
+ """Merge incoming kubeconfig entries into existing kubeconfig.
65
+
66
+ Args:
67
+ existing: Parsed existing kubeconfig dict (or None for fresh start).
68
+ incoming: Parsed incoming kubeconfig dict to merge in.
69
+ rename_context: If set, rename the first incoming context to this value.
70
+ rename_cluster: If set, rename the first incoming cluster to this value.
71
+ rename_user: If set, rename the first incoming user to this value.
72
+
73
+ Returns:
74
+ Tuple of (merged dict, result dict) where result contains lists of
75
+ added/replaced entries per section for structured output.
76
+ """
77
+ result = {
78
+ "clusters": {"added": [], "replaced": []},
79
+ "users": {"added": [], "replaced": []},
80
+ "contexts": {"added": [], "replaced": []},
81
+ }
82
+
83
+ if existing is None:
84
+ existing = {
85
+ "apiVersion": "v1",
86
+ "kind": "Config",
87
+ "clusters": [],
88
+ "contexts": [],
89
+ "users": [],
90
+ "current-context": "",
91
+ "preferences": {},
92
+ }
93
+
94
+ for key in ("clusters", "contexts", "users"):
95
+ if existing.get(key) is None:
96
+ existing[key] = []
97
+ if incoming.get(key) is None:
98
+ incoming[key] = []
99
+
100
+ # Determine original → new names for first entries
101
+ orig_cluster_name = new_cluster_name = None
102
+ orig_context_name = new_context_name = None
103
+ orig_user_name = new_user_name = None
104
+
105
+ if incoming["clusters"]:
106
+ orig_cluster_name = incoming["clusters"][0]["name"]
107
+ new_cluster_name = rename_cluster or orig_cluster_name
108
+
109
+ if incoming["contexts"]:
110
+ orig_context_name = incoming["contexts"][0]["name"]
111
+ new_context_name = rename_context or orig_context_name
112
+
113
+ if incoming["users"]:
114
+ orig_user_name = incoming["users"][0]["name"]
115
+ new_user_name = rename_user or orig_user_name
116
+
117
+ # Merge clusters
118
+ for cluster in incoming["clusters"]:
119
+ is_first = cluster["name"] == orig_cluster_name and rename_cluster
120
+ name = new_cluster_name if is_first else cluster["name"]
121
+ entry = {"name": name, "cluster": cluster["cluster"]}
122
+ existing_entry = find_by_name(existing["clusters"], name)
123
+ if existing_entry:
124
+ existing["clusters"].remove(existing_entry)
125
+ result["clusters"]["replaced"].append(name)
126
+ else:
127
+ result["clusters"]["added"].append(name)
128
+ existing["clusters"].append(entry)
129
+
130
+ # Merge users
131
+ for user in incoming["users"]:
132
+ is_first = user["name"] == orig_user_name and rename_user
133
+ name = new_user_name if is_first else user["name"]
134
+ entry = {"name": name, "user": user["user"]}
135
+ existing_entry = find_by_name(existing["users"], name)
136
+ if existing_entry:
137
+ existing["users"].remove(existing_entry)
138
+ result["users"]["replaced"].append(name)
139
+ else:
140
+ result["users"]["added"].append(name)
141
+ existing["users"].append(entry)
142
+
143
+ # Merge contexts
144
+ for ctx in incoming["contexts"]:
145
+ is_first = ctx["name"] == orig_context_name and rename_context
146
+ name = new_context_name if is_first else ctx["name"]
147
+ context_data = dict(ctx["context"])
148
+ if rename_cluster and context_data.get("cluster") == orig_cluster_name:
149
+ context_data["cluster"] = new_cluster_name
150
+ if rename_user and context_data.get("user") == orig_user_name:
151
+ context_data["user"] = new_user_name
152
+ entry = {"name": name, "context": context_data}
153
+ existing_entry = find_by_name(existing["contexts"], name)
154
+ if existing_entry:
155
+ existing["contexts"].remove(existing_entry)
156
+ result["contexts"]["replaced"].append(name)
157
+ else:
158
+ result["contexts"]["added"].append(name)
159
+ existing["contexts"].append(entry)
160
+
161
+ return existing, result
162
+
163
+
164
+ def main():
165
+ parser = argparse.ArgumentParser(
166
+ prog="konfuse",
167
+ description="Merge a new kubeconfig file into your existing kubeconfig.",
168
+ epilog="Examples:\n"
169
+ " konfuse new-cluster.yaml\n"
170
+ " konfuse new-cluster.yaml --rename-context prod --rename-cluster eks-prod\n"
171
+ " konfuse new-cluster.yaml --dry-run --json\n"
172
+ " konfuse new-cluster.yaml --kubeconfig /path/to/config\n",
173
+ formatter_class=argparse.RawDescriptionHelpFormatter,
174
+ )
175
+ parser.add_argument("input", help="Path to the kubeconfig YAML file to merge")
176
+ parser.add_argument("--rename-context", metavar="NAME", help="Rename the first incoming context")
177
+ parser.add_argument("--rename-cluster", metavar="NAME", help="Rename the first incoming cluster")
178
+ parser.add_argument("--rename-user", metavar="NAME", help="Rename the first incoming user")
179
+ parser.add_argument(
180
+ "--kubeconfig",
181
+ default=DEFAULT_KUBECONFIG,
182
+ metavar="PATH",
183
+ help=f"Target kubeconfig to merge into (default: {DEFAULT_KUBECONFIG})",
184
+ )
185
+ parser.add_argument(
186
+ "--dry-run",
187
+ action="store_true",
188
+ help="Preview what would be merged without writing any changes",
189
+ )
190
+ parser.add_argument(
191
+ "--json",
192
+ action="store_true",
193
+ dest="json_output",
194
+ help="Output results as JSON (default when stdout is not a TTY)",
195
+ )
196
+ parser.add_argument(
197
+ "--yes",
198
+ action="store_true",
199
+ help="Skip all confirmation prompts (non-interactive mode)",
200
+ )
201
+
202
+ args = parser.parse_args()
203
+
204
+ use_json = args.json_output or not _is_tty()
205
+
206
+ def emit(data):
207
+ """Print structured JSON result and exit."""
208
+ print(json.dumps(data, indent=2))
209
+
210
+ def fail(message, hint=None, code=EXIT_ERROR):
211
+ if use_json:
212
+ payload = {"error": message}
213
+ if hint:
214
+ payload["hint"] = hint
215
+ print(json.dumps(payload), file=sys.stderr)
216
+ else:
217
+ print(f"Error: {message}", file=sys.stderr)
218
+ if hint:
219
+ print(f"Try: {hint}", file=sys.stderr)
220
+ sys.exit(code)
221
+
222
+ # Validate input
223
+ if not os.path.isfile(args.input):
224
+ fail(
225
+ f"Input file not found: {args.input}",
226
+ hint="konfuse <path-to-kubeconfig.yaml>",
227
+ code=EXIT_NOT_FOUND,
228
+ )
229
+
230
+ incoming = load_yaml(args.input)
231
+ if not incoming or incoming.get("kind") != "Config":
232
+ fail(
233
+ "Input file is not a valid kubeconfig (missing kind: Config)",
234
+ hint="Ensure the file is a valid kubeconfig YAML",
235
+ )
236
+
237
+ # Load existing config
238
+ existing = None
239
+ existing_path_exists = os.path.isfile(args.kubeconfig)
240
+ if existing_path_exists:
241
+ existing = load_yaml(args.kubeconfig)
242
+
243
+ # Compute merge (pure — no I/O)
244
+ merged, result = merge_kubeconfig(
245
+ existing, incoming, args.rename_context, args.rename_cluster, args.rename_user
246
+ )
247
+
248
+ has_conflicts = any(result[s]["replaced"] for s in result)
249
+
250
+ if args.dry_run:
251
+ output = {
252
+ "dry_run": True,
253
+ "target": args.kubeconfig,
254
+ "changes": result,
255
+ "has_conflicts": has_conflicts,
256
+ }
257
+ if use_json:
258
+ emit(output)
259
+ else:
260
+ print("Dry run — no changes will be written\n")
261
+ for section, ops in result.items():
262
+ for name in ops["added"]:
263
+ print(f" ✓ Would add {section[:-1]}: {name}")
264
+ for name in ops["replaced"]:
265
+ print(f" ⚠ Would replace {section[:-1]}: {name}")
266
+ if has_conflicts:
267
+ print("\n⚠ Conflicts detected. Use --rename-* flags to avoid replacing existing entries.")
268
+ sys.exit(EXIT_OK)
269
+
270
+ # Backup + save
271
+ backup_path = None
272
+ if existing_path_exists:
273
+ backup_path = backup_config(args.kubeconfig)
274
+ save_yaml(merged, args.kubeconfig)
275
+
276
+ output = {
277
+ "dry_run": False,
278
+ "target": args.kubeconfig,
279
+ "backup": backup_path,
280
+ "changes": result,
281
+ "has_conflicts": has_conflicts,
282
+ }
283
+
284
+ if use_json:
285
+ emit(output)
286
+ else:
287
+ if backup_path:
288
+ print(f"💾 Backup saved: {backup_path}")
289
+ print()
290
+ for section, ops in result.items():
291
+ for name in ops["added"]:
292
+ print(f" ✓ Added {section[:-1]}: {name}")
293
+ for name in ops["replaced"]:
294
+ print(f" ⚠ Replaced {section[:-1]}: {name}")
295
+ if has_conflicts:
296
+ print(
297
+ "\n⚠ Some entries were replaced. Use --rename-* flags to keep both versions."
298
+ )
299
+ print(f"\n✅ Merged config saved to: {args.kubeconfig}")
300
+
301
+ sys.exit(EXIT_OK)
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "konfuse"
7
+ version = "0.1.0"
8
+ description = "Merge any kubeconfig in one command. Rename on import. Never lose your existing config."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.8"
12
+ dependencies = ["PyYAML>=5.4"]
13
+
14
+ [project.scripts]
15
+ konfuse = "konfuse:main"
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/chameerar/konfuse"
19
+ Source = "https://github.com/chameerar/konfuse"
20
+ "Bug Tracker" = "https://github.com/chameerar/konfuse/issues"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4"]
24
+
25
+ [tool.ruff]
26
+ target-version = "py38"
27
+ line-length = 110
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "W", "I"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,217 @@
1
+ from konfuse import backup_config, merge_kubeconfig
2
+
3
+ # ---------------------------------------------------------------------------
4
+ # Helpers
5
+ # ---------------------------------------------------------------------------
6
+
7
+ def make_kubeconfig(contexts=None, clusters=None, users=None):
8
+ return {
9
+ "apiVersion": "v1",
10
+ "kind": "Config",
11
+ "clusters": clusters or [],
12
+ "contexts": contexts or [],
13
+ "users": users or [],
14
+ "current-context": "",
15
+ "preferences": {},
16
+ }
17
+
18
+
19
+ def cluster(name, server="https://example.com"):
20
+ return {"name": name, "cluster": {"server": server}}
21
+
22
+
23
+ def user(name, token="tok"):
24
+ return {"name": name, "user": {"token": token}}
25
+
26
+
27
+ def context(name, cluster_name, user_name="admin"):
28
+ return {"name": name, "context": {"cluster": cluster_name, "user": user_name}}
29
+
30
+
31
+ def merge(*args, **kwargs):
32
+ """Unwrap the (merged, result) tuple for tests that only need the merged dict."""
33
+ merged, _ = merge_kubeconfig(*args, **kwargs)
34
+ return merged
35
+
36
+
37
+ def merge_result(*args, **kwargs):
38
+ """Unwrap the (merged, result) tuple for tests that need the result dict."""
39
+ _, result = merge_kubeconfig(*args, **kwargs)
40
+ return result
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Merge into empty
45
+ # ---------------------------------------------------------------------------
46
+
47
+ class TestMergeIntoEmpty:
48
+ def test_creates_default_structure(self):
49
+ incoming = make_kubeconfig(clusters=[cluster("c1")], users=[user("u1")],
50
+ contexts=[context("ctx1", "c1", "u1")])
51
+ result = merge(None, incoming)
52
+ assert result["apiVersion"] == "v1"
53
+ assert result["kind"] == "Config"
54
+
55
+ def test_entries_present(self):
56
+ incoming = make_kubeconfig(clusters=[cluster("c1")], users=[user("u1")],
57
+ contexts=[context("ctx1", "c1", "u1")])
58
+ result = merge(None, incoming)
59
+ assert any(e["name"] == "c1" for e in result["clusters"])
60
+ assert any(e["name"] == "u1" for e in result["users"])
61
+ assert any(e["name"] == "ctx1" for e in result["contexts"])
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # No rename
66
+ # ---------------------------------------------------------------------------
67
+
68
+ class TestMergeNoRename:
69
+ def test_names_preserved(self):
70
+ existing = make_kubeconfig(clusters=[cluster("existing-c")])
71
+ incoming = make_kubeconfig(clusters=[cluster("new-c")], users=[user("new-u")],
72
+ contexts=[context("new-ctx", "new-c", "new-u")])
73
+ result = merge(existing, incoming)
74
+ names = [e["name"] for e in result["clusters"]]
75
+ assert "existing-c" in names
76
+ assert "new-c" in names
77
+
78
+ def test_empty_incoming_sections(self):
79
+ existing = make_kubeconfig(clusters=[cluster("c1")])
80
+ result = merge(existing, make_kubeconfig())
81
+ assert len(result["clusters"]) == 1
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Rename context
86
+ # ---------------------------------------------------------------------------
87
+
88
+ class TestRenameContext:
89
+ def test_first_context_renamed(self):
90
+ incoming = make_kubeconfig(clusters=[cluster("c1")], users=[user("u1")],
91
+ contexts=[context("orig-ctx", "c1", "u1")])
92
+ result = merge(None, incoming, rename_context="prod")
93
+ assert any(e["name"] == "prod" for e in result["contexts"])
94
+ assert not any(e["name"] == "orig-ctx" for e in result["contexts"])
95
+
96
+ def test_second_context_not_renamed(self):
97
+ incoming = make_kubeconfig(clusters=[cluster("c1"), cluster("c2")],
98
+ users=[user("u1")],
99
+ contexts=[context("ctx1", "c1"), context("ctx2", "c2")])
100
+ result = merge(None, incoming, rename_context="prod")
101
+ names = [e["name"] for e in result["contexts"]]
102
+ assert "prod" in names
103
+ assert "ctx2" in names
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Rename cluster
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class TestRenameCluster:
111
+ def test_first_cluster_renamed(self):
112
+ incoming = make_kubeconfig(clusters=[cluster("orig-c")], users=[user("u1")],
113
+ contexts=[context("ctx1", "orig-c")])
114
+ result = merge(None, incoming, rename_cluster="prod-cluster")
115
+ assert any(e["name"] == "prod-cluster" for e in result["clusters"])
116
+ assert not any(e["name"] == "orig-c" for e in result["clusters"])
117
+
118
+ def test_cluster_reference_updated_in_context(self):
119
+ incoming = make_kubeconfig(clusters=[cluster("orig-c")], users=[user("u1")],
120
+ contexts=[context("ctx1", "orig-c")])
121
+ result = merge(None, incoming, rename_cluster="prod-cluster")
122
+ ctx = next(e for e in result["contexts"] if e["name"] == "ctx1")
123
+ assert ctx["context"]["cluster"] == "prod-cluster"
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Rename user
128
+ # ---------------------------------------------------------------------------
129
+
130
+ class TestRenameUser:
131
+ def test_first_user_renamed(self):
132
+ incoming = make_kubeconfig(clusters=[cluster("c1")], users=[user("orig-u")],
133
+ contexts=[context("ctx1", "c1", "orig-u")])
134
+ result = merge(None, incoming, rename_user="admin")
135
+ assert any(e["name"] == "admin" for e in result["users"])
136
+ assert not any(e["name"] == "orig-u" for e in result["users"])
137
+
138
+ def test_user_reference_updated_in_context(self):
139
+ incoming = make_kubeconfig(clusters=[cluster("c1")], users=[user("orig-u")],
140
+ contexts=[context("ctx1", "c1", "orig-u")])
141
+ result = merge(None, incoming, rename_user="admin")
142
+ ctx = next(e for e in result["contexts"] if e["name"] == "ctx1")
143
+ assert ctx["context"]["user"] == "admin"
144
+
145
+ def test_second_user_not_renamed(self):
146
+ incoming = make_kubeconfig(clusters=[cluster("c1")], users=[user("u1"), user("u2")],
147
+ contexts=[context("ctx1", "c1", "u1")])
148
+ result = merge(None, incoming, rename_user="admin")
149
+ names = [e["name"] for e in result["users"]]
150
+ assert "admin" in names
151
+ assert "u2" in names
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Rename all three
156
+ # ---------------------------------------------------------------------------
157
+
158
+ class TestRenameAll:
159
+ def test_all_renamed_and_references_updated(self):
160
+ incoming = make_kubeconfig(clusters=[cluster("orig-c")], users=[user("orig-u")],
161
+ contexts=[context("orig-ctx", "orig-c", "orig-u")])
162
+ result = merge(None, incoming, rename_context="prod",
163
+ rename_cluster="prod-c", rename_user="prod-u")
164
+ assert any(e["name"] == "prod" for e in result["contexts"])
165
+ assert any(e["name"] == "prod-c" for e in result["clusters"])
166
+ assert any(e["name"] == "prod-u" for e in result["users"])
167
+ ctx = next(e for e in result["contexts"] if e["name"] == "prod")
168
+ assert ctx["context"]["cluster"] == "prod-c"
169
+ assert ctx["context"]["user"] == "prod-u"
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Conflicts
174
+ # ---------------------------------------------------------------------------
175
+
176
+ class TestConflicts:
177
+ def test_existing_cluster_replaced(self):
178
+ existing = make_kubeconfig(clusters=[cluster("c1", "https://old.example.com")])
179
+ incoming = make_kubeconfig(clusters=[cluster("c1", "https://new.example.com")])
180
+ result = merge(existing, incoming)
181
+ assert len([e for e in result["clusters"] if e["name"] == "c1"]) == 1
182
+ assert result["clusters"][-1]["cluster"]["server"] == "https://new.example.com"
183
+
184
+ def test_conflict_recorded_in_result(self):
185
+ existing = make_kubeconfig(clusters=[cluster("c1")])
186
+ incoming = make_kubeconfig(clusters=[cluster("c1")])
187
+ r = merge_result(existing, incoming)
188
+ assert "c1" in r["clusters"]["replaced"]
189
+ assert "c1" not in r["clusters"]["added"]
190
+
191
+ def test_new_entry_recorded_in_result(self):
192
+ incoming = make_kubeconfig(clusters=[cluster("new-c")])
193
+ r = merge_result(None, incoming)
194
+ assert "new-c" in r["clusters"]["added"]
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # backup_config
199
+ # ---------------------------------------------------------------------------
200
+
201
+ class TestBackupConfig:
202
+ def test_backup_created(self, tmp_path):
203
+ config = tmp_path / "config"
204
+ config.write_text("original content")
205
+ backup_path = backup_config(str(config))
206
+ assert backup_path is not None
207
+ with open(backup_path) as f:
208
+ assert f.read() == "original content"
209
+
210
+ def test_no_backup_if_file_missing(self, tmp_path):
211
+ assert backup_config(str(tmp_path / "nonexistent")) is None
212
+
213
+ def test_backup_path_contains_timestamp(self, tmp_path):
214
+ config = tmp_path / "config"
215
+ config.write_text("x")
216
+ backup_path = backup_config(str(config))
217
+ assert ".backup." in backup_path