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.
- konfuse-0.1.0/.github/workflows/ci.yml +40 -0
- konfuse-0.1.0/.github/workflows/release.yml +124 -0
- konfuse-0.1.0/.gitignore +31 -0
- konfuse-0.1.0/AGENTS.md +58 -0
- konfuse-0.1.0/CHANGELOG.md +28 -0
- konfuse-0.1.0/CLAUDE.md +49 -0
- konfuse-0.1.0/LICENSE +21 -0
- konfuse-0.1.0/PKG-INFO +166 -0
- konfuse-0.1.0/README.md +129 -0
- konfuse-0.1.0/SKILL.md +69 -0
- konfuse-0.1.0/konfuse.py +305 -0
- konfuse-0.1.0/pyproject.toml +33 -0
- konfuse-0.1.0/tests/__init__.py +0 -0
- konfuse-0.1.0/tests/test_merge.py +217 -0
|
@@ -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
|
konfuse-0.1.0/.gitignore
ADDED
|
@@ -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
|
konfuse-0.1.0/AGENTS.md
ADDED
|
@@ -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
|
konfuse-0.1.0/CLAUDE.md
ADDED
|
@@ -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
|
+
[](https://github.com/chameerar/konfuse/actions/workflows/ci.yml)
|
|
43
|
+
[](https://pypi.org/project/konfuse/)
|
|
44
|
+
[](https://pypi.org/project/konfuse/)
|
|
45
|
+
[](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
|
konfuse-0.1.0/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# konfuse
|
|
2
|
+
|
|
3
|
+
> Merge any kubeconfig in one command. Rename on import. Never lose your existing config.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/chameerar/konfuse/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/konfuse/)
|
|
7
|
+
[](https://pypi.org/project/konfuse/)
|
|
8
|
+
[](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
|
+
```
|
konfuse-0.1.0/konfuse.py
ADDED
|
@@ -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
|