Compare commits

...
44 Commits
Author SHA1 Message Date
stefankoelle 2911f97991 Add CHANGELOG, CONTRIBUTING, and GitHub Issue/PR templates for v0.2.0 2026-08-09 22:13:09 +02:00
stefankoelle 7db58b6fe8 Disable Bubbles list filter to prevent stuck filter state
The / filter in the list component captured keystrokes after returning
from tmux, showing random text like 'cc' and blocking cursor navigation.
Filtering is not needed for the 3-level context/team/namespace flow.
2026-08-09 22:07:49 +02:00
stefankoelle 201168e8cd Fix k9s on Windows: --namespace, --command pods
- -n flag not supported on Windows k9s, use --namespace instead
- Start k9s directly in pods view with --command pods
2026-08-09 21:57:24 +02:00
stefankoelle ffbf4341fa Remove debug output from TUI, use verbose-only logging for tmux setup
- Debug fmt.Fprintf calls were always visible in TUI
- Added VerboseLog() to kubeexec package for use by full.go
- All tmux debug output now only shows with --verbose flag
2026-08-09 21:46:25 +02:00
stefankoelle 37be0707ad Fix Windows: run tmux setup commands individually, attach via ExecProcess
psmux on Windows doesn't handle ; separators when args are passed
individually via exec.Command. Instead of chaining commands with ;,
run each setup command (new-session, set-option, split-window, etc.)
as individual exec.Command calls. Only tmux attach uses ExecProcess
so it properly takes over the terminal.
2026-08-09 21:34:46 +02:00
stefankoelle 9e6acdedc2 Fix Windows: use cmd.exe /c for tmux command chain
psmux on Windows doesn't handle ; separators when args are passed
individually via exec.Command. On Windows, build the full tmux
command string and run it through cmd.exe /c instead.
2026-08-09 21:27:37 +02:00
stefankoelle 07662dc9b7 Add debug logging to startTmuxSession for Windows troubleshooting 2026-08-09 21:20:27 +02:00
stefankoelle 38b28f64fb Fix: no-args should start TUI, not print help
The condition showHelp||len(filtered)==0 caused the tool to always
print usage and exit. Now only --help triggers usage+exit.
2026-08-09 21:00:45 +02:00
stefankoelle 6a749811c7 LICENSE 2026-08-09 20:59:04 +02:00
stefankoelle 313dffcbbf Fix Windows: remove -- separator (psmux incompatible), add unknown command error
- Remove -- separator from tmux args (psmux on Windows doesn't support it)
- Unknown subcommands now show error + usage (exit 1) instead of starting TUI
- --help now always shows usage and exits (no fallthrough to TUI)
2026-08-09 20:56:39 +02:00
stefankoelle 8546ac9bf2 Fix: kill stale tmux session as separate command, not in chain
tmux aborts the entire command chain when kill-session fails (no
existing session). This caused 'no current target' error on Linux.

The kill-session is now a separate exec.Command() call before
starting the tmux chain, ignoring any error.
2026-08-09 20:45:44 +02:00
stefankoelle 4c0650c43b Fix tmux: add -- separator for shell commands, kill stale sessions 2026-08-09 20:33:07 +02:00
stefankoelle e36ac3568f Add --help, use os.Executable() for panel path, improve usage text 2026-08-09 20:29:55 +02:00
stefankoelle 697017e496 Add kctl-tui doctor command for health checks 2026-08-09 20:26:55 +02:00
stefankoelle 36a7cc5e3a Windows support: psmux hint, install.ps1, updated docs 2026-08-09 20:14:54 +02:00
stefankoelle e48bd790bd fix: move tool checks before tea.ExecProcess to fix namespace selection 2026-08-09 19:38:21 +02:00
stefankoelle 6795ee1f4f fix: tmux exec type mismatch, add --version flag, add config check command 2026-08-09 19:30:14 +02:00
stefankoelle edc02d48d4 Phase 2 hardening: binary secret handling, tool checks, input validation, verbose flag, diff scroll 2026-08-09 19:23:37 +02:00
stefankoelle 2c8e8016ff fix: config error handling, stderr separation, single-env pane, namespace sort 2026-08-09 19:12:19 +02:00
stefankoelle 886efa5958 Add go.sum and tidy indirect dependencies 2026-08-09 19:07:31 +02:00
stefankoelle c4d54d153f update docs 2026-08-09 19:00:55 +02:00
Stefan Koelle 4bb3507aec Use separate templates for AWS secret ID and Kubernetes secret name 2026-08-09 14:39:32 +02:00
Stefan Koelle 145288971a Add separate k8s_secret_name_template, since Kubernetes secret names follow a different pattern than AWS secret names 2026-08-09 14:36:44 +02:00
Stefan Koelle 75e0b832a2 fix: compute Kubernetes secret name from secret_name_template too, no more manual prompt 2026-08-09 14:31:24 +02:00
Stefan Koelle 0f889de86f docs: update README for the redesigned env-menu control pane and template-based config 2026-08-09 14:14:34 +02:00
Stefan Koelle ff898f727a Redesign panel: env-first menu (quit/beta/prod, each with secrets sync + redeploy), templated AWS secret ID instead of listing/region input 2026-08-09 14:05:33 +02:00
Stefan Koelle f7606011bb Redesign full-mode navigation: start at team selection with default context, resolve k9s panes from config envs instead of context-pairs 2026-08-09 14:02:55 +02:00
Stefan Koelle c02e145e4a Rework kubeexec to take explicit --context per call instead of mutating global kubectl state; drop functions superseded by config templates 2026-08-09 14:01:06 +02:00
Stefan Koelle 929b073382 Redesign config schema: contexts + envs + AWS region + secret/context templates instead of live kubectl discovery and context-pairs 2026-08-09 13:58:19 +02:00
Stefan Koelle 3af468de98 Remove context-pair tests, superseded by env-template based context resolution 2026-08-09 13:54:01 +02:00
Stefan Koelle 0a607bab28 Remove context-pair logic, superseded by env-template based context resolution 2026-08-09 13:51:36 +02:00
Stefan Koelle 1f87cdbf08 Add pure template-resolution logic (kctl.ResolveTemplate) with unit tests 2026-08-09 13:49:38 +02:00
Stefan Koelle 79c861682b Add AWS auth check before secrets workflow, with interactive SSO login prompt on expired session 2026-08-09 12:40:02 +02:00
Stefan Koelle c229f4d5d8 Add AWS auth check (sts get-caller-identity) and configurable SSO login command 2026-08-09 12:37:51 +02:00
Stefan Koelle 9daba2a3f0 fix: show a visible error screen instead of silently swallowing errors on resetToMenu 2026-08-09 12:31:43 +02:00
Stefan Koelle 4be10b6432 docs: update PLAN.md to reflect the redesigned secrets workflow (list + diff-all-fields + whole-secret force-sync) 2026-08-09 12:15:05 +02:00
Stefan Koelle e57d58aecc Redesign secrets workflow: pick AWS secret from a list, diff all fields at once, offer force-sync for the whole secret 2026-08-09 12:14:01 +02:00
Stefan Koelle b7d553c966 Add ListAWSSecrets and GetSecretAllFields for the redesigned secrets diff flow 2026-08-09 12:10:59 +02:00
Stefan Koelle 793c28afff Add pure secret-diff logic (kctl.DiffSecretValues) with unit tests 2026-08-09 12:09:25 +02:00
stefankoelle 9162eebccc fix install.sh 2026-08-09 12:03:05 +02:00
Stefan Koelle 26b5754f60 fix: use even-vertical tmux layout and remain-on-exit so panes stack correctly and errors stay visible 2026-08-09 12:01:50 +02:00
Stefan Koelle 76a389f52d install.sh: prevent silent abort from pipefail in tag_name extraction, add diagnostics 2026-08-09 11:57:06 +02:00
Stefan Koelle 73b38ef52c install.sh: read GitHub API response into a variable instead of a temp file (fixes curl exit 23) 2026-08-09 11:50:49 +02:00
Stefan Koelle c10b553b48 install.sh: fail with a clear message when no GitHub release exists yet 2026-08-09 11:48:06 +02:00
33 changed files with 2054 additions and 538 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug Report
about: Report a bug to help us improve kctl-tui
title: ""
labels: bug
assignees: ""
---
## Describe the Bug
A clear description of what the bug is.
## Steps to Reproduce
1. Run `kctl-tui ...`
2. Select '...'
3. See error
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Environment
- OS: [e.g. Windows 11, Ubuntu 24.04, macOS 15]
- Go version: [e.g. 1.22.5]
- kctl-tui version: [e.g. v0.2.0]
- kubectl version: [e.g. v1.30.0]
- tmux/psmux version: [e.g. tmux 3.4, psmux latest]
- Terminal: [e.g. Windows Terminal, iTerm2, GNOME Terminal]
## Additional Context
Config file (without secrets), error logs, or screenshots.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: ""
labels: enhancement
assignees: ""
---
## Problem
What problem does this feature solve?
## Proposed Solution
Describe the solution you'd like.
## Alternatives Considered
Any alternative solutions or workarounds you considered.
## Additional Context
Any mockups, examples, or references.
+23
View File
@@ -0,0 +1,23 @@
## Summary
Brief description of what this PR does.
## Changes
- ...
## Related Issues
Closes #
## Testing
- [ ] `go vet ./...` passes
- [ ] `go test ./...` passes
- [ ] Manually tested on [OS]
## Checklist
- [ ] Code follows existing style
- [ ] No new comments added (unless necessary)
- [ ] CHANGELOG.md updated (if applicable)
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
out="dist/kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}${ext}"
go build -o "$out" -ldflags "-s -w" ./cmd/kctl-tui
go build -o "$out" -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" ./cmd/kctl-tui
echo "Built $out"
- name: Upload artifact
+2 -2
View File
@@ -1,8 +1,8 @@
# Binaries
/bin/
/dist/
kctl-tui
kctl-tui.exe
/kctl-tui
/kctl-tui.exe
# Go
*.test
+59
View File
@@ -0,0 +1,59 @@
# Changelog
All notable changes to kctl-tui will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [0.2.0] - 2026-08-09
### Added
- **Windows support** via [psmux](https://github.com/marlocarlo/psmux) as tmux-compatible multiplexer
- `install.ps1` PowerShell install script for Windows
- `--help` flag with full usage documentation
- `--version` / `-v` flag (set via `-ldflags` at build time)
- `kctl-tui doctor` command to verify tools, config and cluster connectivity
- `kctl-tui config check` command to validate `~/.kctl-tui/config.yaml`
- `--verbose` flag for debug logging of all kubectl/aws commands to stderr
- Binary secret value detection (`IsBinary`) — base64 or non-UTF-8 content is flagged
- ExternalSecret name validation before force-sync
- `CheckTool()` and `CheckAWSAuth()` helpers for pre-flight checks
- Diff table scroll support (j/k, up/down arrows)
- `--command pods` flag for k9s to start directly in pod view
- `--namespace` flag for k9s (Windows compatibility)
### Fixed
- Tmux session cleanup: stale sessions are killed before creating new ones
- Panel path: uses `os.Executable()` instead of PATH lookup for correct binary
- k9s on Windows: `--namespace` instead of `-n`, direct pods view
- `--help` always shows usage and exits (no fallthrough to TUI)
- Unknown subcommands show error message + usage (exit 1)
- Panel quit: properly closes tmux session and exits
- Bubbles list filter disabled to prevent stuck filter state after tmux return
### Changed
- Config uses `k8s_secret_name_template` for Kubernetes secret names (separate from AWS `secret_name_template`)
- Contexts resolved via template (`context_template`) instead of live kubectl discovery
- Panel redesigned: env-first menu (quit/beta/prod) with secrets sync + redeploy
- Full-mode navigation starts at team selection with default context
- `tea.ExecProcess` only used for `tmux attach` — setup commands run synchronously
### Removed
- Context-pair logic (superseded by env-template based context resolution)
- Live kubectl discovery (superseded by config templates)
## [0.1.0] - 2026-07-XX
### Added
- Initial release with Bubble Tea TUI
- 3-pane tmux orchestration (control panel + 2x k9s)
- Team/namespace navigation with kubectl context switching
- Panel mode with redeploy and secrets diff/force-sync
- AWS SSO integration with interactive login prompt
- CI/CD pipeline with Go build and release
- Linux/macOS install script
[Unreleased]: https://github.com/skoelle/kctl-tui/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/skoelle/kctl-tui/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/skoelle/kctl-tui/releases/tag/v0.1.0
+95
View File
@@ -0,0 +1,95 @@
# Contributing to kctl-tui
Thanks for your interest in contributing! This document explains how to get
started.
## Development Setup
```bash
git clone https://github.com/skoelle/kctl-tui.git
cd kctl-tui
go mod download
```
### Prerequisites
- Go 1.22+
- kubectl, k9s, tmux (or psmux on Windows)
- An active Kubernetes cluster for integration testing
### Running Locally
```bash
go run ./cmd/kctl-tui
```
### Building
```bash
go build -o kctl-tui ./cmd/kctl-tui
```
### With Version Tag
```bash
go build -ldflags "-X main.version=v0.2.0" -o kctl-tui ./cmd/kctl-tui
```
## Project Structure
```
cmd/kctl-tui/ Entry points (main, full mode, panel mode)
internal/config/ YAML config loading and template resolution
internal/kctl/ Pure logic (secret diffing, template engine)
internal/kubeexec/ kubectl/aws/tmux wrappers (side effects only)
```
### Architecture Rules
- **Pure logic** goes into `internal/kctl` or `internal/config` — no exec, no I/O.
- **Side effects** (running kubectl, aws, tmux) go into `internal/kubeexec`.
- **UI** lives in `cmd/kctl-tui/` — Bubble Tea models, views, handlers.
- Unit tests cover pure logic only. Side-effect packages are tested via
integration/manual tests.
## Testing
```bash
go vet ./... # static analysis
go test ./... # unit tests
```
There are no integration tests yet. Manual testing against a real cluster is
expected for UI and kubeexec changes.
## Code Style
- Standard Go formatting (`gofmt`).
- No comments unless the logic is non-obvious.
- Error messages should be actionable — tell the user what to fix.
- Log commands with `kubeexec.VerboseLog()` when `--verbose` is active.
## Commits
- One logical change per commit.
- Imperative mood in commit messages ("Add ...", "Fix ...", "Remove ...").
- No co-authors in commits.
## Pull Requests
1. Fork the repo and create a feature branch.
2. Make your changes following the style guide above.
3. Run `go vet` and `go test`.
4. Open a PR against `main` with a clear description of what changed and why.
5. Reference any related issues.
## Issues
- Use the provided issue templates.
- Include your OS, Go version, and kctl-tui version.
- For bugs: steps to reproduce, expected vs actual behavior.
## License
By contributing, you agree that your contributions will be licensed under the
MIT License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Stefan Koelle
Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+44 -27
View File
@@ -10,61 +10,78 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
attaches binaries to GitHub Releases on version tags.
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release
asset.
asset, with clear diagnostics if no release exists yet or the GitHub
API is unreachable.
- [x] `README.md`, `config.example.yaml`.
## Phase 1 — Core logic + navigation (done, initial version)
- [x] `internal/kctl`: pure, unit-tested logic —
context-pair matching (`FindNextContext`) and namespace/label
filtering (`DistinctLabelValues`, `NamespacesForLabelValue`).
- [x] `internal/config`: YAML config loading (`context_pairs`,
`team_label_key`), with safe defaults when no config file exists yet.
template resolution (`ResolveTemplate`), namespace/label filtering
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
(`DiffSecretValues`, `AnyMismatch`).
- [x] `internal/config`: YAML config loading with template-based context
and secret name resolution (`ContextTemplate`, `SecretNameTemplate`,
`K8sSecretNameTemplate`), with safe defaults when no config file
exists yet.
- [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
(contexts, namespaces, deployments, rollout restart/status, secret
read, ExternalSecret annotation).
(namespaces, deployments, rollout restart/status, fetching AWS
secrets by template-resolved ID, reading all fields of a Kubernetes
secret, ExternalSecret annotation, AWS auth check).
- [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
context -> team -> namespace, with `Esc` correctly popping back one
level at a time, defaults pre-selected from the currently active
context/namespace.
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
session (control pane + two `k9s` panes) via `tea.ExecProcess` and
resumes at the namespace screen once the session ends.
- [x] `cmd/kctl-tui` "panel" mode: menu for Redeploy and the AWS/Kubernetes
secrets diff + force-sync wizard, with `Esc` closing the whole tmux
session (`tmux kill-session`).
session (control pane + two `k9s` panes, `even-vertical` layout,
`remain-on-exit` so a crashing control pane stays visible) via
`tea.ExecProcess` and resumes at the namespace screen once the
session ends.
- [x] `cmd/kctl-tui` "panel" mode:
- Redeploy: pick a deployment from a list, confirm, then
`rollout restart` + `rollout status`.
- Secrets: AWS auth check with interactive SSO login fallback,
then automatically resolve the AWS secret ID (from
`secret_name_template`) and Kubernetes secret name (from
`k8s_secret_name_template`), fetch both, diff **every field**
in one table (key / AWS value / Kubernetes value / match status).
If any field differs, offer a single force-sync request for the
**whole secret** (one ExternalSecret annotation).
- `Esc` closes the whole tmux session (`tmux kill-session`).
## Phase 2 — Hardening (open)
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value
formatting with a proper typed decode, and handle secrets that are
plain strings rather than JSON.
- [ ] Handle non-JSON AWS secrets and Kubernetes secrets with binary
(non-UTF8) values more gracefully in the diff table (currently
falls back to a single "value" key or may render oddly).
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
CI for the `kubeexec` wrappers currently excluded from automated
testing.
- [ ] Input validation for the free-text steps in "panel" mode (empty
secret ID/region/name, invalid characters).
region/secret name, invalid characters).
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
(currently surfaces the raw exec error).
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
`kubectl` calls.
`kubectl`/`aws` calls.
- [ ] Paginate/scroll the secrets diff table for secrets with many fields
instead of relying on terminal wrapping.
## Phase 3 — Windows-native support (open, secondary priority)
## Phase 3 — Windows-native support (done)
- [ ] Detect OS at runtime; on native Windows (no WSL), fall back to
`wt.exe split-pane` instead of `tmux` for the status panes.
- [ ] Document/implement that `Tab`-based context switching and
`Esc`-triggered session close are **not** available in the native
Windows fallback, per SPEC.md 3.6 — the panes must be closed
manually there.
- [x] Windows support via [psmux](https://github.com/marlocarlo/psmux) —
a native, tmux-compatible terminal multiplexer. kctl-tui works
without code changes; `CheckTool("tmux")` error message includes
Windows-specific install hint.
- [x] `install.ps1` — PowerShell install script for Windows.
- [x] Updated README and SPEC with Windows + psmux setup instructions.
## Phase 4 — Nice-to-haves (open, not committed)
- [x] `--version` flag — prints version, set via `-ldflags` at build time.
- [x] Config validation command (`kctl-tui config check`) — validates
required fields and shows a resolved context example.
- [ ] Optional direct use of `client-go` instead of shelling out to
`kubectl`, for faster context/namespace/label queries.
- [ ] Config validation command (`kctl-tui config check`) that reports
unknown label keys or context names not present in the current
kubeconfig.
- [ ] Homebrew tap / `scoop` manifest as additional install options
alongside `install.sh`.
+136 -58
View File
@@ -1,27 +1,35 @@
# kctl-tui
A small terminal entry point for everyday Kubernetes work: pick a context,
a team, and a namespace once, then drive status (via k9s), rollout
restarts, and an AWS Secrets Manager <-> Kubernetes Secret diff/force-sync
workflow from one place instead of retyping long `kubectl` commands.
A small terminal entry point for everyday Kubernetes work: pick a context
and a namespace once, then drive rollout restarts and an AWS Secrets
Manager <-> Kubernetes Secret diff/force-sync per environment from one
place instead of retyping long `kubectl` commands.
## Why
Working with several clusters, many namespaces per team, and paired
environments (e.g. staging/production) quickly turns into a lot of repeated
typing with plain `kubectl`/`k9s`. kctl-tui adds:
environments (e.g. beta/prod) quickly turns into a lot of repeated typing
with plain `kubectl`/`k9s`. kctl-tui adds:
- A guided **context -> team -> namespace** selection with sensible
defaults (the currently active context/namespace is pre-selected).
- A guided **context -> team -> namespace** selection that starts
directly at team selection (using a configured default context), with
the context screen just one `Esc` away.
- Namespace grouping by an arbitrary, configurable **label** instead of
scrolling through every namespace in the cluster.
- A **3-pane view** (via `tmux`): one control pane for actions, two status
panes running `k9s` for the current namespace across two related
contexts.
- A guided **rollout restart** that lists deployments instead of requiring
you to know/type the exact deployment name.
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison,
including an optional ExternalSecret force-sync annotation.
panes running `k9s` for the current namespace across your two
configured environments (e.g. beta/prod), shown side by side.
- A control-pane menu organized **by environment**: pick beta or prod,
then Secrets sync or Redeploy for that environment specifically.
- AWS Secrets Manager secret IDs and Kubernetes context names/ARNs are
**computed from configurable templates** (namespace + environment),
instead of listing secrets or discovering contexts live from
`kubectl`/`aws-cli`.
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison of
every field at once, with a force-sync request for the whole secret if
anything differs.
- An **AWS auth check** before the secrets workflow, offering to run your
configured SSO login command interactively if the session has expired.
See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
[PLAN.md](PLAN.md) for the implementation roadmap and current status.
@@ -31,38 +39,45 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
```
+--------------------------------------------------+
| Control pane: kctl-tui panel |
| -> Redeploy, Secrets diff/force-sync |
| -> 1) Quit 2) beta 3) prod |
| each with: a) Secrets sync b) Redeploy |
+--------------------------------------------------+
| k9s --context <context-a> -n <namespace> |
| k9s --context <resolved beta context> -n <ns> |
+--------------------------------------------------+
| k9s --context <context-b> -n <namespace> |
| k9s --context <resolved prod context> -n <ns> |
+--------------------------------------------------+
```
1. Run `kctl-tui`. It walks you through context, team, and namespace
selection.
2. Once a namespace is confirmed, it opens a `tmux` session with the layout
above and attaches to it.
3. Inside the control pane you can trigger a rollout restart or compare/
force-sync a secret. The two status panes keep showing live pod state
via `k9s`, so there is no separate "status" menu entry.
4. Pressing `Esc` in the control pane closes the whole `tmux` session
(including both `k9s` panes) and returns you to the namespace
selection.
5. Pressing `Tab` in the control pane switches both status panes to the
paired context configured in `context_pairs` (see Configuration),
keeping the same namespace.
1. Run `kctl-tui`. It loads your config, applies the default context, and
jumps straight to team selection; press `Esc` there to pick a
different context first.
2. Pick a team (namespace label filter), then a namespace.
3. It opens a `tmux` session with the layout above: the control pane runs
this binary in "panel" mode, the two status panes run `k9s` against
your first two configured environments (e.g. beta and prod), resolved
from `context_template`.
4. In the control pane, pick an environment, then Secrets sync or
Redeploy for that environment. `Esc` goes back one level (action menu
-> environment menu -> closes the whole tmux session, including both
`k9s` panes, and returns you to namespace selection).
## Requirements
- `kubectl`, configured with access to your cluster(s).
- `kubectl`, configured with access to your cluster(s) (the actual
context names/ARNs are resolved from your `context_template`, see
Configuration below - they must already exist in your kubeconfig, e.g.
added via `aws eks update-kubeconfig`).
- `k9s` (used for the two status panes).
- `tmux` (used for the 3-pane layout). On Windows, this means running
kctl-tui inside **WSL**`tmux` has no native Windows port. Native
Windows Terminal has its own split-pane feature, but it cannot be
scripted from inside a pane the way `tmux` can, so the automated 3-pane
layout and the `Tab`/`Esc` session handling described above are only
fully supported under Linux/WSL. See SPEC.md section 3.6 for details.
- `tmux` (used for the 3-pane layout). On **Linux/macOS**, install
`tmux` via your package manager. On **Windows**, install
[psmux](https://github.com/marlocarlo/psmux) — a native,
tmux-compatible terminal multiplexer:
```powershell
scoop install psmux
# or
cargo install psmux
```
psmux provides a `tmux` command, so kctl-tui works without changes.
- `aws` CLI, configured with credentials, only needed for the secrets
workflow.
@@ -74,6 +89,15 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
curl -fsSL https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.sh | bash
```
### Quick install (Windows)
```powershell
irm https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.ps1 | iex
```
This downloads the latest release binary for your architecture from
GitHub Releases and installs it to your PATH.
This downloads the latest release binary for your OS/architecture from
GitHub Releases and installs it to `/usr/local/bin/kctl-tui`.
@@ -98,41 +122,95 @@ matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/rele
## Configuration
Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml`
and adjust it to your own cluster setup:
and adjust it to your own setup:
```yaml
context_pairs:
- name: "example-environment-pair"
contexts:
- "example-context-a"
- "example-context-b"
contexts:
- "internal"
- "external"
default_context: "internal"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
team_label_key: "example.org/team"
aws_sso_login_command: "aws sso login"
```
- `context_pairs`: groups of related `kubectl` contexts. `Tab` in the
control pane cycles through the contexts of whichever group the current
context belongs to.
- `contexts` / `default_context`: the top-level grouping the tool starts
from (e.g. a network boundary such as internal/external-facing
clusters). This is the outermost navigation level, one `Esc` above team
selection.
- `envs`: the environments switchable from the control panel (e.g.
"beta"/"prod"). The **first two** entries are also used for the two k9s
status panes shown side by side.
- `aws_region` / `aws_account_id`: used for AWS Secrets Manager calls and
to fill the `{account_id}` placeholder in `context_template`.
`123456789012` is a placeholder, not a real account.
- `secret_name_template`: builds the AWS Secrets Manager secret ID from
the chosen namespace and environment. Placeholders: `{namespace}`,
`{env}`.
- `k8s_secret_name_template`: builds the Kubernetes secret name from the
chosen namespace. Kept separate from `secret_name_template` because the
two sides commonly follow different naming conventions. Placeholders:
`{namespace}`.
- `context_template`: builds the actual kubectl context name/ARN from
region, account ID, environment, and context. Placeholders: `{region}`,
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
`-1`, cluster naming, ARN shape) to match how your own clusters/contexts
are actually named — the resolved value must match an existing context
in your kubeconfig.
- `team_label_key`: the Kubernetes namespace label used to group
namespaces by team/ownership in the team-selection screen. This is
entirely up to your organization's labeling convention; kctl-tui ships
with no default team label of its own.
- `aws_sso_login_command`: run interactively if `aws sts
get-caller-identity` fails before the secrets workflow (e.g. an expired
SSO session). Defaults to `aws sso login`.
`~/.kctl-tui/config.yaml` is not part of this repository and should stay
that way — it typically contains your organization's internal context and
label names.
that way — it typically contains your organization's internal account ID,
context naming, and label names.
## WSL setup notes
## Windows notes
If `kubectx`/`kubens` or `kctl-tui` report a missing kubeconfig inside WSL,
your kubeconfig most likely only exists on the Windows side. Symlink it
into WSL:
On native Windows (without WSL), install [psmux](https://github.com/marlocarlo/psmux)
for the 3-pane layout. psmux is a native Windows terminal multiplexer
that is tmux-compatible — kctl-tui works without code changes:
```powershell
scoop install psmux
# or
cargo install psmux
```
If you prefer WSL, symlink your kubeconfig into WSL:
```bash
mkdir -p ~/.kube
ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config
```
## Usage
```bash
kctl-tui # start the TUI (full navigation mode)
kctl-tui --help # show all commands and flags
kctl-tui --version # print version
kctl-tui --verbose # enable debug logging to stderr
kctl-tui doctor # check if all tools, config and connections are OK
kctl-tui config check # validate ~/.kctl-tui/config.yaml
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
```
## Development
```bash
@@ -141,12 +219,12 @@ go vet ./...
go build ./cmd/kctl-tui
```
Pure logic (context-pair matching, label filtering, config parsing) lives
in `internal/kctl` and `internal/config` and is covered by unit tests. Code
that shells out to `kubectl`/`aws`/`tmux` lives in `internal/kubeexec` and
in `cmd/kctl-tui` and is intentionally kept thin and untested, since it has
no meaningful behavior without a live cluster.
Pure logic (template resolution, label filtering, config parsing, secret
diffing) lives in `internal/kctl` and `internal/config` and is covered by
unit tests. Code that shells out to `kubectl`/`aws`/`tmux` lives in
`internal/kubeexec` and in `cmd/kctl-tui` and is intentionally kept thin
and untested, since it has no meaningful behavior without a live cluster.
## License
[MIT](LICENSE)
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+59 -49
View File
@@ -5,7 +5,7 @@
A single terminal tool as the central entry point for everyday Kubernetes
work, bundling the most common workflows currently done via long
`kubectl`/`k9s`/`aws-cli` commands, operable through a text UI (arrow keys,
Esc, Tab) instead of long typed commands.
Esc) instead of long typed commands.
Target platform: **Linux / WSL** (primary usage scenario, since split
panes require a real terminal multiplexer). Native Windows (without WSL)
@@ -49,9 +49,6 @@ Navigation:
(`tmux kill-session`, closing both k9s panes as well) and then moves the
Go tool's screen stack one level up: namespace selection -> team
selection -> context selection.
- **Tab** in the control pane switches the context pair according to the
context-pair pattern (see 3.6) for both status panes simultaneously; the
namespace stays the same.
### 3.2 Namespace grouping via labels
@@ -64,10 +61,10 @@ Navigation:
kubectl get ns -o jsonpath='{range .items[*]}{.metadata.labels["<team-label-key>"]}{"\n"}{end}' | sort -u
```
- The actual label key is project-specific and set via the configuration
file (see 3.6), not hardcoded.
file (see `team_label_key` in config), not hardcoded.
- Namespace labeling is a prerequisite (one-time setup outside the tool).
### 3.3 Layout: 3-panel view (core design change vs. earlier drafts)
### 3.3 Layout: 3-panel view
Once start navigation is complete, the tool opens a tmux session with
**three panes**, started with a single command:
@@ -78,9 +75,9 @@ Once start navigation is complete, the tool opens a tmux session with
| -> runs the kctl-tui binary in "panel" mode |
| -> menu: Redeploy, secrets diff |
+--------------------------------------------------+
| Pane 1 (middle): k9s --context <context-a> -n <ns>|
| Pane 1 (middle): k9s --context <context-a> --namespace <ns> --command pods|
+--------------------------------------------------+
| Pane 2 (bottom): k9s --context <context-b> -n <ns>|
| Pane 2 (bottom): k9s --context <context-b> --namespace <ns> --command pods|
+--------------------------------------------------+
```
@@ -92,11 +89,15 @@ not cover: **redeploy** and **secrets diff**.
Example startup command (generic placeholders):
```
# Kill stale session first (separate command — tmux aborts on kill-session error).
tmux kill-session -t kctl
tmux new-session -d -s kctl \
"kctl-tui panel --ctx=$CTX_A --ns=$NS --team=$TEAM" \; \
split-window -v "k9s --context $CTX_A -n $NS" \; \
split-window -v "k9s --context $CTX_B -n $NS" \; \
select-layout main-horizontal \; \
"kctl-tui panel --context=$CTX_A --ns=$NS --team=$TEAM" \; \
set-option -t kctl remain-on-exit on \; \
split-window -v -t kctl:0.0 "k9s --context $CTX_A --namespace $NS --command pods" \; \
split-window -v -t kctl:0.1 "k9s --context $CTX_B --namespace $NS --command pods" \; \
select-layout -t kctl even-vertical \; \
select-pane -t kctl:0.0 \; \
attach -t kctl
```
@@ -115,59 +116,68 @@ Switching between panes: `Ctrl-b` + arrow key, or `Ctrl-b` `o`.
### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane
1. Load the secret from AWS Secrets Manager:
1. Before entering the secrets workflow, verify the AWS session is valid
(`aws sts get-caller-identity`). If expired, offer to run the
configured SSO login command interactively.
2. Resolve the AWS Secrets Manager secret ID from `secret_name_template`
(using namespace + env) and the Kubernetes secret name from
`k8s_secret_name_template` (using namespace). No manual input required
for either name.
3. Fetch the AWS secret:
`aws secretsmanager get-secret-value --secret-id <secret-id> --region <region> --query SecretString --output text`.
2. Show the contained keys for selection.
3. Load the matching Kubernetes secret field:
`kubectl -n <ns> get secret <secret-name> -o jsonpath='{.data.<field>}'`,
base64-decode it.
4. Compare the values (identical / different).
5. On mismatch, optionally request a force-sync:
4. Fetch all fields of the Kubernetes secret and base64-decode them:
`kubectl -n <ns> get secret <secret-name> -o json`.
5. Compare every field at once in a table (key / AWS value / Kubernetes
value / match status).
6. On mismatch, optionally request a force-sync for the whole secret:
`kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`.
All names (secret ID, secret name, field name, ExternalSecret name) are
asked for interactively at runtime, never hardcoded in the tool.
The ExternalSecret object name for the force-sync annotation is the only
value asked for interactively at runtime.
### 3.6 Context-pair pattern (configurable) — drives both status panes at once
### 3.6 Context resolution via templates
Requirement: the Tab switch in the control pane must switch **both**
status panes below it, not just an internal state.
The actual kubectl context name/ARN for each environment is computed from
a configurable template at startup. The two k9s status panes and all
kubectl calls in the control pane use the resolved context.
Configuration format (e.g. `~/.kctl-tui/config.yaml`), purely illustrative
with generic placeholders:
```yaml
context_pairs:
- name: "environment-pair-1"
contexts: ["<context-a1>", "<context-a2>"]
- name: "environment-pair-2"
contexts: ["<context-b1>", "<context-b2>"]
contexts:
- "internal"
- "external"
default_context: "internal"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
team_label_key: "<organization>/<label-name>"
```
Behavior on Tab in the control pane:
The `context_template` replaces `{region}`, `{account_id}`, `{env}`, and
`{context}` placeholders with the configured values and the currently
selected environment/context. The resolved value must match an existing
context in your kubeconfig (e.g. added via `aws eks update-kubeconfig`).
1. Determine the current context pair from the configuration.
2. Restart both k9s panes via
`tmux respawn-pane -k -t kctl:0.1 "k9s --context <newA> -n <ns>"` and
`... kctl:0.2 ...` (namespace stays the same).
3. If the current context is in no configured list: show a hint in the
control pane instead of an error.
4. No action outside this configuration — no error, only a hint.
**Platform limitation on Windows without WSL:** `respawn-pane`/
`kill-session` are tmux-specific. Windows Terminal (`wt.exe`) offers no
equivalent scripting to replace panes or end the session from inside a
pane. On plain Windows (without WSL), only a simplified flow is possible:
k9s panes are closed manually (`q`, then `Ctrl+Shift+W`); Tab switching and
automatic session termination are unavailable there. This limitation is
the main reason the primary target system is set to Linux/WSL.
**Windows support:** On native Windows, install
[psmux](https://github.com/marlocarlo/psmux) — a native, tmux-compatible
terminal multiplexer. psmux provides a `tmux` command, so kctl-tui works
without code changes (including `Esc`-triggered session termination).
Alternatively, run kctl-tui inside WSL with standard `tmux`.
## 4. Non-functional requirements
- **Primary platform Linux/WSL**, secondary native Windows with reduced
functionality.
- **Primary platform Linux/WSL**, secondary native Windows (via psmux).
- **Single-binary distribution** without external runtime dependency (Go
provides this natively).
- **External dependencies**: `kubectl` mandatory; `tmux`, `k9s`, `aws-cli`
@@ -240,8 +250,8 @@ Rejected options (see discussion history):
- Split view v1 fixed at 2 status panes + 1 control pane (3 panes total).
- No RBAC/permission checks before executing sensitive actions — the tool
assumes existing kubectl permissions.
- Configuration file format (`config.yaml`) is a proposal, not finally
agreed; concrete label keys, context names, and namespace names are
- Configuration file format (`config.yaml`) is defined and implemented;
concrete label keys, context names, and namespace names are
project-specific and belong exclusively in the user's local, unversioned
configuration, not in this document or the source code.
- Native Windows (without WSL) remains a secondary platform with manual
+118 -52
View File
@@ -1,8 +1,13 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main
import (
"fmt"
"os"
"os/exec"
"sort"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list"
@@ -20,9 +25,12 @@ const (
screenNamespace
)
// fullModel drives the interactive context -> team -> namespace navigation
// and, once a namespace is chosen, launches the 3-pane tmux session
// (control pane + two k9s panes) via tea.ExecProcess.
// fullModel drives the interactive navigation. It starts directly at the
// team-selection screen using the configured default context, and only
// shows the context screen when the user explicitly goes back via Esc.
// Once a namespace is chosen, it launches the 3-pane tmux session
// (control pane + two k9s panes, one per configured env) via
// tea.ExecProcess.
type fullModel struct {
list list.Model
state screenState
@@ -42,48 +50,47 @@ func newFullModel() *fullModel {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "kctl-tui"
l.SetShowStatusBar(false)
return &fullModel{list: l, state: screenContext}
l.SetFilteringEnabled(false)
return &fullModel{list: l}
}
func (m *fullModel) Init() tea.Cmd {
return m.loadContexts
return m.bootstrap
}
func (m *fullModel) loadContexts() tea.Msg {
contexts, err := kubeexec.GetContexts()
// bootstrap loads the config and applies the default context so the tool
// can jump straight to the team-selection screen.
func (m *fullModel) bootstrap() tea.Msg {
cfgPath, _ := config.DefaultPath()
cfg, err := config.Load(cfgPath)
if err != nil {
return errMsg{err}
}
current := kubeexec.GetCurrentContext()
cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath)
items := make([]list.Item, 0, len(contexts))
if current != "" {
items = append(items, simpleItem{label: "(current) " + current, value: current})
if len(cfg.Contexts) == 0 {
return errMsg{fmt.Errorf("no 'contexts' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
}
for _, c := range contexts {
if c != current {
items = append(items, simpleItem{label: c, value: c})
}
if len(cfg.Envs) == 0 {
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
}
return contextsLoadedMsg{items: items, cfg: cfg}
if err := kubeexec.CheckTool("kubectl"); err != nil {
return errMsg{err}
}
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
}
type contextsLoadedMsg struct {
items []list.Item
cfg config.Config
type bootstrapMsg struct {
cfg config.Config
context string
}
type contextsLoadedMsg struct{ items []list.Item }
type teamsLoadedMsg struct {
items []list.Item
namespaces map[string]map[string]string
}
type namespacesLoadedMsg struct {
items []list.Item
}
type namespacesLoadedMsg struct{ items []list.Item }
type tmuxDoneMsg struct{ err error }
@@ -100,8 +107,12 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err
return m, nil
case contextsLoadedMsg:
case bootstrapMsg:
m.cfg = msg.cfg
m.selectedContext = msg.context
return m, m.loadTeams
case contextsLoadedMsg:
m.state = screenContext
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
m.list.SetItems(msg.items)
@@ -110,7 +121,7 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case teamsLoadedMsg:
m.namespaces = msg.namespaces
m.state = screenTeam
m.list.Title = "Select team (esc = back to context)"
m.list.Title = fmt.Sprintf("Select team [context=%s] (esc = back to context)", m.selectedContext)
m.list.SetItems(msg.items)
return m, nil
@@ -157,6 +168,18 @@ func (m *fullModel) handleBack() (tea.Model, tea.Cmd) {
}
}
func (m *fullModel) loadContexts() tea.Msg {
items := make([]list.Item, 0, len(m.cfg.Contexts))
for _, c := range m.cfg.Contexts {
label := c
if c == m.selectedContext {
label = "(current) " + c
}
items = append(items, simpleItem{label: label, value: c})
}
return contextsLoadedMsg{items: items}
}
func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
@@ -166,9 +189,6 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
switch m.state {
case screenContext:
m.selectedContext = item.value
if err := kubeexec.UseContext(item.value); err != nil {
return m, func() tea.Msg { return errMsg{err} }
}
return m, m.loadTeams
case screenTeam:
@@ -177,16 +197,32 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
case screenNamespace:
m.selectedNamespace = item.value
if err := kubeexec.SetNamespace(item.value); err != nil {
return m, func() tea.Msg { return errMsg{err} }
if err := kubeexec.CheckTool("tmux"); err != nil {
m.err = err
return m, nil
}
if err := kubeexec.CheckTool("k9s"); err != nil {
m.err = err
return m, nil
}
return m, m.startTmuxSession()
}
return m, nil
}
// bootstrapContext resolves a kubectl context purely to discover
// namespaces/labels for the team/namespace screens. The first configured
// env is used as a stable default for this discovery step, since
// namespace names are assumed to be identical across envs.
func (m *fullModel) bootstrapContext() string {
if len(m.cfg.Envs) == 0 {
return ""
}
return m.cfg.ResolveContext(m.cfg.Envs[0], m.selectedContext)
}
func (m *fullModel) loadTeams() tea.Msg {
namespaces, err := kubeexec.GetNamespacesWithLabels()
namespaces, err := kubeexec.GetNamespacesWithLabels(m.bootstrapContext())
if err != nil {
return errMsg{err}
}
@@ -216,6 +252,7 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
for ns := range m.namespaces {
names = append(names, ns)
}
sort.Strings(names)
} else {
names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue)
}
@@ -227,29 +264,58 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
}
}
// startTmuxSession builds the 3-pane tmux command (control pane running
// this binary in "panel" mode, plus two k9s status panes) and runs it via
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal.
// startTmuxSession builds the 3-pane tmux command: the control pane runs
// this binary in "panel" mode (letting the user pick an env and an
// action), and the two status panes run k9s against the first two
// configured envs, resolved via the context template, so both are
// visible side by side.
func (m *fullModel) startTmuxSession() tea.Cmd {
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s",
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
secondCtx := m.selectedContext
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok {
secondCtx = next
selfPath, err := os.Executable()
if err != nil {
selfPath = "kctl-tui" // fallback to PATH lookup
}
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace)
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
panelCmd, ";",
"split-window", "-v", k9sCmdA, ";",
"split-window", "-v", k9sCmdB, ";",
"select-layout", "main-horizontal", ";",
"attach", "-t", "kctl",
envA := m.cfg.Envs[0]
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
k9sCmdA := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxA, m.selectedNamespace)
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
// Kill stale session first (ignore error if none exists).
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
// Run setup commands individually — this avoids the ; separator
// issue on Windows where psmux doesn't handle chained args.
setup := [][]string{
{"new-session", "-d", "-s", "kctl", panelCmd},
{"set-option", "-t", "kctl", "remain-on-exit", "on"},
{"split-window", "-v", "-t", "kctl:0.0", k9sCmdA},
}
if len(m.cfg.Envs) > 1 {
envB := m.cfg.Envs[1]
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
k9sCmdB := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxB, m.selectedNamespace)
kubeexec.VerboseLog("[debug] k9sCmdB=%s\n", k9sCmdB)
setup = append(setup, []string{"split-window", "-v", "-t", "kctl:0.1", k9sCmdB})
}
setup = append(setup,
[]string{"select-layout", "-t", "kctl", "even-vertical"},
[]string{"select-pane", "-t", "kctl:0.0"},
)
for _, args := range setup {
if out, err := exec.Command("tmux", args...).CombinedOutput(); err != nil {
kubeexec.VerboseLog("[debug] tmux %s failed: %v\n%s\n", args[0], err, out)
return func() tea.Msg { return tmuxDoneMsg{err: fmt.Errorf("tmux %s: %w", args[0], err)} }
}
}
// Only attach uses tea.ExecProcess so it takes over the terminal.
c := exec.Command("tmux", "attach", "-t", "kctl")
return tea.ExecProcess(c, func(err error) tea.Msg {
return tmuxDoneMsg{err: err}
})
+9 -2
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main
import "github.com/skoelle/kctl-tui/internal/kctl"
@@ -10,6 +13,10 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
}
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
return kctl.FindNextContext(current, pairs)
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
return kctl.DiffSecretValues(left, right)
}
func anyMismatch(entries []kctl.SecretDiffEntry) bool {
return kctl.AnyMismatch(entries)
}
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main
// simpleItem is a minimal implementation of list.Item used for all
+246 -4
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main
import (
@@ -5,15 +8,71 @@ import (
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/skoelle/kctl-tui/internal/config"
"github.com/skoelle/kctl-tui/internal/kubeexec"
)
// version is set via -ldflags at build time.
var version = "dev"
func main() {
if len(os.Args) > 1 && os.Args[1] == "panel" {
if err := runPanel(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err)
args := os.Args[1:]
// Extract global flags before delegating to sub-commands.
verbose := false
showHelp := false
filtered := make([]string, 0, len(args))
for _, a := range args {
switch a {
case "--verbose":
verbose = true
case "--version", "-v":
fmt.Printf("kctl-tui %s\n", version)
return
case "--help", "-h":
showHelp = true
default:
filtered = append(filtered, a)
}
}
if verbose {
kubeexec.SetVerbose(true, os.Stderr)
}
if showHelp {
printUsage()
return
}
if len(filtered) == 0 {
// No subcommand — start the full TUI.
}
if len(filtered) > 0 {
switch filtered[0] {
case "panel":
if err := runPanel(filtered[1:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err)
os.Exit(1)
}
return
case "config":
if err := runConfig(filtered[1:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui config error:", err)
os.Exit(1)
}
return
case "doctor":
if err := runDoctor(); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui doctor error:", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", filtered[0])
printUsage()
os.Exit(1)
}
return
}
m := newFullModel()
@@ -23,3 +82,186 @@ func main() {
os.Exit(1)
}
}
func printUsage() {
fmt.Print(`kctl-tui — Kubernetes entry-point TUI
Usage:
kctl-tui [flags] start the TUI (full navigation mode)
kctl-tui doctor check tools, config and connections
kctl-tui config check validate ~/.kctl-tui/config.yaml
kctl-tui panel [options] control pane (called internally by tmux)
Flags:
--verbose log all kubectl/aws commands to stderr
--version print version
--help show this help
Examples:
kctl-tui # start the TUI
kctl-tui doctor # verify everything is installed
kctl-tui --verbose 2>debug.log # log commands to a file
kctl-tui config check # validate config
`)
}
func runConfig(args []string) error {
if len(args) == 0 || args[0] != "check" {
return fmt.Errorf("usage: kctl-tui config check")
}
cfgPath, err := config.DefaultPath()
if err != nil {
return fmt.Errorf("cannot determine config path: %w", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
return fmt.Errorf("failed to load %s: %w", cfgPath, err)
}
ok := true
if len(cfg.Contexts) == 0 {
fmt.Fprintln(os.Stderr, "ERROR: no 'contexts' configured")
ok = false
}
if len(cfg.Envs) == 0 {
fmt.Fprintln(os.Stderr, "ERROR: no 'envs' configured")
ok = false
}
if cfg.ContextTemplate == "" {
fmt.Fprintln(os.Stderr, "ERROR: 'context_template' is empty")
ok = false
}
if cfg.SecretNameTemplate == "" {
fmt.Fprintln(os.Stderr, "ERROR: 'secret_name_template' is empty")
ok = false
}
if cfg.TeamLabelKey == "" {
fmt.Fprintln(os.Stderr, "WARNING: 'team_label_key' is empty — team selection will have no groups")
}
if cfg.AWSRegion == "" {
fmt.Fprintln(os.Stderr, "WARNING: 'aws_region' is empty — secrets workflow will fail")
}
// Try resolving one context to verify the template works.
if len(cfg.Contexts) > 0 && len(cfg.Envs) > 0 && cfg.ContextTemplate != "" {
ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0])
fmt.Printf("Resolved context example: %s\n", ctx)
}
if ok {
fmt.Println("Config OK")
} else {
fmt.Fprintln(os.Stderr, "Config has errors — see above")
os.Exit(1)
}
return nil
}
func runDoctor() error {
pass := "ok"
fail := "FAIL"
warn := "WARN"
status := pass
errs := 0
check := func(label string, err error) {
if err != nil {
fmt.Printf(" [%s] %s: %v\n", fail, label, err)
status = fail
errs++
} else {
fmt.Printf(" [%s] %s\n", pass, label)
}
}
warnCheck := func(label string, err error) {
if err != nil {
fmt.Printf(" [%s] %s: %v\n", warn, label, err)
} else {
fmt.Printf(" [%s] %s\n", pass, label)
}
}
// --- Tools ---
fmt.Println("\nTools:")
check("kubectl", kubeexec.CheckTool("kubectl"))
check("tmux/psmux", kubeexec.CheckTool("tmux"))
check("k9s", kubeexec.CheckTool("k9s"))
warnCheck("aws CLI (optional)", kubeexec.CheckTool("aws"))
// --- Config ---
fmt.Println("\nConfig:")
cfgPath, err := config.DefaultPath()
if err != nil {
fmt.Printf(" [%s] config path: %v\n", fail, err)
errs++
status = fail
} else {
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Printf(" [%s] load config: %v\n", fail, err)
errs++
status = fail
} else {
check("config file exists", nil)
if len(cfg.Contexts) == 0 {
fmt.Printf(" [%s] contexts configured\n", fail)
errs++
status = fail
} else {
fmt.Printf(" [%s] contexts configured (%d)\n", pass, len(cfg.Contexts))
}
if len(cfg.Envs) == 0 {
fmt.Printf(" [%s] envs configured\n", fail)
errs++
status = fail
} else {
fmt.Printf(" [%s] envs configured (%d)\n", pass, len(cfg.Envs))
}
if cfg.ContextTemplate != "" {
ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0])
fmt.Printf(" [%s] context_template resolves to: %s\n", pass, ctx)
} else {
fmt.Printf(" [%s] context_template is empty\n", fail)
errs++
status = fail
}
if cfg.SecretNameTemplate != "" && len(cfg.Envs) > 0 {
secret := cfg.ResolveSecretName("example-ns", cfg.Envs[0])
fmt.Printf(" [%s] secret_name_template resolves to: %s\n", pass, secret)
}
}
}
// --- Connections ---
fmt.Println("\nConnections:")
if kubeexec.CheckTool("kubectl") == nil {
err := kubeexec.CheckAWSAuth()
if err == nil {
fmt.Printf(" [%s] kubectl cluster reachable\n", pass)
} else {
// Not fatal — cluster might be unreachable from this machine
fmt.Printf(" [%s] kubectl cluster: %v\n", warn, err)
}
}
if kubeexec.CheckTool("aws") == nil {
err := kubeexec.CheckAWSAuth()
if err == nil {
fmt.Printf(" [%s] AWS credentials valid\n", pass)
} else {
fmt.Printf(" [%s] AWS credentials: %v\n", warn, err)
}
} else {
fmt.Printf(" [%s] AWS credentials (aws CLI not installed)\n", warn)
}
// --- Summary ---
fmt.Println()
if status == pass {
fmt.Println("All checks passed. kctl-tui is ready to use.")
} else {
fmt.Printf("%d error(s) found. Fix the issues above and re-run: kctl-tui doctor\n", errs)
os.Exit(1)
}
return nil
}
+328 -165
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main
import (
@@ -6,95 +9,138 @@ import (
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/skoelle/kctl-tui/internal/config"
"github.com/skoelle/kctl-tui/internal/kctl"
"github.com/skoelle/kctl-tui/internal/kubeexec"
)
// panelStep identifies which part of the redeploy/secrets wizard is shown.
// panelStep identifies which part of the env/action wizard is shown.
type panelStep int
const (
stepMenu panelStep = iota
stepEnvMenu panelStep = iota
stepActionMenu
stepRedeployList
stepRedeployConfirm
stepSecretID
stepSecretRegion
stepSecretKeyList
stepK8sSecretName
stepK8sFieldName
stepAWSAuthPrompt
stepDiffResult
stepForceSyncConfirm
stepExternalSecretName
stepDone
stepError
)
type panelModel struct {
ctx, ns, team string
context, ns, team string
cfg config.Config
step panelStep
list list.Model
currentEnv string
step panelStep
list list.Model
input textinput.Model
awsSecretID string
awsRegion string
deploymentName string
awsSecretName string // resolved via secret_name_template (namespace + env)
k8sSecretName string // resolved via k8s_secret_name_template (namespace only)
awsValues map[string]string
selectedKey string
awsValue string
k8sSecretName string
k8sFieldName string
k8sValue string
k8sValues map[string]string
diffEntries []kctl.SecretDiffEntry
diffOffset int // scroll position for diff table
message string
err error
err error
}
func runPanel(args []string) error {
fs := flag.NewFlagSet("panel", flag.ContinueOnError)
ctx := fs.String("ctx", "", "kubectl context")
context := fs.String("context", "", "context (e.g. internal/external)")
ns := fs.String("ns", "", "namespace")
team := fs.String("team", "", "team label value")
if err := fs.Parse(args); err != nil {
return err
}
m := newPanelModel(*ctx, *ns, *team)
m := newPanelModel(*context, *ns, *team)
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}
func newPanelModel(ctx, ns, team string) *panelModel {
l := list.New(menuItems(), list.NewDefaultDelegate(), 0, 0)
l.Title = fmt.Sprintf("kctl-tui panel [ctx=%s ns=%s team=%s]", ctx, ns, team)
l.SetShowStatusBar(false)
func newPanelModel(context, ns, team string) *panelModel {
ti := textinput.New()
ti.Focus()
return &panelModel{ctx: ctx, ns: ns, team: team, step: stepMenu, list: l, input: ti}
cfgPath, _ := config.DefaultPath()
cfg, loadErr := config.Load(cfgPath)
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
if loadErr != nil {
m.err = fmt.Errorf("config load failed: %w", loadErr)
m.step = stepError
} else {
m.showEnvMenu()
}
return m
}
func menuItems() []list.Item {
return []list.Item{
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"},
simpleItem{label: "Secrets: AWS <-> Kubernetes diff", value: "secrets"},
simpleItem{label: "Quit (closes this tmux session)", value: "quit"},
func (m *panelModel) showEnvMenu() {
items := make([]list.Item, 0, len(m.cfg.Envs)+1)
items = append(items, simpleItem{label: "Quit (closes this tmux session)", value: "quit"})
for _, env := range m.cfg.Envs {
items = append(items, simpleItem{label: env, value: env})
}
m.list.SetItems(items)
m.list.Title = fmt.Sprintf("kctl-tui panel [context=%s ns=%s team=%s]", m.context, m.ns, m.team)
m.step = stepEnvMenu
m.currentEnv = ""
m.message = ""
m.err = nil
}
func (m *panelModel) showActionMenu() {
m.list.SetItems([]list.Item{
simpleItem{label: "Secrets sync (AWS <-> Kubernetes)", value: "secrets"},
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"},
})
m.list.Title = fmt.Sprintf("kctl-tui panel [context=%s ns=%s team=%s env=%s] (esc = back)",
m.context, m.ns, m.team, m.currentEnv)
m.step = stepActionMenu
m.message = ""
m.err = nil
}
// resolvedContext returns the actual kubectl context/ARN for the
// currently selected env, built from the configured context_template.
func (m *panelModel) resolvedContext() string {
return m.cfg.ResolveContext(m.currentEnv, m.context)
}
func (m *panelModel) Init() tea.Cmd { return nil }
type awsLoginDoneMsg struct{ err error }
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
return m, nil
case awsLoginDoneMsg:
return m.afterAWSLogin(msg.err)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
@@ -103,6 +149,14 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleEsc()
case "enter":
return m.handleEnter()
case "up", "k":
if m.step == stepDiffResult {
return m.scrollDiff(-1)
}
case "down", "j":
if m.step == stepDiffResult {
return m.scrollDiff(1)
}
}
}
@@ -118,103 +172,180 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *panelModel) usesTextInput() bool {
switch m.step {
case stepSecretID, stepSecretRegion, stepK8sSecretName, stepK8sFieldName, stepExternalSecretName:
return true
}
return false
return m.step == stepExternalSecretName
}
// handleEsc closes the whole tmux session (all panes, including the two
// k9s status panes) before quitting this program, per SPEC.md 3.6.
// handleEsc navigates one level up: action menu -> env menu, most
// sub-steps -> action menu. From the top-level env menu it closes the
// whole tmux session (all panes, including the two k9s status panes)
// before quitting this program, per SPEC.md 3.6.
func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
return m, tea.Quit
switch m.step {
case stepEnvMenu:
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
return m, tea.Quit
case stepActionMenu:
m.showEnvMenu()
return m, nil
default:
m.showActionMenu()
return m, nil
}
}
func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
switch m.step {
case stepMenu:
return m.fromMenu()
case stepEnvMenu:
return m.fromEnvMenu()
case stepActionMenu:
return m.fromActionMenu()
case stepRedeployList:
return m.fromRedeployList()
case stepRedeployConfirm:
return m.fromRedeployConfirm()
case stepSecretID:
m.awsSecretID = m.input.Value()
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
return m, nil
case stepSecretRegion:
m.awsRegion = m.input.Value()
return m.fetchAWSSecret()
case stepSecretKeyList:
return m.fromSecretKeyList()
case stepK8sSecretName:
m.k8sSecretName = m.input.Value()
m.step = stepK8sFieldName
m.input.SetValue("")
return m, nil
case stepK8sFieldName:
m.k8sFieldName = m.input.Value()
return m.compareSecret()
case stepAWSAuthPrompt:
return m.fromAWSAuthPrompt()
case stepForceSyncConfirm:
return m.fromForceSyncConfirm()
case stepExternalSecretName:
return m.doForceSync()
case stepDiffResult, stepDone:
m.resetToMenu()
case stepDiffResult, stepDone, stepError:
m.showActionMenu()
return m, nil
}
return m, nil
}
func (m *panelModel) resetToMenu() {
m.step = stepMenu
m.list.SetItems(menuItems())
m.list.Title = "kctl-tui panel"
// showError switches to a dedicated error screen so failures from
// kubectl/aws calls stay visible until the user explicitly acknowledges
// them with Enter, instead of being silently discarded.
func (m *panelModel) showError(err error) (tea.Model, tea.Cmd) {
m.err = err
m.step = stepError
return m, nil
}
func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
func (m *panelModel) fromEnvMenu() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
if item.value == "quit" {
return m.handleEsc()
}
m.currentEnv = item.value
m.showActionMenu()
return m, nil
}
func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
switch item.value {
case "redeploy":
deployments, err := kubeexec.GetDeployments(m.ns)
deployments, err := kubeexec.GetDeployments(m.resolvedContext(), m.ns)
if err != nil {
m.err = err
return m, nil
return m.showError(err)
}
items := make([]list.Item, 0, len(deployments))
for _, d := range deployments {
items = append(items, simpleItem{label: d, value: d})
}
m.list.SetItems(items)
m.list.Title = "Select deployment to restart (esc = back)"
m.list.Title = fmt.Sprintf("Select deployment to restart [env=%s] (esc = back)", m.currentEnv)
m.step = stepRedeployList
case "secrets":
m.step = stepSecretID
m.input.SetValue("")
m.input.Placeholder = "AWS secret ID"
case "quit":
return m.handleEsc()
return m.checkAWSAuthAndProceed()
}
return m, nil
}
// checkAWSAuthAndProceed verifies the current AWS credentials/SSO session
// before entering the secrets workflow. If the check fails (e.g. an
// expired SSO session), it offers to run the configured login command
// interactively instead of letting the user hit a confusing failure
// several steps later.
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
if err := kubeexec.CheckTool("aws"); err != nil {
return m.showError(err)
}
if err := kubeexec.CheckAWSAuth(); err != nil {
m.err = err
m.list.SetItems([]list.Item{
simpleItem{label: "Run AWS login now (" + m.cfg.LoginCommand() + ")", value: "login"},
simpleItem{label: "Cancel", value: "cancel"},
})
m.list.Title = "AWS session invalid or expired"
m.step = stepAWSAuthPrompt
return m, nil
}
return m.startSecretsFlow()
}
func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "login" {
m.showActionMenu()
return m, nil
}
cmd := kubeexec.RunAWSLogin(m.cfg.LoginCommand())
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
return awsLoginDoneMsg{err: err}
})
}
// afterAWSLogin re-checks AWS auth once the interactive login command has
// finished (successfully or not) and either proceeds into the secrets
// workflow or shows the remaining error.
func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
if execErr != nil {
return m.showError(fmt.Errorf("login command failed to run: %w", execErr))
}
if err := kubeexec.CheckAWSAuth(); err != nil {
return m.showError(fmt.Errorf("still not authenticated with AWS after running '%s': %w", m.cfg.LoginCommand(), err))
}
return m.startSecretsFlow()
}
// startSecretsFlow computes the AWS secret ID (namespace + env) and the
// Kubernetes secret name (namespace only) from their respective
// templates and fetches the AWS side directly - no manual input required
// for either name.
func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
m.awsSecretName = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
m.k8sSecretName = m.cfg.ResolveK8sSecretName(m.ns)
raw, err := kubeexec.GetAWSSecretString(m.awsSecretName, m.cfg.AWSRegion)
if err != nil {
return m.showError(fmt.Errorf("failed to fetch AWS secret %q: %w", m.awsSecretName, err))
}
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
// Not a JSON secret - treat the whole value as a single field.
m.awsValues = map[string]string{"value": raw}
} else {
m.awsValues = map[string]string{}
for k, v := range parsed {
m.awsValues[k] = fmt.Sprintf("%v", v)
}
}
return m.compareAllFields()
}
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedKey = item.value // reused as "deployment name" here
m.deploymentName = item.value
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"},
})
m.list.Title = "Confirm rollout restart"
m.list.Title = fmt.Sprintf("Confirm rollout restart [env=%s]", m.currentEnv)
m.step = stepRedeployConfirm
return m, nil
}
@@ -222,89 +353,108 @@ func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" {
m.resetToMenu()
m.showActionMenu()
return m, nil
}
_, err := kubeexec.RolloutRestart(m.ns, m.selectedKey)
ctx := m.resolvedContext()
_, err := kubeexec.RolloutRestart(ctx, m.ns, m.deploymentName)
if err != nil {
m.err = err
return m.showError(err)
}
status, _ := kubeexec.RolloutStatus(m.ns, m.selectedKey)
m.message = "Rollout status: " + status
status, err := kubeexec.RolloutStatus(ctx, m.ns, m.deploymentName)
if err != nil {
return m.showError(err)
}
m.message = fmt.Sprintf("[env=%s] Rollout status: %s", m.currentEnv, status)
m.step = stepDone
return m, nil
}
func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) {
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion)
// compareAllFields fetches every field of the Kubernetes secret and diffs
// it against every key of the AWS secret in one go.
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
k8sValues, err := kubeexec.GetSecretAllFields(m.resolvedContext(), m.ns, m.k8sSecretName)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
return m.showError(fmt.Errorf("failed to fetch Kubernetes secret %q: %w", m.k8sSecretName, err))
}
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
m.awsValues = map[string]string{"__raw__": raw}
m.k8sValues = k8sValues
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.diffOffset = 0
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync for this secret", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
} else {
m.awsValues = map[string]string{}
for k, v := range parsed {
m.awsValues[k] = fmt.Sprintf("%v", v)
}
}
items := make([]list.Item, 0, len(m.awsValues))
for k := range m.awsValues {
items = append(items, simpleItem{label: k, value: k})
}
m.list.SetItems(items)
m.list.Title = "Select AWS secret key to compare"
m.step = stepSecretKeyList
return m, nil
}
func (m *panelModel) fromSecretKeyList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedKey = item.value
m.awsValue = m.awsValues[item.value]
m.step = stepK8sSecretName
m.input.SetValue("")
m.input.Placeholder = "Kubernetes secret name"
return m, nil
}
func (m *panelModel) compareSecret() (tea.Model, tea.Cmd) {
b64, err := kubeexec.GetSecretValueBase64(m.ns, m.k8sSecretName, m.k8sFieldName)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
}
decoded, err := kubeexec.DecodeBase64(b64)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
}
m.k8sValue = decoded
if m.awsValue == m.k8sValue {
m.message = "IDENTICAL\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
m.step = stepDiffResult
return m, nil
}
m.message = "DIFFERENT\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
return m, nil
}
func (m *panelModel) scrollDiff(delta int) (tea.Model, tea.Cmd) {
newOff := m.diffOffset + delta
if newOff < 0 {
newOff = 0
}
maxOff := len(m.diffEntries) - 1
if maxOff < 0 {
maxOff = 0
}
if newOff > maxOff {
newOff = maxOff
}
m.diffOffset = newOff
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
return m, nil
}
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry, offset, visibleHeight int) string {
var b strings.Builder
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName)
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
start := offset
if start > len(entries) {
start = len(entries)
}
end := len(entries)
if visibleHeight > 0 && start+visibleHeight < end {
end = start + visibleHeight
}
for _, e := range entries[start:end] {
status := "OK"
if !e.Match {
status = "MISMATCH"
}
left := e.Left
if e.LeftBin {
left = fmt.Sprintf("<binary %d bytes>", len(e.Left))
}
right := e.Right
if e.RightBin {
right = fmt.Sprintf("<binary %d bytes>", len(e.Right))
}
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(left, 20), truncate(right, 20), status)
}
if len(entries) > 0 {
fmt.Fprintf(&b, "\n Showing %d-%d of %d fields (j/k or arrow keys to scroll)", start+1, end, len(entries))
}
return b.String()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}
func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" {
@@ -312,50 +462,63 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
return m, nil
}
m.step = stepExternalSecretName
m.input.SetValue("")
m.input.SetValue(m.k8sSecretName)
m.input.Placeholder = "ExternalSecret object name"
return m, nil
}
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
name := m.input.Value()
ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts)
if err != nil {
m.err = err
name := strings.TrimSpace(m.input.Value())
if name == "" {
return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
}
m.message = "Force-sync requested (timestamp " + strconv.FormatInt(ts, 10) + ")."
for _, r := range name {
if r < 0x20 || r > 0x7e || r == '/' || r == ' ' {
return m.showError(fmt.Errorf("ExternalSecret name contains invalid character: %q", r))
}
}
ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
if err != nil {
return m.showError(err)
}
m.message += fmt.Sprintf("\nForce-sync requested for %s (timestamp %s).", name, strconv.FormatInt(ts, 10))
m.step = stepDone
return m, nil
}
func (m *panelModel) View() string {
switch m.step {
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretKeyList, stepForceSyncConfirm:
case stepEnvMenu, stepActionMenu, stepRedeployList, stepRedeployConfirm:
v := m.list.View()
if m.err != nil {
v += "\nerror: " + m.err.Error()
}
return v
case stepAWSAuthPrompt:
errText := ""
if m.err != nil {
errText = m.err.Error() + "\n\n"
}
return errText + m.list.View()
case stepForceSyncConfirm:
return m.message + "\n\n" + m.list.View()
case stepDiffResult, stepDone:
return m.message + "\n\n(press enter to return to menu, esc to close session)"
return m.message + "\n\n(press enter to return to the action menu, esc to go back)"
case stepError:
errText := "unknown error"
if m.err != nil {
errText = m.err.Error()
}
return "ERROR:\n\n" + errText + "\n\n(press enter to return to the action menu, esc to go back)"
default:
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)",
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back)",
m.stepPrompt(), m.input.View())
}
}
func (m *panelModel) stepPrompt() string {
switch m.step {
case stepSecretID:
return "AWS Secrets Manager: enter secret ID"
case stepSecretRegion:
return "AWS region"
case stepK8sSecretName:
return "Kubernetes secret name (in namespace " + m.ns + ")"
case stepK8sFieldName:
return "Field name inside the Kubernetes secret for key \"" + m.selectedKey + "\""
case stepExternalSecretName:
if m.step == stepExternalSecretName {
return "ExternalSecret object name to annotate"
}
return ""
+57 -14
View File
@@ -1,19 +1,62 @@
# Example configuration for kctl-tui.
# Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your
# own cluster setup. Do NOT commit your real config.yaml with company- or
# project-specific context/namespace/label names to a public repository.
# own AWS/Kubernetes setup. Do NOT commit your real config.yaml with
# company- or project-specific account IDs, contexts, or label names to a
# public repository.
# Groups of kubectl contexts that belong together (e.g. the same
# environment pair, such as staging/production of the same cluster).
# Pressing TAB in the control pane cycles through the contexts listed here
# while keeping the current namespace.
context_pairs:
- name: "example-environment-pair"
contexts:
- "example-context-a"
- "example-context-b"
# Top-level grouping the tool starts from (e.g. network boundary such as
# internal/external-facing clusters). This is the outermost navigation
# level; press Esc from the team-selection screen to get here.
contexts:
- "internal"
- "external"
# The namespace label key used to group namespaces by team/ownership in the
# team-selection screen. Adjust this to whatever label your organization
# actually uses (can contain a domain prefix, e.g. "example.org/team").
# Pre-selected on startup so the tool can jump straight to team selection
# instead of asking for the context every time. Falls back to the first
# entry of 'contexts' if omitted.
default_context: "internal"
# Environments switchable from the control panel (e.g. "1) beta" /
# "2) prod"). The first two entries are also used for the two k9s status
# panes shown side by side.
envs:
- "beta"
- "prod"
# AWS region used for all AWS Secrets Manager calls.
aws_region: "eu-central-1"
# AWS account ID, used to fill the {account_id} placeholder below.
# 123456789012 is a placeholder, not a real account.
aws_account_id: "123456789012"
# Builds the AWS Secrets Manager secret ID from the chosen namespace and
# environment. Available placeholders: {namespace}, {env}.
secret_name_template: "tf-{namespace}-{env}-secrets"
# Builds the Kubernetes secret name from the chosen namespace. Kept as a
# separate template from secret_name_template above because the AWS side
# and the Kubernetes side commonly follow different naming conventions
# (e.g. the Kubernetes secret is per-namespace only, without an env
# segment, because each environment already has its own cluster).
# Available placeholders: {namespace}.
k8s_secret_name_template: "{namespace}-common-secrets"
# Builds the actual kubectl context name/ARN from region, account ID, env,
# and context. Available placeholders: {region}, {account_id}, {env},
# {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN
# shape) to match how your own EKS clusters/contexts are actually named.
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
# The namespace label key used to group namespaces by team/ownership in
# the team-selection screen. Adjust this to whatever label your
# organization actually uses (can contain a domain prefix, e.g.
# "example.org/team").
team_label_key: "example.org/team"
# Command used to (re-)authenticate with AWS before the Secrets workflow,
# if 'aws sts get-caller-identity' fails (e.g. an expired AWS SSO session).
# Defaults to "aws sso login" if omitted. Override this if your organization
# wraps SSO login in a custom script or needs a specific --profile, e.g.:
# aws_sso_login_command: "aws sso login --profile my-profile"
aws_sso_login_command: "aws sso login"
+21 -1
View File
@@ -5,6 +5,26 @@ go 1.22
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/lipgloss v1.0.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.4.2 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+49
View File
@@ -0,0 +1,49 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+74
View File
@@ -0,0 +1,74 @@
# Install script for kctl-tui on Windows.
# Downloads the latest GitHub release binary matching the current architecture
# and installs it to your PATH.
#
# Usage (PowerShell):
# irm https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.ps1 | iex
#
# Or save and run locally:
# .\install.ps1
#
# Requires: PowerShell 5.1+, internet access.
$ErrorActionPreference = "Stop"
$Repo = "skoelle/kctl-tui"
$BinName = "kctl-tui"
# --- Detect architecture ---
$arch = $env:PROCESSOR_ARCHITECTURE
switch ($arch) {
"AMD64" { $goarch = "amd64" }
"ARM64" { $goarch = "arm64" }
default {
Write-Error "Unsupported architecture: $arch"
exit 1
}
}
# --- Determine install directory ---
$installDir = "$env:USERPROFILE\bin"
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir | Out-Null
}
# Add to PATH if not already there
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$installDir*") {
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$installDir", "User")
$env:Path = "$env:Path;$installDir"
Write-Host "Added $installDir to your PATH."
}
# --- Query GitHub API for latest release ---
Write-Host "Detecting latest release for $Repo ..."
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
} catch {
Write-Error "Failed to reach the GitHub API (network error). Check your internet connection and try again."
exit 1
}
$tag = $release.tag_name
if (-not $tag) {
Write-Error "Could not find a published release for $Repo. No release has been tagged yet."
exit 1
}
Write-Host "Latest release: $tag"
# --- Download binary ---
$asset = "kctl-tui-windows-${goarch}.exe"
$url = "https://github.com/$Repo/releases/download/$tag/$asset"
$outFile = "$installDir\$BinName.exe"
Write-Host "Downloading $asset ($tag) ..."
try {
Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing
} catch {
Write-Error "Download failed: $_"
exit 1
}
Write-Host "Installed $BinName to $outFile"
Write-Host "Done. Run '$BinName' to get started."
+42 -4
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
# Install script for kctl-tui.
# Downloads the latest GitHub release binary matching the current OS/arch
# and installs it to /usr/local/bin (or $INSTALL_DIR if set).
@@ -37,13 +39,50 @@ case "$os" in
esac
echo "Detecting latest release for $REPO ..."
latest_tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
if [ -z "$latest_tag" ]; then
echo "Could not determine latest release tag. Is there at least one published release?" >&2
release_json="$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest")" || {
echo "Failed to reach the GitHub API (network error). Check your internet connection and try again." >&2
exit 1
}
echo "Received ${#release_json} bytes from the GitHub API."
if echo "$release_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then
echo "" >&2
echo "Could not find a published release for ${REPO}." >&2
echo "This usually means no release has been tagged yet." >&2
echo "" >&2
echo "Options:" >&2
echo " 1) Ask the maintainer to push a version tag (e.g. 'git tag v0.1.0 && git push origin v0.1.0')," >&2
echo " which triggers the release build via GitHub Actions." >&2
echo " 2) Build from source instead:" >&2
echo " git clone https://github.com/${REPO}.git" >&2
echo " cd $(basename "$REPO")" >&2
echo " go build -o ${BIN_NAME} ./cmd/${BIN_NAME}" >&2
echo " sudo mv ${BIN_NAME} ${INSTALL_DIR}/" >&2
exit 1
fi
if echo "$release_json" | grep -qi 'rate limit exceeded'; then
echo "GitHub API rate limit exceeded. Wait a bit and try again, or authenticate with a GitHub token." >&2
exit 1
fi
# Note: '|| true' below prevents 'set -o pipefail' + 'set -e' from aborting the
# script silently if grep finds no match; the emptiness check right after
# gives a proper diagnostic instead.
latest_tag="$(echo "$release_json" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" || true
if [ -z "$latest_tag" ]; then
echo "Could not parse the latest release tag from the GitHub API response." >&2
echo "Raw response (truncated):" >&2
echo "$release_json" | head -c 800 >&2
echo "" >&2
exit 1
fi
echo "Latest release tag: ${latest_tag}"
asset="kctl-tui-${os}-${arch}"
url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}"
@@ -60,5 +99,4 @@ else
fi
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}"
"${INSTALL_DIR}/${BIN_NAME}" --help >/dev/null 2>&1 || true
echo "Done. Run '${BIN_NAME}' to get started."
+111 -6
View File
@@ -1,5 +1,8 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
// Package config loads the user-specific, non-versioned kctl-tui
// configuration (context pairs, team label key) from a YAML file.
// configuration (contexts, envs, templates) from a YAML file.
package config
import (
@@ -11,10 +14,111 @@ import (
"github.com/skoelle/kctl-tui/internal/kctl"
)
// DefaultAWSSSOLoginCommand is used when the user has not configured a
// custom login command in their config.yaml.
const DefaultAWSSSOLoginCommand = "aws sso login"
// Config is the root structure of ~/.kctl-tui/config.yaml
type Config struct {
ContextPairs []kctl.ContextPair `yaml:"context_pairs"`
TeamLabelKey string `yaml:"team_label_key"`
// Contexts are the top-level groupings the tool starts from, e.g.
// "internal"/"external". This is the outermost navigation level.
Contexts []string `yaml:"contexts"`
// DefaultContext is pre-selected on startup so the tool can jump
// straight to team selection; falls back to the first entry of
// Contexts if empty.
DefaultContext string `yaml:"default_context"`
// Envs are the environments switchable from the control panel, e.g.
// "beta"/"prod". The first two entries are used for the two k9s
// status panes.
Envs []string `yaml:"envs"`
// AWSRegion is used for all AWS Secrets Manager calls.
AWSRegion string `yaml:"aws_region"`
// AWSAccountID fills the {account_id} placeholder in ContextTemplate.
AWSAccountID string `yaml:"aws_account_id"`
// SecretNameTemplate builds the AWS Secrets Manager secret ID from a
// namespace and env, e.g. "tf-{namespace}-{env}-secrets".
SecretNameTemplate string `yaml:"secret_name_template"`
// K8sSecretNameTemplate builds the Kubernetes secret name from a
// namespace, e.g. "{namespace}-common-secrets". Kept separate from
// SecretNameTemplate because the two sides commonly follow different
// naming conventions.
K8sSecretNameTemplate string `yaml:"k8s_secret_name_template"`
// ContextTemplate builds the actual kubectl context name/ARN from
// region, account_id, env, and context, e.g.
// "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1".
ContextTemplate string `yaml:"context_template"`
// TeamLabelKey is the namespace label used to group namespaces by
// team/ownership in the team-selection screen.
TeamLabelKey string `yaml:"team_label_key"`
// AWSSSOLoginCommand is run interactively if an AWS auth check fails
// before the secrets workflow (e.g. an expired SSO session).
AWSSSOLoginCommand string `yaml:"aws_sso_login_command"`
}
// LoginCommand returns the configured AWS SSO login command, falling back
// to DefaultAWSSSOLoginCommand if none is set.
func (c Config) LoginCommand() string {
if c.AWSSSOLoginCommand == "" {
return DefaultAWSSSOLoginCommand
}
return c.AWSSSOLoginCommand
}
// EffectiveDefaultContext returns DefaultContext if set, otherwise the
// first entry of Contexts, otherwise an empty string.
func (c Config) EffectiveDefaultContext() string {
if c.DefaultContext != "" {
return c.DefaultContext
}
if len(c.Contexts) > 0 {
return c.Contexts[0]
}
return ""
}
// ResolveContext builds the actual kubectl context name/ARN for a given
// env + context (e.g. "beta" + "internal") using ContextTemplate.
func (c Config) ResolveContext(env, context string) string {
return kctl.ResolveTemplate(c.ContextTemplate, map[string]string{
"region": c.AWSRegion,
"account_id": c.AWSAccountID,
"env": env,
"context": context,
})
}
// ResolveSecretName builds the AWS Secrets Manager secret ID for a given
// namespace + env using SecretNameTemplate.
func (c Config) ResolveSecretName(namespace, env string) string {
return kctl.ResolveTemplate(c.SecretNameTemplate, map[string]string{
"namespace": namespace,
"env": env,
})
}
// ResolveK8sSecretName builds the Kubernetes secret name for a given
// namespace using K8sSecretNameTemplate. Falls back to
// SecretNameTemplate resolved without an env placeholder if
// K8sSecretNameTemplate is not configured, so existing configs keep
// working, though setting it explicitly is recommended since the two
// naming conventions usually differ.
func (c Config) ResolveK8sSecretName(namespace string) string {
template := c.K8sSecretNameTemplate
if template == "" {
template = c.SecretNameTemplate
}
return kctl.ResolveTemplate(template, map[string]string{
"namespace": namespace,
})
}
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
@@ -27,9 +131,10 @@ func DefaultPath() (string, error) {
}
// Load reads and parses the config file at path. If the file does not
// exist, it returns a zero-value Config (no pairs, empty label key) and no
// error, so the tool can run with sane defaults before the user has set up
// a config file.
// exist, it returns a zero-value Config and no error, so the tool can
// still start (with an explanatory error surfaced later where a required
// field turns out to be missing) before the user has set up a config
// file.
func Load(path string) (Config, error) {
cfg := Config{}
+91 -10
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package config
import (
@@ -11,19 +14,25 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.ContextPairs) != 0 {
t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs)
}
if cfg.TeamLabelKey != "" {
t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey)
if len(cfg.Contexts) != 0 || len(cfg.Envs) != 0 {
t.Fatalf("expected no contexts/envs, got %+v", cfg)
}
}
func TestLoad_ValidFile(t *testing.T) {
content := []byte(`
context_pairs:
- name: "env-pair-1"
contexts: ["ctx-a", "ctx-b"]
contexts:
- "internal"
- "external"
default_context: "internal"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
team_label_key: "example.org/team"
`)
path := filepath.Join(t.TempDir(), "config.yaml")
@@ -38,7 +47,79 @@ team_label_key: "example.org/team"
if cfg.TeamLabelKey != "example.org/team" {
t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey)
}
if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" {
t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs)
if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 {
t.Fatalf("unexpected contexts/envs: %+v", cfg)
}
if cfg.K8sSecretNameTemplate != "{namespace}-common-secrets" {
t.Fatalf("unexpected k8s secret name template: %q", cfg.K8sSecretNameTemplate)
}
}
func TestEffectiveDefaultContext(t *testing.T) {
cfg := Config{Contexts: []string{"internal", "external"}}
if got := cfg.EffectiveDefaultContext(); got != "internal" {
t.Fatalf("expected first context as fallback default, got %q", got)
}
cfg.DefaultContext = "external"
if got := cfg.EffectiveDefaultContext(); got != "external" {
t.Fatalf("expected explicit default_context to win, got %q", got)
}
empty := Config{}
if got := empty.EffectiveDefaultContext(); got != "" {
t.Fatalf("expected empty string when no contexts configured, got %q", got)
}
}
func TestResolveContext(t *testing.T) {
cfg := Config{
AWSRegion: "eu-central-1",
AWSAccountID: "123456789012",
ContextTemplate: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1",
}
got := cfg.ResolveContext("beta", "internal")
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveSecretName(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveSecretName("example-ns", "beta")
want := "tf-example-ns-beta-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveK8sSecretName_ExplicitTemplate(t *testing.T) {
cfg := Config{K8sSecretNameTemplate: "{namespace}-common-secrets"}
got := cfg.ResolveK8sSecretName("example-ns")
want := "example-ns-common-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveK8sSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveK8sSecretName("example-ns")
want := "tf-example-ns-{env}-secrets" // {env} intentionally left unresolved here
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestLoginCommand_DefaultsWhenUnset(t *testing.T) {
cfg := Config{}
if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand {
t.Fatalf("got %q, want default %q", got, DefaultAWSSSOLoginCommand)
}
cfg.AWSSSOLoginCommand = "aws sso login --profile custom"
if got := cfg.LoginCommand(); got != "aws sso login --profile custom" {
t.Fatalf("expected custom login command to be used, got %q", got)
}
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "sort"
// SecretDiffEntry represents the comparison of one key between two secret
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
type SecretDiffEntry struct {
Key string
Left string // e.g. the AWS Secrets Manager value
Right string // e.g. the decoded Kubernetes secret value
Match bool
LeftBin bool // true if Left contains non-printable (binary) data
RightBin bool // true if Right contains non-printable (binary) data
}
// IsBinary reports whether s contains non-printable bytes (i.e. is likely
// binary data rather than human-readable text). Control characters below
// space (0x20) are excluded, except for common whitespace (\t, \n, \r).
func IsBinary(s string) bool {
for _, r := range s {
if r > 0x7f {
return true
}
if r < 0x20 && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
}
// DiffSecretValues compares two key/value maps and returns a sorted list of
// diff entries covering the union of keys present in either map. A key that
// only exists on one side is still reported, with the missing side left as
// an empty string and Match set to false (unless both sides happen to be
// empty strings).
func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
seen := map[string]bool{}
for k := range left {
seen[k] = true
}
for k := range right {
seen[k] = true
}
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
result := make([]SecretDiffEntry, 0, len(keys))
for _, k := range keys {
l := left[k]
r := right[k]
lb := IsBinary(l)
rb := IsBinary(r)
match := l == r
if lb || rb {
match = l == r
}
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: match, LeftBin: lb, RightBin: rb})
}
return result
}
// AnyMismatch reports whether at least one diff entry does not match.
func AnyMismatch(entries []SecretDiffEntry) bool {
for _, e := range entries {
if !e.Match {
return true
}
}
return false
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "testing"
func TestDiffSecretValues_AllMatch(t *testing.T) {
left := map[string]string{"a": "1", "b": "2"}
right := map[string]string{"a": "1", "b": "2"}
entries := DiffSecretValues(left, right)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if AnyMismatch(entries) {
t.Fatalf("expected no mismatch, got %v", entries)
}
}
func TestDiffSecretValues_Mismatch(t *testing.T) {
left := map[string]string{"a": "1", "b": "2"}
right := map[string]string{"a": "1", "b": "different"}
entries := DiffSecretValues(left, right)
if !AnyMismatch(entries) {
t.Fatalf("expected a mismatch, got %v", entries)
}
var bEntry *SecretDiffEntry
for i := range entries {
if entries[i].Key == "b" {
bEntry = &entries[i]
}
}
if bEntry == nil || bEntry.Match {
t.Fatalf("expected key 'b' to be a mismatch, got %v", bEntry)
}
}
func TestDiffSecretValues_KeyOnlyOnOneSide(t *testing.T) {
left := map[string]string{"a": "1", "only-left": "x"}
right := map[string]string{"a": "1", "only-right": "y"}
entries := DiffSecretValues(left, right)
if len(entries) != 3 {
t.Fatalf("expected 3 entries (union of keys), got %d: %v", len(entries), entries)
}
if !AnyMismatch(entries) {
t.Fatalf("expected mismatch due to keys only present on one side")
}
}
func TestDiffSecretValues_EmptyMaps(t *testing.T) {
entries := DiffSecretValues(nil, nil)
if len(entries) != 0 {
t.Fatalf("expected no entries for empty maps, got %v", entries)
}
if AnyMismatch(entries) {
t.Fatalf("expected no mismatch for empty maps")
}
}
func TestIsBinary_Plaintext(t *testing.T) {
if IsBinary("hello world") {
t.Fatal("expected plaintext to not be binary")
}
if IsBinary("line1\nline2\ttab") {
t.Fatal("expected newline/tab to not be binary")
}
}
func TestIsBinary_BinaryData(t *testing.T) {
if !IsBinary("hello\x00world") {
t.Fatal("expected null byte to be binary")
}
if !IsBinary("key=\xff\xfe") {
t.Fatal("expected non-ASCII bytes to be binary")
}
}
func TestDiffSecretValues_BinaryDetection(t *testing.T) {
left := map[string]string{"ok": "text", "bin": "data\x00here"}
right := map[string]string{"ok": "text", "bin": "data\x00here"}
entries := DiffSecretValues(left, right)
for _, e := range entries {
if e.Key == "bin" {
if !e.LeftBin || !e.RightBin {
t.Fatalf("expected binary flags set for key 'bin', got LeftBin=%v RightBin=%v", e.LeftBin, e.RightBin)
}
if !e.Match {
t.Fatalf("expected binary values to match")
}
}
if e.Key == "ok" {
if e.LeftBin || e.RightBin {
t.Fatalf("expected binary flags unset for key 'ok'")
}
}
}
}
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "sort"
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import (
-35
View File
@@ -1,35 +0,0 @@
// Package kctl contains the core, non-interactive logic of kctl-tui.
// Functions here are pure (no kubectl/tmux side effects) so they can be
// unit tested without a live cluster.
package kctl
// ContextPair groups a set of related kubectl contexts that should be
// switchable via TAB while keeping the same namespace (e.g. staging/prod).
type ContextPair struct {
Name string `yaml:"name"`
Contexts []string `yaml:"contexts"`
}
// FindNextContext returns the next context in the same pair/group as
// current, cycling through the group. Returns "", false if current is not
// part of any configured pair.
func FindNextContext(current string, pairs []ContextPair) (string, bool) {
for _, pair := range pairs {
idx := indexOf(pair.Contexts, current)
if idx == -1 {
continue
}
next := pair.Contexts[(idx+1)%len(pair.Contexts)]
return next, true
}
return "", false
}
func indexOf(items []string, target string) int {
for i, v := range items {
if v == target {
return i
}
}
return -1
}
-48
View File
@@ -1,48 +0,0 @@
package kctl
import "testing"
func TestFindNextContext_TwoWayToggle(t *testing.T) {
pairs := []ContextPair{
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
}
next, ok := FindNextContext("ctx-a", pairs)
if !ok || next != "ctx-b" {
t.Fatalf("expected ctx-b, got %q (ok=%v)", next, ok)
}
next, ok = FindNextContext("ctx-b", pairs)
if !ok || next != "ctx-a" {
t.Fatalf("expected ctx-a, got %q (ok=%v)", next, ok)
}
}
func TestFindNextContext_Rotation(t *testing.T) {
pairs := []ContextPair{
{Name: "rotation", Contexts: []string{"a", "b", "c"}},
}
next, ok := FindNextContext("c", pairs)
if !ok || next != "a" {
t.Fatalf("expected wraparound to a, got %q (ok=%v)", next, ok)
}
}
func TestFindNextContext_NotConfigured(t *testing.T) {
pairs := []ContextPair{
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
}
_, ok := FindNextContext("unrelated-context", pairs)
if ok {
t.Fatalf("expected ok=false for a context with no configured pair")
}
}
func TestFindNextContext_NoPairsConfigured(t *testing.T) {
_, ok := FindNextContext("ctx-a", nil)
if ok {
t.Fatalf("expected ok=false when no pairs are configured")
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "strings"
// ResolveTemplate replaces "{key}" placeholders in template with the
// corresponding value from values. Placeholders with no matching key are
// left untouched, so a misconfigured template is visible (e.g. a literal
// "{typo}" in the result) instead of silently collapsing to an empty
// string.
func ResolveTemplate(template string, values map[string]string) string {
result := template
for k, v := range values {
result = strings.ReplaceAll(result, "{"+k+"}", v)
}
return result
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "testing"
func TestResolveTemplate_SinglePlaceholder(t *testing.T) {
got := ResolveTemplate("secret-{namespace}", map[string]string{"namespace": "example-ns"})
want := "secret-example-ns"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_MultiplePlaceholders(t *testing.T) {
template := "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
values := map[string]string{
"region": "eu-central-1",
"account_id": "123456789012",
"env": "beta",
"context": "internal",
}
got := ResolveTemplate(template, values)
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_UnknownPlaceholderLeftAsIs(t *testing.T) {
got := ResolveTemplate("secret-{unknown}", map[string]string{"namespace": "example-ns"})
want := "secret-{unknown}"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_EmptyTemplate(t *testing.T) {
got := ResolveTemplate("", map[string]string{"namespace": "example-ns"})
if got != "" {
t.Fatalf("expected empty result, got %q", got)
}
}
+107 -59
View File
@@ -1,7 +1,16 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
// Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui.
// All functions here have side effects (they run external processes) and
// are therefore not covered by unit tests; the pure logic they depend on
// lives in the kctl package instead.
//
// Every kubectl-related function takes an explicit context argument
// (passed as --context) instead of relying on/mutating the globally
// active kubectl context. This lets the panel act on multiple resolved
// contexts (e.g. beta and prod) without switching global state back and
// forth.
package kubeexec
import (
@@ -13,53 +22,35 @@ import (
)
func runOutput(name string, args ...string) (string, error) {
logCmd(name, args...)
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return "", fmt.Errorf("%s %s failed: %w\n%s", name, strings.Join(args, " "), err, string(out))
logErr(name, err)
prefix := fmt.Sprintf("%s %s failed: %v", name, strings.Join(args, " "), err)
msg := prefix
if s := strings.TrimSpace(stderr.String()); s != "" {
msg += "\n" + s
}
if s := strings.TrimSpace(stdout.String()); s != "" {
msg += "\n" + s
}
return "", fmt.Errorf("%s", msg)
}
return strings.TrimSpace(string(out)), nil
out := strings.TrimSpace(stdout.String())
logOutput(name, out)
return out, nil
}
// GetContexts returns all configured kubectl context names.
func GetContexts() ([]string, error) {
out, err := runOutput("kubectl", "config", "get-contexts", "-o", "name")
if err != nil {
return nil, err
// kubectlArgs prepends a --context flag when context is non-empty.
func kubectlArgs(context string, args ...string) []string {
if context == "" {
return args
}
if out == "" {
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
// GetCurrentContext returns the currently active kubectl context, or an
// empty string if none is set.
func GetCurrentContext() string {
out, _ := runOutput("kubectl", "config", "current-context")
return out
}
// GetCurrentNamespace returns the namespace bound to the current context,
// defaulting to "default" if unset.
func GetCurrentNamespace() string {
out, _ := runOutput("kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}")
if out == "" {
return "default"
}
return out
}
// UseContext switches the active kubectl context.
func UseContext(ctx string) error {
_, err := runOutput("kubectl", "config", "use-context", ctx)
return err
}
// SetNamespace binds a namespace to the current kubectl context.
func SetNamespace(ns string) error {
_, err := runOutput("kubectl", "config", "set-context", "--current", "--namespace="+ns)
return err
return append([]string{"--context", context}, args...)
}
type nsItem struct {
@@ -74,9 +65,10 @@ type nsList struct {
}
// GetNamespacesWithLabels returns a map of namespace name -> labels for all
// namespaces visible in the current context.
func GetNamespacesWithLabels() (map[string]map[string]string, error) {
out, err := runOutput("kubectl", "get", "ns", "-o", "json")
// namespaces visible in the given context.
func GetNamespacesWithLabels(context string) (map[string]map[string]string, error) {
args := kubectlArgs(context, "get", "ns", "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
@@ -91,10 +83,11 @@ func GetNamespacesWithLabels() (map[string]map[string]string, error) {
return result, nil
}
// GetDeployments lists deployment names in the given namespace.
func GetDeployments(namespace string) ([]string, error) {
out, err := runOutput("kubectl", "-n", namespace, "get", "deploy",
// GetDeployments lists deployment names in the given context/namespace.
func GetDeployments(context, namespace string) ([]string, error) {
args := kubectlArgs(context, "-n", namespace, "get", "deploy",
"-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`)
out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
@@ -105,20 +98,40 @@ func GetDeployments(namespace string) ([]string, error) {
}
// RolloutRestart triggers a rolling restart of a deployment.
func RolloutRestart(namespace, deployment string) (string, error) {
return runOutput("kubectl", "-n", namespace, "rollout", "restart", "deploy/"+deployment)
func RolloutRestart(context, namespace, deployment string) (string, error) {
args := kubectlArgs(context, "-n", namespace, "rollout", "restart", "deploy/"+deployment)
return runOutput("kubectl", args...)
}
// RolloutStatus waits for and returns the rollout status of a deployment.
func RolloutStatus(namespace, deployment string) (string, error) {
return runOutput("kubectl", "-n", namespace, "rollout", "status", "deploy/"+deployment)
func RolloutStatus(context, namespace, deployment string) (string, error) {
args := kubectlArgs(context, "-n", namespace, "rollout", "status", "deploy/"+deployment)
return runOutput("kubectl", args...)
}
// GetSecretValueBase64 returns the raw (still base64-encoded) value of a
// single field in a Kubernetes secret.
func GetSecretValueBase64(namespace, secretName, field string) (string, error) {
path := fmt.Sprintf("jsonpath={.data.%s}", field)
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path)
// GetSecretAllFields returns all fields of a Kubernetes secret, already
// base64-decoded into plain values.
func GetSecretAllFields(context, namespace, secretName string) (map[string]string, error) {
args := kubectlArgs(context, "-n", namespace, "get", "secret", secretName, "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
var parsed struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
return nil, err
}
result := make(map[string]string, len(parsed.Data))
for k, v := range parsed.Data {
decoded, err := DecodeBase64(v)
if err != nil {
return nil, fmt.Errorf("failed to decode field %q: %w", k, err)
}
result[k] = decoded
}
return result, nil
}
// DecodeBase64 decodes a base64-encoded Kubernetes secret value.
@@ -132,16 +145,51 @@ func DecodeBase64(value string) (string, error) {
// AnnotateForceSync sets the force-sync annotation on an ExternalSecret
// object to trigger an immediate re-sync from the upstream secret store.
func AnnotateForceSync(namespace, externalSecretName string, unixTimestamp int64) (string, error) {
func AnnotateForceSync(context, namespace, externalSecretName string, unixTimestamp int64) (string, error) {
annotation := fmt.Sprintf("force-sync=%d", unixTimestamp)
return runOutput("kubectl", "-n", namespace, "annotate", "externalsecret",
args := kubectlArgs(context, "-n", namespace, "annotate", "externalsecret",
externalSecretName, annotation, "--overwrite")
return runOutput("kubectl", args...)
}
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
// secret via the aws-cli.
// secret via the aws-cli. The secret ID is computed from config templates
// (see internal/config), not looked up interactively.
func GetAWSSecretString(secretID, region string) (string, error) {
return runOutput("aws", "secretsmanager", "get-secret-value",
"--secret-id", secretID, "--region", region,
"--query", "SecretString", "--output", "text")
}
// CheckAWSAuth performs a cheap, fast call to verify the current AWS
// credentials/SSO session are valid. Returns nil if authenticated, or the
// underlying error (e.g. an expired SSO session) otherwise.
func CheckAWSAuth() error {
_, err := runOutput("aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
return err
}
// RunAWSLogin returns an *exec.Cmd for the given login command (e.g.
// "aws sso login"), split on whitespace. The caller is responsible for
// running it interactively (e.g. via tea.ExecProcess) since SSO login
// typically requires opening a browser and confirming a device code.
func RunAWSLogin(loginCommand string) *exec.Cmd {
parts := strings.Fields(loginCommand)
if len(parts) == 0 {
parts = []string{"aws", "sso", "login"}
}
return exec.Command(parts[0], parts[1:]...)
}
// CheckTool verifies that a named executable is available in PATH.
// Returns nil if found, or a descriptive error if not.
func CheckTool(name string) error {
_, err := exec.LookPath(name)
if err == nil {
return nil
}
if name == "tmux" {
return fmt.Errorf("%q not found in PATH — install tmux (Linux/macOS) or psmux (Windows: scoop install psmux or cargo install psmux)", name)
}
return fmt.Errorf("%q not found in PATH — please install it first", name)
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kubeexec
import (
"fmt"
"io"
"strings"
"sync"
)
var (
verbose bool
logOut io.Writer = io.Discard
mu sync.Mutex
)
// SetVerbose enables or disables debug logging of executed commands.
// When enabled, commands and their outputs are written to the provided
// writer (typically os.Stderr). When disabled (the default), all logging
// is discarded.
func SetVerbose(enabled bool, w io.Writer) {
mu.Lock()
defer mu.Unlock()
verbose = enabled
if w != nil {
logOut = w
}
}
// VerboseLog writes a message to the verbose log if enabled.
func VerboseLog(format string, args ...interface{}) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, format, args...)
}
func logCmd(name string, args ...string) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, "[cmd] %s %s\n", name, strings.Join(args, " "))
}
func logOutput(name string, output string) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
if output != "" {
fmt.Fprintf(logOut, "[out] %s: %s\n", name, output)
}
}
func logErr(name string, err error) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, "[err] %s: %v\n", name, err)
}