Compare commits

..
25 Commits
Author SHA1 Message Date
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
23 changed files with 1424 additions and 518 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
ext="" ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
out="dist/kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}${ext}" 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" echo "Built $out"
- name: Upload artifact - name: Upload artifact
+2 -2
View File
@@ -1,8 +1,8 @@
# Binaries # Binaries
/bin/ /bin/
/dist/ /dist/
kctl-tui /kctl-tui
kctl-tui.exe /kctl-tui.exe
# Go # Go
*.test *.test
+39 -23
View File
@@ -10,61 +10,77 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
attaches binaries to GitHub Releases on version tags. attaches binaries to GitHub Releases on version tags.
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release - [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`. - [x] `README.md`, `config.example.yaml`.
## Phase 1 — Core logic + navigation (done, initial version) ## Phase 1 — Core logic + navigation (done, initial version)
- [x] `internal/kctl`: pure, unit-tested logic — - [x] `internal/kctl`: pure, unit-tested logic —
context-pair matching (`FindNextContext`) and namespace/label template resolution (`ResolveTemplate`), namespace/label filtering
filtering (`DistinctLabelValues`, `NamespacesForLabelValue`). (`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
- [x] `internal/config`: YAML config loading (`context_pairs`, (`DiffSecretValues`, `AnyMismatch`).
`team_label_key`), with safe defaults when no config file exists yet. - [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 - [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
(contexts, namespaces, deployments, rollout restart/status, secret (namespaces, deployments, rollout restart/status, fetching AWS
read, ExternalSecret annotation). 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 - [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
context -> team -> namespace, with `Esc` correctly popping back one context -> team -> namespace, with `Esc` correctly popping back one
level at a time, defaults pre-selected from the currently active level at a time, defaults pre-selected from the currently active
context/namespace. context/namespace.
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux` - [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
session (control pane + two `k9s` panes) via `tea.ExecProcess` and session (control pane + two `k9s` panes, `even-vertical` layout,
resumes at the namespace screen once the session ends. `remain-on-exit` so a crashing control pane stays visible) via
- [x] `cmd/kctl-tui` "panel" mode: menu for Redeploy and the AWS/Kubernetes `tea.ExecProcess` and resumes at the namespace screen once the
secrets diff + force-sync wizard, with `Esc` closing the whole tmux session ends.
session (`tmux kill-session`). - [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) ## Phase 2 — Hardening (open)
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value - [ ] Handle non-JSON AWS secrets and Kubernetes secrets with binary
formatting with a proper typed decode, and handle secrets that are (non-UTF8) values more gracefully in the diff table (currently
plain strings rather than JSON. falls back to a single "value" key or may render oddly).
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in - [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
CI for the `kubeexec` wrappers currently excluded from automated CI for the `kubeexec` wrappers currently excluded from automated
testing. testing.
- [ ] Input validation for the free-text steps in "panel" mode (empty - [ ] 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 - [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
(currently surfaces the raw exec error). (currently surfaces the raw exec error).
- [ ] Structured logging / `--verbose` flag for troubleshooting failed - [ ] 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 (open, secondary priority)
- [ ] Detect OS at runtime; on native Windows (no WSL), fall back to - [ ] Detect OS at runtime; on native Windows (no WSL), fall back to
`wt.exe split-pane` instead of `tmux` for the status panes. `wt.exe split-pane` instead of `tmux` for the status panes.
- [ ] Document/implement that `Tab`-based context switching and - [ ] Document/implement that `Esc`-triggered session close is **not**
`Esc`-triggered session close are **not** available in the native available in the native Windows fallback — the panes must be closed
Windows fallback, per SPEC.md 3.6 — the panes must be closed
manually there. manually there.
## Phase 4 — Nice-to-haves (open, not committed) ## 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 - [ ] Optional direct use of `client-go` instead of shelling out to
`kubectl`, for faster context/namespace/label queries. `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 - [ ] Homebrew tap / `scoop` manifest as additional install options
alongside `install.sh`. alongside `install.sh`.
+104 -49
View File
@@ -1,27 +1,35 @@
# kctl-tui # kctl-tui
A small terminal entry point for everyday Kubernetes work: pick a context, A small terminal entry point for everyday Kubernetes work: pick a context
a team, and a namespace once, then drive status (via k9s), rollout and a namespace once, then drive rollout restarts and an AWS Secrets
restarts, and an AWS Secrets Manager <-> Kubernetes Secret diff/force-sync Manager <-> Kubernetes Secret diff/force-sync per environment from one
workflow from one place instead of retyping long `kubectl` commands. place instead of retyping long `kubectl` commands.
## Why ## Why
Working with several clusters, many namespaces per team, and paired Working with several clusters, many namespaces per team, and paired
environments (e.g. staging/production) quickly turns into a lot of repeated environments (e.g. beta/prod) quickly turns into a lot of repeated typing
typing with plain `kubectl`/`k9s`. kctl-tui adds: with plain `kubectl`/`k9s`. kctl-tui adds:
- A guided **context -> team -> namespace** selection with sensible - A guided **context -> team -> namespace** selection that starts
defaults (the currently active context/namespace is pre-selected). 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 - Namespace grouping by an arbitrary, configurable **label** instead of
scrolling through every namespace in the cluster. scrolling through every namespace in the cluster.
- A **3-pane view** (via `tmux`): one control pane for actions, two status - A **3-pane view** (via `tmux`): one control pane for actions, two status
panes running `k9s` for the current namespace across two related panes running `k9s` for the current namespace across your two
contexts. configured environments (e.g. beta/prod), shown side by side.
- A guided **rollout restart** that lists deployments instead of requiring - A control-pane menu organized **by environment**: pick beta or prod,
you to know/type the exact deployment name. then Secrets sync or Redeploy for that environment specifically.
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison, - AWS Secrets Manager secret IDs and Kubernetes context names/ARNs are
including an optional ExternalSecret force-sync annotation. **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 See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
[PLAN.md](PLAN.md) for the implementation roadmap and current status. [PLAN.md](PLAN.md) for the implementation roadmap and current status.
@@ -31,38 +39,41 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
``` ```
+--------------------------------------------------+ +--------------------------------------------------+
| Control pane: kctl-tui panel | | 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 1. Run `kctl-tui`. It loads your config, applies the default context, and
selection. jumps straight to team selection; press `Esc` there to pick a
2. Once a namespace is confirmed, it opens a `tmux` session with the layout different context first.
above and attaches to it. 2. Pick a team (namespace label filter), then a namespace.
3. Inside the control pane you can trigger a rollout restart or compare/ 3. It opens a `tmux` session with the layout above: the control pane runs
force-sync a secret. The two status panes keep showing live pod state this binary in "panel" mode, the two status panes run `k9s` against
via `k9s`, so there is no separate "status" menu entry. your first two configured environments (e.g. beta and prod), resolved
4. Pressing `Esc` in the control pane closes the whole `tmux` session from `context_template`.
(including both `k9s` panes) and returns you to the namespace 4. In the control pane, pick an environment, then Secrets sync or
selection. Redeploy for that environment. `Esc` goes back one level (action menu
5. Pressing `Tab` in the control pane switches both status panes to the -> environment menu -> closes the whole tmux session, including both
paired context configured in `context_pairs` (see Configuration), `k9s` panes, and returns you to namespace selection).
keeping the same namespace.
## Requirements ## 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). - `k9s` (used for the two status panes).
- `tmux` (used for the 3-pane layout). On Windows, this means running - `tmux` (used for the 3-pane layout). On Windows, this means running
kctl-tui inside **WSL**`tmux` has no native Windows port. Native kctl-tui inside **WSL**`tmux` has no native Windows port. Native
Windows Terminal has its own split-pane feature, but it cannot be 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 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 layout and the `Esc` session handling described above are only fully
fully supported under Linux/WSL. See SPEC.md section 3.6 for details. supported under Linux/WSL. See SPEC.md section 3.6 for details.
- `aws` CLI, configured with credentials, only needed for the secrets - `aws` CLI, configured with credentials, only needed for the secrets
workflow. workflow.
@@ -98,29 +109,63 @@ matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/rele
## Configuration ## Configuration
Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml` 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 ```yaml
context_pairs: contexts:
- name: "example-environment-pair" - "internal"
contexts: - "external"
- "example-context-a" default_context: "internal"
- "example-context-b"
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" team_label_key: "example.org/team"
aws_sso_login_command: "aws sso login"
``` ```
- `context_pairs`: groups of related `kubectl` contexts. `Tab` in the - `contexts` / `default_context`: the top-level grouping the tool starts
control pane cycles through the contexts of whichever group the current from (e.g. a network boundary such as internal/external-facing
context belongs to. 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 - `team_label_key`: the Kubernetes namespace label used to group
namespaces by team/ownership in the team-selection screen. This is namespaces by team/ownership in the team-selection screen. This is
entirely up to your organization's labeling convention; kctl-tui ships entirely up to your organization's labeling convention; kctl-tui ships
with no default team label of its own. 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 `~/.kctl-tui/config.yaml` is not part of this repository and should stay
that way — it typically contains your organization's internal context and that way — it typically contains your organization's internal account ID,
label names. context naming, and label names.
## WSL setup notes ## WSL setup notes
@@ -133,6 +178,16 @@ mkdir -p ~/.kube
ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config
``` ```
## Usage
```bash
kctl-tui # start the TUI (full navigation mode)
kctl-tui --version # print version
kctl-tui --verbose # enable debug logging to stderr
kctl-tui config check # validate ~/.kctl-tui/config.yaml
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
```
## Development ## Development
```bash ```bash
@@ -141,11 +196,11 @@ go vet ./...
go build ./cmd/kctl-tui go build ./cmd/kctl-tui
``` ```
Pure logic (context-pair matching, label filtering, config parsing) lives Pure logic (template resolution, label filtering, config parsing, secret
in `internal/kctl` and `internal/config` and is covered by unit tests. Code diffing) lives in `internal/kctl` and `internal/config` and is covered by
that shells out to `kubectl`/`aws`/`tmux` lives in `internal/kubeexec` and unit tests. Code that shells out to `kubectl`/`aws`/`tmux` lives in
in `cmd/kctl-tui` and is intentionally kept thin and untested, since it has `internal/kubeexec` and in `cmd/kctl-tui` and is intentionally kept thin
no meaningful behavior without a live cluster. and untested, since it has no meaningful behavior without a live cluster.
## License ## License
+56 -45
View File
@@ -5,7 +5,7 @@
A single terminal tool as the central entry point for everyday Kubernetes A single terminal tool as the central entry point for everyday Kubernetes
work, bundling the most common workflows currently done via long work, bundling the most common workflows currently done via long
`kubectl`/`k9s`/`aws-cli` commands, operable through a text UI (arrow keys, `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 Target platform: **Linux / WSL** (primary usage scenario, since split
panes require a real terminal multiplexer). Native Windows (without WSL) 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 (`tmux kill-session`, closing both k9s panes as well) and then moves the
Go tool's screen stack one level up: namespace selection -> team Go tool's screen stack one level up: namespace selection -> team
selection -> context selection. 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 ### 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 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 - 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). - 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 Once start navigation is complete, the tool opens a tmux session with
**three panes**, started with a single command: **three panes**, started with a single command:
@@ -93,10 +90,12 @@ Example startup command (generic placeholders):
``` ```
tmux new-session -d -s kctl \ tmux new-session -d -s kctl \
"kctl-tui panel --ctx=$CTX_A --ns=$NS --team=$TEAM" \; \ "kctl-tui panel --context=$CTX_A --ns=$NS --team=$TEAM" \; \
split-window -v "k9s --context $CTX_A -n $NS" \; \ set-option -t kctl remain-on-exit on \; \
split-window -v "k9s --context $CTX_B -n $NS" \; \ split-window -v -t kctl:0.0 "k9s --context $CTX_A -n $NS" \; \
select-layout main-horizontal \; \ split-window -v -t kctl:0.1 "k9s --context $CTX_B -n $NS" \; \
select-layout -t kctl even-vertical \; \
select-pane -t kctl:0.0 \; \
attach -t kctl attach -t kctl
``` ```
@@ -115,54 +114,66 @@ Switching between panes: `Ctrl-b` + arrow key, or `Ctrl-b` `o`.
### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane ### 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`. `aws secretsmanager get-secret-value --secret-id <secret-id> --region <region> --query SecretString --output text`.
2. Show the contained keys for selection. 4. Fetch all fields of the Kubernetes secret and base64-decode them:
3. Load the matching Kubernetes secret field: `kubectl -n <ns> get secret <secret-name> -o json`.
`kubectl -n <ns> get secret <secret-name> -o jsonpath='{.data.<field>}'`, 5. Compare every field at once in a table (key / AWS value / Kubernetes
base64-decode it. value / match status).
4. Compare the values (identical / different). 6. On mismatch, optionally request a force-sync for the whole secret:
5. On mismatch, optionally request a force-sync:
`kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`. `kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`.
All names (secret ID, secret name, field name, ExternalSecret name) are The ExternalSecret object name for the force-sync annotation is the only
asked for interactively at runtime, never hardcoded in the tool. 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** The actual kubectl context name/ARN for each environment is computed from
status panes below it, not just an internal state. 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 Configuration format (e.g. `~/.kctl-tui/config.yaml`), purely illustrative
with generic placeholders: with generic placeholders:
```yaml ```yaml
context_pairs: contexts:
- name: "environment-pair-1" - "internal"
contexts: ["<context-a1>", "<context-a2>"] - "external"
- name: "environment-pair-2" default_context: "internal"
contexts: ["<context-b1>", "<context-b2>"]
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>" 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. **Platform limitation on Windows without WSL:** `kill-session` is
2. Restart both k9s panes via tmux-specific. Windows Terminal (`wt.exe`) offers no equivalent scripting
`tmux respawn-pane -k -t kctl:0.1 "k9s --context <newA> -n <ns>"` and to end the session from inside a pane. On plain Windows (without WSL),
`... kctl:0.2 ...` (namespace stays the same). only a simplified flow is possible: k9s panes are closed manually (`q`,
3. If the current context is in no configured list: show a hint in the then `Ctrl+Shift+W`); automatic session termination is unavailable there.
control pane instead of an error. This limitation is the main reason the primary target system is set to
4. No action outside this configuration — no error, only a hint. Linux/WSL.
**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.
## 4. Non-functional requirements ## 4. Non-functional requirements
@@ -240,8 +251,8 @@ Rejected options (see discussion history):
- Split view v1 fixed at 2 status panes + 1 control pane (3 panes total). - Split view v1 fixed at 2 status panes + 1 control pane (3 panes total).
- No RBAC/permission checks before executing sensitive actions — the tool - No RBAC/permission checks before executing sensitive actions — the tool
assumes existing kubectl permissions. assumes existing kubectl permissions.
- Configuration file format (`config.yaml`) is a proposal, not finally - Configuration file format (`config.yaml`) is defined and implemented;
agreed; concrete label keys, context names, and namespace names are concrete label keys, context names, and namespace names are
project-specific and belong exclusively in the user's local, unversioned project-specific and belong exclusively in the user's local, unversioned
configuration, not in this document or the source code. configuration, not in this document or the source code.
- Native Windows (without WSL) remains a secondary platform with manual - Native Windows (without WSL) remains a secondary platform with manual
+90 -52
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os/exec" "os/exec"
"sort"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@@ -20,9 +21,12 @@ const (
screenNamespace screenNamespace
) )
// fullModel drives the interactive context -> team -> namespace navigation // fullModel drives the interactive navigation. It starts directly at the
// and, once a namespace is chosen, launches the 3-pane tmux session // team-selection screen using the configured default context, and only
// (control pane + two k9s panes) via tea.ExecProcess. // 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 { type fullModel struct {
list list.Model list list.Model
state screenState state screenState
@@ -42,48 +46,46 @@ func newFullModel() *fullModel {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "kctl-tui" l.Title = "kctl-tui"
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
return &fullModel{list: l, state: screenContext} return &fullModel{list: l}
} }
func (m *fullModel) Init() tea.Cmd { func (m *fullModel) Init() tea.Cmd {
return m.loadContexts return m.bootstrap
} }
func (m *fullModel) loadContexts() tea.Msg { // bootstrap loads the config and applies the default context so the tool
contexts, err := kubeexec.GetContexts() // 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 { if err != nil {
return errMsg{err} return errMsg{err}
} }
current := kubeexec.GetCurrentContext() if len(cfg.Contexts) == 0 {
return errMsg{fmt.Errorf("no 'contexts' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
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})
} }
for _, c := range contexts { if len(cfg.Envs) == 0 {
if c != current { return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
items = append(items, simpleItem{label: c, value: c})
}
} }
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 { type bootstrapMsg struct {
items []list.Item cfg config.Config
cfg config.Config context string
} }
type contextsLoadedMsg struct{ items []list.Item }
type teamsLoadedMsg struct { type teamsLoadedMsg struct {
items []list.Item items []list.Item
namespaces map[string]map[string]string namespaces map[string]map[string]string
} }
type namespacesLoadedMsg struct { type namespacesLoadedMsg struct{ items []list.Item }
items []list.Item
}
type tmuxDoneMsg struct{ err error } type tmuxDoneMsg struct{ err error }
@@ -100,8 +102,12 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
case contextsLoadedMsg: case bootstrapMsg:
m.cfg = msg.cfg m.cfg = msg.cfg
m.selectedContext = msg.context
return m, m.loadTeams
case contextsLoadedMsg:
m.state = screenContext m.state = screenContext
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)" m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
m.list.SetItems(msg.items) m.list.SetItems(msg.items)
@@ -110,7 +116,7 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case teamsLoadedMsg: case teamsLoadedMsg:
m.namespaces = msg.namespaces m.namespaces = msg.namespaces
m.state = screenTeam 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) m.list.SetItems(msg.items)
return m, nil return m, nil
@@ -157,6 +163,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) { func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
@@ -166,9 +184,6 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
switch m.state { switch m.state {
case screenContext: case screenContext:
m.selectedContext = item.value m.selectedContext = item.value
if err := kubeexec.UseContext(item.value); err != nil {
return m, func() tea.Msg { return errMsg{err} }
}
return m, m.loadTeams return m, m.loadTeams
case screenTeam: case screenTeam:
@@ -177,16 +192,32 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
case screenNamespace: case screenNamespace:
m.selectedNamespace = item.value m.selectedNamespace = item.value
if err := kubeexec.SetNamespace(item.value); err != nil { if err := kubeexec.CheckTool("tmux"); err != nil {
return m, func() tea.Msg { return errMsg{err} } 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, m.startTmuxSession()
} }
return m, nil 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 { func (m *fullModel) loadTeams() tea.Msg {
namespaces, err := kubeexec.GetNamespacesWithLabels() namespaces, err := kubeexec.GetNamespacesWithLabels(m.bootstrapContext())
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -216,6 +247,7 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
for ns := range m.namespaces { for ns := range m.namespaces {
names = append(names, ns) names = append(names, ns)
} }
sort.Strings(names)
} else { } else {
names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue) names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue)
} }
@@ -227,36 +259,42 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
} }
} }
// startTmuxSession builds the 3-pane tmux command (control pane running // startTmuxSession builds the 3-pane tmux command: the control pane runs
// this binary in "panel" mode, plus two k9s status panes) and runs it via // this binary in "panel" mode (letting the user pick an env and an
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal. // action), and the two status panes run k9s against the first two
// // configured envs, resolved via the context template, so both are
// Layout: even-vertical stacks all three panes evenly from top to bottom // visible side by side.
// (control pane, then the two k9s status panes). remain-on-exit keeps a
// pane visible (showing its exit status/output) instead of tmux silently
// closing it if the control pane's process crashes on startup.
func (m *fullModel) startTmuxSession() tea.Cmd { func (m *fullModel) startTmuxSession() tea.Cmd {
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions selfPath := "kctl-tui"
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s", panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam) selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
secondCtx := m.selectedContext envA := m.cfg.Envs[0]
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok { ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
secondCtx = next k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace)
}
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace)
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl", args := []string{
"new-session", "-d", "-s", "kctl",
panelCmd, ";", panelCmd, ";",
"set-option", "-t", "kctl", "remain-on-exit", "on", ";", "set-option", "-t", "kctl", "remain-on-exit", "on", ";",
"split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";", "split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";",
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";", }
if len(m.cfg.Envs) > 1 {
envB := m.cfg.Envs[1]
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
args = append(args,
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";",
)
}
args = append(args,
"select-layout", "-t", "kctl", "even-vertical", ";", "select-layout", "-t", "kctl", "even-vertical", ";",
"select-pane", "-t", "kctl:0.0", ";", "select-pane", "-t", "kctl:0.0", ";",
"attach", "-t", "kctl", "attach", "-t", "kctl",
) )
c := exec.Command("tmux", args...)
return tea.ExecProcess(c, func(err error) tea.Msg { return tea.ExecProcess(c, func(err error) tea.Msg {
return tmuxDoneMsg{err: err} return tmuxDoneMsg{err: err}
}) })
+6 -2
View File
@@ -10,6 +10,10 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
return kctl.NamespacesForLabelValue(namespaces, labelKey, value) return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
} }
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) { func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
return kctl.FindNextContext(current, pairs) return kctl.DiffSecretValues(left, right)
}
func anyMismatch(entries []kctl.SecretDiffEntry) bool {
return kctl.AnyMismatch(entries)
} }
+92 -5
View File
@@ -5,15 +5,50 @@ import (
"os" "os"
tea "github.com/charmbracelet/bubbletea" 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() { func main() {
if len(os.Args) > 1 && os.Args[1] == "panel" { args := os.Args[1:]
if err := runPanel(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err) // Extract global flags before delegating to sub-commands.
os.Exit(1) verbose := 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
default:
filtered = append(filtered, a)
}
}
if verbose {
kubeexec.SetVerbose(true, os.Stderr)
}
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
} }
return
} }
m := newFullModel() m := newFullModel()
@@ -23,3 +58,55 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
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
}
+324 -165
View File
@@ -6,95 +6,137 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" 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" "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 type panelStep int
const ( const (
stepMenu panelStep = iota stepEnvMenu panelStep = iota
stepActionMenu
stepRedeployList stepRedeployList
stepRedeployConfirm stepRedeployConfirm
stepSecretID stepAWSAuthPrompt
stepSecretRegion
stepSecretKeyList
stepK8sSecretName
stepK8sFieldName
stepDiffResult stepDiffResult
stepForceSyncConfirm stepForceSyncConfirm
stepExternalSecretName stepExternalSecretName
stepDone stepDone
stepError
) )
type panelModel struct { type panelModel struct {
ctx, ns, team string context, ns, team string
cfg config.Config
step panelStep currentEnv string
list list.Model
step panelStep
list list.Model
input textinput.Model input textinput.Model
awsSecretID string deploymentName string
awsRegion string
awsSecretName string // resolved via secret_name_template (namespace + env)
k8sSecretName string // resolved via k8s_secret_name_template (namespace only)
awsValues map[string]string awsValues map[string]string
selectedKey string k8sValues map[string]string
awsValue string diffEntries []kctl.SecretDiffEntry
k8sSecretName string diffOffset int // scroll position for diff table
k8sFieldName string
k8sValue string
message string message string
err error err error
} }
func runPanel(args []string) error { func runPanel(args []string) error {
fs := flag.NewFlagSet("panel", flag.ContinueOnError) 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") ns := fs.String("ns", "", "namespace")
team := fs.String("team", "", "team label value") team := fs.String("team", "", "team label value")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
m := newPanelModel(*ctx, *ns, *team) m := newPanelModel(*context, *ns, *team)
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run() _, err := p.Run()
return err return err
} }
func newPanelModel(ctx, ns, team string) *panelModel { func newPanelModel(context, 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)
ti := textinput.New() ti := textinput.New()
ti.Focus() 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)
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 { func (m *panelModel) showEnvMenu() {
return []list.Item{ items := make([]list.Item, 0, len(m.cfg.Envs)+1)
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"}, items = append(items, simpleItem{label: "Quit (closes this tmux session)", value: "quit"})
simpleItem{label: "Secrets: AWS <-> Kubernetes diff", value: "secrets"}, for _, env := range m.cfg.Envs {
simpleItem{label: "Quit (closes this tmux session)", value: "quit"}, 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 } func (m *panelModel) Init() tea.Cmd { return nil }
type awsLoginDoneMsg struct{ err error }
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2) m.list.SetSize(msg.Width, msg.Height-2)
return m, nil return m, nil
case awsLoginDoneMsg:
return m.afterAWSLogin(msg.err)
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
@@ -103,6 +145,14 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleEsc() return m.handleEsc()
case "enter": case "enter":
return m.handleEnter() 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 +168,180 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *panelModel) usesTextInput() bool { func (m *panelModel) usesTextInput() bool {
switch m.step { return m.step == stepExternalSecretName
case stepSecretID, stepSecretRegion, stepK8sSecretName, stepK8sFieldName, stepExternalSecretName:
return true
}
return false
} }
// handleEsc closes the whole tmux session (all panes, including the two // handleEsc navigates one level up: action menu -> env menu, most
// k9s status panes) before quitting this program, per SPEC.md 3.6. // 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) { func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
exec.Command("tmux", "kill-session", "-t", "kctl").Run() switch m.step {
return m, tea.Quit 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) { func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
switch m.step { switch m.step {
case stepMenu: case stepEnvMenu:
return m.fromMenu() return m.fromEnvMenu()
case stepActionMenu:
return m.fromActionMenu()
case stepRedeployList: case stepRedeployList:
return m.fromRedeployList() return m.fromRedeployList()
case stepRedeployConfirm: case stepRedeployConfirm:
return m.fromRedeployConfirm() return m.fromRedeployConfirm()
case stepSecretID: case stepAWSAuthPrompt:
m.awsSecretID = m.input.Value() return m.fromAWSAuthPrompt()
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 stepForceSyncConfirm: case stepForceSyncConfirm:
return m.fromForceSyncConfirm() return m.fromForceSyncConfirm()
case stepExternalSecretName: case stepExternalSecretName:
return m.doForceSync() return m.doForceSync()
case stepDiffResult, stepDone: case stepDiffResult, stepDone, stepError:
m.resetToMenu() m.showActionMenu()
return m, nil return m, nil
} }
return m, nil return m, nil
} }
func (m *panelModel) resetToMenu() { // showError switches to a dedicated error screen so failures from
m.step = stepMenu // kubectl/aws calls stay visible until the user explicitly acknowledges
m.list.SetItems(menuItems()) // them with Enter, instead of being silently discarded.
m.list.Title = "kctl-tui panel" 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) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
return m, nil return m, nil
} }
switch item.value { switch item.value {
case "redeploy": case "redeploy":
deployments, err := kubeexec.GetDeployments(m.ns) deployments, err := kubeexec.GetDeployments(m.resolvedContext(), m.ns)
if err != nil { if err != nil {
m.err = err return m.showError(err)
return m, nil
} }
items := make([]list.Item, 0, len(deployments)) items := make([]list.Item, 0, len(deployments))
for _, d := range deployments { for _, d := range deployments {
items = append(items, simpleItem{label: d, value: d}) items = append(items, simpleItem{label: d, value: d})
} }
m.list.SetItems(items) 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 m.step = stepRedeployList
case "secrets": case "secrets":
m.step = stepSecretID return m.checkAWSAuthAndProceed()
m.input.SetValue("")
m.input.Placeholder = "AWS secret ID"
case "quit":
return m.handleEsc()
} }
return m, nil 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) { func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
return m, nil return m, nil
} }
m.selectedKey = item.value // reused as "deployment name" here m.deploymentName = item.value
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
simpleItem{label: "Yes, restart " + item.value, value: "yes"}, simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"}, 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 m.step = stepRedeployConfirm
return m, nil return m, nil
} }
@@ -222,89 +349,108 @@ func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) { func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" { if !ok || item.value != "yes" {
m.resetToMenu() m.showActionMenu()
return m, nil return m, nil
} }
_, err := kubeexec.RolloutRestart(m.ns, m.selectedKey) ctx := m.resolvedContext()
_, err := kubeexec.RolloutRestart(ctx, m.ns, m.deploymentName)
if err != nil { if err != nil {
m.err = err return m.showError(err)
} }
status, _ := kubeexec.RolloutStatus(m.ns, m.selectedKey) status, err := kubeexec.RolloutStatus(ctx, m.ns, m.deploymentName)
m.message = "Rollout status: " + status if err != nil {
return m.showError(err)
}
m.message = fmt.Sprintf("[env=%s] Rollout status: %s", m.currentEnv, status)
m.step = stepDone m.step = stepDone
return m, nil return m, nil
} }
func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) { // compareAllFields fetches every field of the Kubernetes secret and diffs
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion) // 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 { if err != nil {
m.err = err return m.showError(fmt.Errorf("failed to fetch Kubernetes secret %q: %w", m.k8sSecretName, err))
m.resetToMenu()
return m, nil
} }
var parsed map[string]interface{} m.k8sValues = k8sValues
if err := json.Unmarshal([]byte(raw), &parsed); err != nil { m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.awsValues = map[string]string{"__raw__": raw} 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 { } 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 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 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) { func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" { if !ok || item.value != "yes" {
@@ -312,50 +458,63 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.step = stepExternalSecretName m.step = stepExternalSecretName
m.input.SetValue("") m.input.SetValue(m.k8sSecretName)
m.input.Placeholder = "ExternalSecret object name" m.input.Placeholder = "ExternalSecret object name"
return m, nil return m, nil
} }
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) { func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
name := m.input.Value() name := strings.TrimSpace(m.input.Value())
ts := time.Now().Unix() if name == "" {
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts) return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
if err != nil {
m.err = err
} }
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 m.step = stepDone
return m, nil return m, nil
} }
func (m *panelModel) View() string { func (m *panelModel) View() string {
switch m.step { switch m.step {
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretKeyList, stepForceSyncConfirm: case stepEnvMenu, stepActionMenu, stepRedeployList, stepRedeployConfirm:
v := m.list.View() v := m.list.View()
if m.err != nil { if m.err != nil {
v += "\nerror: " + m.err.Error() v += "\nerror: " + m.err.Error()
} }
return v 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: 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: 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()) m.stepPrompt(), m.input.View())
} }
} }
func (m *panelModel) stepPrompt() string { func (m *panelModel) stepPrompt() string {
switch m.step { if m.step == stepExternalSecretName {
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:
return "ExternalSecret object name to annotate" return "ExternalSecret object name to annotate"
} }
return "" return ""
+57 -14
View File
@@ -1,19 +1,62 @@
# Example configuration for kctl-tui. # Example configuration for kctl-tui.
# Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your # 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 # own AWS/Kubernetes setup. Do NOT commit your real config.yaml with
# project-specific context/namespace/label names to a public repository. # 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 # Top-level grouping the tool starts from (e.g. network boundary such as
# environment pair, such as staging/production of the same cluster). # internal/external-facing clusters). This is the outermost navigation
# Pressing TAB in the control pane cycles through the contexts listed here # level; press Esc from the team-selection screen to get here.
# while keeping the current namespace. contexts:
context_pairs: - "internal"
- name: "example-environment-pair" - "external"
contexts:
- "example-context-a"
- "example-context-b"
# The namespace label key used to group namespaces by team/ownership in the # Pre-selected on startup so the tool can jump straight to team selection
# team-selection screen. Adjust this to whatever label your organization # instead of asking for the context every time. Falls back to the first
# actually uses (can contain a domain prefix, e.g. "example.org/team"). # 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" 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 ( require (
github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/lipgloss v1.0.0
gopkg.in/yaml.v3 v3.0.1 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=
-1
View File
@@ -97,5 +97,4 @@ else
fi fi
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}" 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." echo "Done. Run '${BIN_NAME}' to get started."
+108 -6
View File
@@ -1,5 +1,5 @@
// Package config loads the user-specific, non-versioned kctl-tui // 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 package config
import ( import (
@@ -11,10 +11,111 @@ import (
"github.com/skoelle/kctl-tui/internal/kctl" "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 // Config is the root structure of ~/.kctl-tui/config.yaml
type Config struct { type Config struct {
ContextPairs []kctl.ContextPair `yaml:"context_pairs"` // Contexts are the top-level groupings the tool starts from, e.g.
TeamLabelKey string `yaml:"team_label_key"` // "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 // DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
@@ -27,9 +128,10 @@ func DefaultPath() (string, error) {
} }
// Load reads and parses the config file at path. If the file does not // 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 // exist, it returns a zero-value Config and no error, so the tool can
// error, so the tool can run with sane defaults before the user has set up // still start (with an explanatory error surfaced later where a required
// a config file. // field turns out to be missing) before the user has set up a config
// file.
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Config{} cfg := Config{}
+88 -10
View File
@@ -11,19 +11,25 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(cfg.ContextPairs) != 0 { if len(cfg.Contexts) != 0 || len(cfg.Envs) != 0 {
t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs) t.Fatalf("expected no contexts/envs, got %+v", cfg)
}
if cfg.TeamLabelKey != "" {
t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey)
} }
} }
func TestLoad_ValidFile(t *testing.T) { func TestLoad_ValidFile(t *testing.T) {
content := []byte(` content := []byte(`
context_pairs: contexts:
- name: "env-pair-1" - "internal"
contexts: ["ctx-a", "ctx-b"] - "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" team_label_key: "example.org/team"
`) `)
path := filepath.Join(t.TempDir(), "config.yaml") path := filepath.Join(t.TempDir(), "config.yaml")
@@ -38,7 +44,79 @@ team_label_key: "example.org/team"
if cfg.TeamLabelKey != "example.org/team" { if cfg.TeamLabelKey != "example.org/team" {
t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey) t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey)
} }
if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" { if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 {
t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs) 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)
} }
} }
+74
View File
@@ -0,0 +1,74 @@
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
}
+99
View File
@@ -0,0 +1,99 @@
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'")
}
}
}
}
-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")
}
}
+16
View File
@@ -0,0 +1,16 @@
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
}
+41
View File
@@ -0,0 +1,41 @@
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)
}
}
+101 -59
View File
@@ -2,6 +2,12 @@
// All functions here have side effects (they run external processes) and // All functions here have side effects (they run external processes) and
// are therefore not covered by unit tests; the pure logic they depend on // are therefore not covered by unit tests; the pure logic they depend on
// lives in the kctl package instead. // 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 package kubeexec
import ( import (
@@ -13,53 +19,35 @@ import (
) )
func runOutput(name string, args ...string) (string, error) { func runOutput(name string, args ...string) (string, error) {
logCmd(name, args...)
cmd := exec.Command(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 { 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. // kubectlArgs prepends a --context flag when context is non-empty.
func GetContexts() ([]string, error) { func kubectlArgs(context string, args ...string) []string {
out, err := runOutput("kubectl", "config", "get-contexts", "-o", "name") if context == "" {
if err != nil { return args
return nil, err
} }
if out == "" { return append([]string{"--context", context}, args...)
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
} }
type nsItem struct { type nsItem struct {
@@ -74,9 +62,10 @@ type nsList struct {
} }
// GetNamespacesWithLabels returns a map of namespace name -> labels for all // GetNamespacesWithLabels returns a map of namespace name -> labels for all
// namespaces visible in the current context. // namespaces visible in the given context.
func GetNamespacesWithLabels() (map[string]map[string]string, error) { func GetNamespacesWithLabels(context string) (map[string]map[string]string, error) {
out, err := runOutput("kubectl", "get", "ns", "-o", "json") args := kubectlArgs(context, "get", "ns", "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -91,10 +80,11 @@ func GetNamespacesWithLabels() (map[string]map[string]string, error) {
return result, nil return result, nil
} }
// GetDeployments lists deployment names in the given namespace. // GetDeployments lists deployment names in the given context/namespace.
func GetDeployments(namespace string) ([]string, error) { func GetDeployments(context, namespace string) ([]string, error) {
out, err := runOutput("kubectl", "-n", namespace, "get", "deploy", args := kubectlArgs(context, "-n", namespace, "get", "deploy",
"-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`) "-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`)
out, err := runOutput("kubectl", args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,20 +95,40 @@ func GetDeployments(namespace string) ([]string, error) {
} }
// RolloutRestart triggers a rolling restart of a deployment. // RolloutRestart triggers a rolling restart of a deployment.
func RolloutRestart(namespace, deployment string) (string, error) { func RolloutRestart(context, namespace, deployment string) (string, error) {
return runOutput("kubectl", "-n", namespace, "rollout", "restart", "deploy/"+deployment) args := kubectlArgs(context, "-n", namespace, "rollout", "restart", "deploy/"+deployment)
return runOutput("kubectl", args...)
} }
// RolloutStatus waits for and returns the rollout status of a deployment. // RolloutStatus waits for and returns the rollout status of a deployment.
func RolloutStatus(namespace, deployment string) (string, error) { func RolloutStatus(context, namespace, deployment string) (string, error) {
return runOutput("kubectl", "-n", namespace, "rollout", "status", "deploy/"+deployment) args := kubectlArgs(context, "-n", namespace, "rollout", "status", "deploy/"+deployment)
return runOutput("kubectl", args...)
} }
// GetSecretValueBase64 returns the raw (still base64-encoded) value of a // GetSecretAllFields returns all fields of a Kubernetes secret, already
// single field in a Kubernetes secret. // base64-decoded into plain values.
func GetSecretValueBase64(namespace, secretName, field string) (string, error) { func GetSecretAllFields(context, namespace, secretName string) (map[string]string, error) {
path := fmt.Sprintf("jsonpath={.data.%s}", field) args := kubectlArgs(context, "-n", namespace, "get", "secret", secretName, "-o", "json")
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path) 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. // DecodeBase64 decodes a base64-encoded Kubernetes secret value.
@@ -132,16 +142,48 @@ func DecodeBase64(value string) (string, error) {
// AnnotateForceSync sets the force-sync annotation on an ExternalSecret // AnnotateForceSync sets the force-sync annotation on an ExternalSecret
// object to trigger an immediate re-sync from the upstream secret store. // 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) annotation := fmt.Sprintf("force-sync=%d", unixTimestamp)
return runOutput("kubectl", "-n", namespace, "annotate", "externalsecret", args := kubectlArgs(context, "-n", namespace, "annotate", "externalsecret",
externalSecretName, annotation, "--overwrite") externalSecretName, annotation, "--overwrite")
return runOutput("kubectl", args...)
} }
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager // 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) { func GetAWSSecretString(secretID, region string) (string, error) {
return runOutput("aws", "secretsmanager", "get-secret-value", return runOutput("aws", "secretsmanager", "get-secret-value",
"--secret-id", secretID, "--region", region, "--secret-id", secretID, "--region", region,
"--query", "SecretString", "--output", "text") "--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 fmt.Errorf("%q not found in PATH — please install it first", name)
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
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
}
}
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)
}