mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-18 04:20:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbf4341fa | ||
|
|
37be0707ad | ||
|
|
9e6acdedc2 | ||
|
|
07662dc9b7 | ||
|
|
38b28f64fb | ||
|
|
6a749811c7 | ||
|
|
313dffcbbf | ||
|
|
8546ac9bf2 | ||
|
|
4c0650c43b | ||
|
|
e36ac3568f | ||
|
|
697017e496 | ||
|
|
36a7cc5e3a | ||
|
|
e48bd790bd | ||
|
|
6795ee1f4f | ||
|
|
edc02d48d4 | ||
|
|
2c8e8016ff | ||
|
|
886efa5958 | ||
|
|
c4d54d153f |
@@ -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
@@ -1,8 +1,8 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
/bin/
|
/bin/
|
||||||
/dist/
|
/dist/
|
||||||
kctl-tui
|
/kctl-tui
|
||||||
kctl-tui.exe
|
/kctl-tui.exe
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Stefan Koelle
|
Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
|||||||
## 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`), namespace/label filtering
|
template resolution (`ResolveTemplate`), namespace/label filtering
|
||||||
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
|
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
|
||||||
(`DiffSecretValues`, `AnyMismatch`).
|
(`DiffSecretValues`, `AnyMismatch`).
|
||||||
- [x] `internal/config`: YAML config loading (`context_pairs`,
|
- [x] `internal/config`: YAML config loading with template-based context
|
||||||
`team_label_key`), with safe defaults when no config file exists yet.
|
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, listing
|
(namespaces, deployments, rollout restart/status, fetching AWS
|
||||||
AWS secrets, reading all fields of a Kubernetes secret, ExternalSecret
|
secrets by template-resolved ID, reading all fields of a Kubernetes
|
||||||
annotation).
|
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
|
||||||
@@ -38,13 +40,13 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
|||||||
- [x] `cmd/kctl-tui` "panel" mode:
|
- [x] `cmd/kctl-tui` "panel" mode:
|
||||||
- Redeploy: pick a deployment from a list, confirm, then
|
- Redeploy: pick a deployment from a list, confirm, then
|
||||||
`rollout restart` + `rollout status`.
|
`rollout restart` + `rollout status`.
|
||||||
- Secrets: pick an AWS region, then pick the actual secret from a
|
- Secrets: AWS auth check with interactive SSO login fallback,
|
||||||
**list of all AWS Secrets Manager secrets** in that region (no more
|
then automatically resolve the AWS secret ID (from
|
||||||
manual secret-ID typing), enter the matching Kubernetes secret
|
`secret_name_template`) and Kubernetes secret name (from
|
||||||
name, and automatically diff **every field** of both secrets in one
|
`k8s_secret_name_template`), fetch both, diff **every field**
|
||||||
table (key / AWS value / Kubernetes value / match status). If any
|
in one table (key / AWS value / Kubernetes value / match status).
|
||||||
field differs, offer a single force-sync request for the **whole
|
If any field differs, offer a single force-sync request for the
|
||||||
secret** (one ExternalSecret annotation), not per individual field.
|
**whole secret** (one ExternalSecret annotation).
|
||||||
- `Esc` closes the whole tmux session (`tmux kill-session`).
|
- `Esc` closes the whole tmux session (`tmux kill-session`).
|
||||||
|
|
||||||
## Phase 2 — Hardening (open)
|
## Phase 2 — Hardening (open)
|
||||||
@@ -64,27 +66,24 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
|||||||
- [ ] Paginate/scroll the secrets diff table for secrets with many fields
|
- [ ] Paginate/scroll the secrets diff table for secrets with many fields
|
||||||
instead of relying on terminal wrapping.
|
instead of relying on terminal wrapping.
|
||||||
|
|
||||||
## Phase 3 — Windows-native support (open, secondary priority)
|
## Phase 3 — Windows-native support (done)
|
||||||
|
|
||||||
- [ ] Detect OS at runtime; on native Windows (no WSL), fall back to
|
- [x] Windows support via [psmux](https://github.com/marlocarlo/psmux) —
|
||||||
`wt.exe split-pane` instead of `tmux` for the status panes.
|
a native, tmux-compatible terminal multiplexer. kctl-tui works
|
||||||
- [ ] Document/implement that `Tab`-based context switching and
|
without code changes; `CheckTool("tmux")` error message includes
|
||||||
`Esc`-triggered session close are **not** available in the native
|
Windows-specific install hint.
|
||||||
Windows fallback, per SPEC.md 3.6 — the panes must be closed
|
- [x] `install.ps1` — PowerShell install script for Windows.
|
||||||
manually there.
|
- [x] Updated README and SPEC with Windows + psmux setup instructions.
|
||||||
|
|
||||||
## 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`.
|
||||||
- [ ] Optional heuristic to suggest a matching Kubernetes secret name for
|
|
||||||
a chosen AWS secret (e.g. by common naming convention), instead of
|
|
||||||
always asking for it manually.
|
|
||||||
|
|
||||||
## Notes for contributors
|
## Notes for contributors
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,16 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
|
|||||||
Configuration below - they must already exist in your kubeconfig, e.g.
|
Configuration below - they must already exist in your kubeconfig, e.g.
|
||||||
added via `aws eks update-kubeconfig`).
|
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 **Linux/macOS**, install
|
||||||
kctl-tui inside **WSL** — `tmux` has no native Windows port. Native
|
`tmux` via your package manager. On **Windows**, install
|
||||||
Windows Terminal has its own split-pane feature, but it cannot be
|
[psmux](https://github.com/marlocarlo/psmux) — a native,
|
||||||
scripted from inside a pane the way `tmux` can, so the automated 3-pane
|
tmux-compatible terminal multiplexer:
|
||||||
layout and the `Esc` session handling described above are only fully
|
```powershell
|
||||||
supported under Linux/WSL. See SPEC.md section 3.6 for details.
|
scoop install psmux
|
||||||
|
# or
|
||||||
|
cargo install psmux
|
||||||
|
```
|
||||||
|
psmux provides a `tmux` command, so kctl-tui works without changes.
|
||||||
- `aws` CLI, configured with credentials, only needed for the secrets
|
- `aws` CLI, configured with credentials, only needed for the secrets
|
||||||
workflow.
|
workflow.
|
||||||
|
|
||||||
@@ -85,6 +89,15 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
|
|||||||
curl -fsSL https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Quick install (Windows)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads the latest release binary for your architecture from
|
||||||
|
GitHub Releases and installs it to your PATH.
|
||||||
|
|
||||||
This downloads the latest release binary for your OS/architecture from
|
This downloads the latest release binary for your OS/architecture from
|
||||||
GitHub Releases and installs it to `/usr/local/bin/kctl-tui`.
|
GitHub Releases and installs it to `/usr/local/bin/kctl-tui`.
|
||||||
|
|
||||||
@@ -125,6 +138,7 @@ aws_region: "eu-central-1"
|
|||||||
aws_account_id: "123456789012"
|
aws_account_id: "123456789012"
|
||||||
|
|
||||||
secret_name_template: "tf-{namespace}-{env}-secrets"
|
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"
|
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"
|
||||||
@@ -144,6 +158,10 @@ aws_sso_login_command: "aws sso login"
|
|||||||
- `secret_name_template`: builds the AWS Secrets Manager secret ID from
|
- `secret_name_template`: builds the AWS Secrets Manager secret ID from
|
||||||
the chosen namespace and environment. Placeholders: `{namespace}`,
|
the chosen namespace and environment. Placeholders: `{namespace}`,
|
||||||
`{env}`.
|
`{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
|
- `context_template`: builds the actual kubectl context name/ARN from
|
||||||
region, account ID, environment, and context. Placeholders: `{region}`,
|
region, account ID, environment, and context. Placeholders: `{region}`,
|
||||||
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
|
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
|
||||||
@@ -162,17 +180,37 @@ aws_sso_login_command: "aws sso login"
|
|||||||
that way — it typically contains your organization's internal account ID,
|
that way — it typically contains your organization's internal account ID,
|
||||||
context naming, and label names.
|
context naming, and label names.
|
||||||
|
|
||||||
## WSL setup notes
|
## Windows notes
|
||||||
|
|
||||||
If `kubectx`/`kubens` or `kctl-tui` report a missing kubeconfig inside WSL,
|
On native Windows (without WSL), install [psmux](https://github.com/marlocarlo/psmux)
|
||||||
your kubeconfig most likely only exists on the Windows side. Symlink it
|
for the 3-pane layout. psmux is a native Windows terminal multiplexer
|
||||||
into WSL:
|
that is tmux-compatible — kctl-tui works without code changes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scoop install psmux
|
||||||
|
# or
|
||||||
|
cargo install psmux
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer WSL, symlink your kubeconfig into WSL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.kube
|
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 --help # show all commands and flags
|
||||||
|
kctl-tui --version # print version
|
||||||
|
kctl-tui --verbose # enable debug logging to stderr
|
||||||
|
kctl-tui doctor # check if all tools, config and connections are OK
|
||||||
|
kctl-tui config check # validate ~/.kctl-tui/config.yaml
|
||||||
|
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -189,4 +227,4 @@ and untested, since it has no meaningful behavior without a live cluster.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -92,11 +89,15 @@ not cover: **redeploy** and **secrets diff**.
|
|||||||
Example startup command (generic placeholders):
|
Example startup command (generic placeholders):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
# Kill stale session first (separate command — tmux aborts on kill-session error).
|
||||||
|
tmux kill-session -t kctl
|
||||||
tmux new-session -d -s kctl \
|
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,59 +116,68 @@ Switching between panes: `Ctrl-b` + arrow key, or `Ctrl-b` `o`.
|
|||||||
|
|
||||||
### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane
|
### 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.
|
**Windows support:** On native Windows, install
|
||||||
2. Restart both k9s panes via
|
[psmux](https://github.com/marlocarlo/psmux) — a native, tmux-compatible
|
||||||
`tmux respawn-pane -k -t kctl:0.1 "k9s --context <newA> -n <ns>"` and
|
terminal multiplexer. psmux provides a `tmux` command, so kctl-tui works
|
||||||
`... kctl:0.2 ...` (namespace stays the same).
|
without code changes (including `Esc`-triggered session termination).
|
||||||
3. If the current context is in no configured list: show a hint in the
|
Alternatively, run kctl-tui inside WSL with standard `tmux`.
|
||||||
control pane instead of an error.
|
|
||||||
4. No action outside this configuration — no error, only a hint.
|
|
||||||
|
|
||||||
**Platform limitation on Windows without WSL:** `respawn-pane`/
|
|
||||||
`kill-session` are tmux-specific. Windows Terminal (`wt.exe`) offers no
|
|
||||||
equivalent scripting to replace panes or end the session from inside a
|
|
||||||
pane. On plain Windows (without WSL), only a simplified flow is possible:
|
|
||||||
k9s panes are closed manually (`q`, then `Ctrl+Shift+W`); Tab switching and
|
|
||||||
automatic session termination are unavailable there. This limitation is
|
|
||||||
the main reason the primary target system is set to Linux/WSL.
|
|
||||||
|
|
||||||
## 4. Non-functional requirements
|
## 4. Non-functional requirements
|
||||||
|
|
||||||
- **Primary platform Linux/WSL**, secondary native Windows with reduced
|
- **Primary platform Linux/WSL**, secondary native Windows (via psmux).
|
||||||
functionality.
|
|
||||||
- **Single-binary distribution** without external runtime dependency (Go
|
- **Single-binary distribution** without external runtime dependency (Go
|
||||||
provides this natively).
|
provides this natively).
|
||||||
- **External dependencies**: `kubectl` mandatory; `tmux`, `k9s`, `aws-cli`
|
- **External dependencies**: `kubectl` mandatory; `tmux`, `k9s`, `aws-cli`
|
||||||
@@ -240,8 +250,8 @@ Rejected options (see discussion history):
|
|||||||
- Split view v1 fixed at 2 status panes + 1 control pane (3 panes total).
|
- 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
|
||||||
|
|||||||
+54
-16
@@ -1,8 +1,13 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"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"
|
||||||
@@ -66,6 +71,9 @@ func (m *fullModel) bootstrap() tea.Msg {
|
|||||||
if len(cfg.Envs) == 0 {
|
if len(cfg.Envs) == 0 {
|
||||||
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
||||||
}
|
}
|
||||||
|
if err := kubeexec.CheckTool("kubectl"); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
|
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +196,14 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case screenNamespace:
|
case screenNamespace:
|
||||||
m.selectedNamespace = item.value
|
m.selectedNamespace = item.value
|
||||||
|
if err := kubeexec.CheckTool("tmux"); err != nil {
|
||||||
|
m.err = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := kubeexec.CheckTool("k9s"); err != nil {
|
||||||
|
m.err = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
return m, m.startTmuxSession()
|
return m, m.startTmuxSession()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -235,6 +251,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)
|
||||||
}
|
}
|
||||||
@@ -252,31 +269,52 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
|||||||
// configured envs, resolved via the context template, so both are
|
// configured envs, resolved via the context template, so both are
|
||||||
// visible side by side.
|
// visible side by side.
|
||||||
func (m *fullModel) startTmuxSession() tea.Cmd {
|
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||||
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
selfPath = "kctl-tui" // fallback to PATH lookup
|
||||||
|
}
|
||||||
panelCmd := fmt.Sprintf("%s panel --context=%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)
|
||||||
|
|
||||||
envA := m.cfg.Envs[0]
|
envA := m.cfg.Envs[0]
|
||||||
envB := m.cfg.Envs[0]
|
|
||||||
if len(m.cfg.Envs) > 1 {
|
|
||||||
envB = m.cfg.Envs[1]
|
|
||||||
}
|
|
||||||
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
|
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
|
||||||
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
|
|
||||||
|
|
||||||
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace)
|
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace)
|
||||||
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
|
|
||||||
|
|
||||||
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
|
||||||
panelCmd, ";",
|
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
|
||||||
"set-option", "-t", "kctl", "remain-on-exit", "on", ";",
|
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
|
||||||
"split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";",
|
|
||||||
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";",
|
// Kill stale session first (ignore error if none exists).
|
||||||
"select-layout", "-t", "kctl", "even-vertical", ";",
|
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
|
||||||
"select-pane", "-t", "kctl:0.0", ";",
|
|
||||||
"attach", "-t", "kctl",
|
// Run setup commands individually — this avoids the ; separator
|
||||||
|
// issue on Windows where psmux doesn't handle chained args.
|
||||||
|
setup := [][]string{
|
||||||
|
{"new-session", "-d", "-s", "kctl", panelCmd},
|
||||||
|
{"set-option", "-t", "kctl", "remain-on-exit", "on"},
|
||||||
|
{"split-window", "-v", "-t", "kctl:0.0", k9sCmdA},
|
||||||
|
}
|
||||||
|
if len(m.cfg.Envs) > 1 {
|
||||||
|
envB := m.cfg.Envs[1]
|
||||||
|
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
|
||||||
|
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdB=%s\n", k9sCmdB)
|
||||||
|
setup = append(setup, []string{"split-window", "-v", "-t", "kctl:0.1", k9sCmdB})
|
||||||
|
}
|
||||||
|
setup = append(setup,
|
||||||
|
[]string{"select-layout", "-t", "kctl", "even-vertical"},
|
||||||
|
[]string{"select-pane", "-t", "kctl:0.0"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for _, args := range setup {
|
||||||
|
if out, err := exec.Command("tmux", args...).CombinedOutput(); err != nil {
|
||||||
|
kubeexec.VerboseLog("[debug] tmux %s failed: %v\n%s\n", args[0], err, out)
|
||||||
|
return func() tea.Msg { return tmuxDoneMsg{err: fmt.Errorf("tmux %s: %w", args[0], err)} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only attach uses tea.ExecProcess so it takes over the terminal.
|
||||||
|
c := exec.Command("tmux", "attach", "-t", "kctl")
|
||||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||||
return tmuxDoneMsg{err: err}
|
return tmuxDoneMsg{err: err}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/skoelle/kctl-tui/internal/kctl"
|
import "github.com/skoelle/kctl-tui/internal/kctl"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
// simpleItem is a minimal implementation of list.Item used for all
|
// simpleItem is a minimal implementation of list.Item used for all
|
||||||
|
|||||||
+246
-4
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,15 +8,71 @@ 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.
|
||||||
|
verbose := false
|
||||||
|
showHelp := false
|
||||||
|
filtered := make([]string, 0, len(args))
|
||||||
|
for _, a := range args {
|
||||||
|
switch a {
|
||||||
|
case "--verbose":
|
||||||
|
verbose = true
|
||||||
|
case "--version", "-v":
|
||||||
|
fmt.Printf("kctl-tui %s\n", version)
|
||||||
|
return
|
||||||
|
case "--help", "-h":
|
||||||
|
showHelp = true
|
||||||
|
default:
|
||||||
|
filtered = append(filtered, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
kubeexec.SetVerbose(true, os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showHelp {
|
||||||
|
printUsage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
// No subcommand — start the full TUI.
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) > 0 {
|
||||||
|
switch filtered[0] {
|
||||||
|
case "panel":
|
||||||
|
if err := runPanel(filtered[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "config":
|
||||||
|
if err := runConfig(filtered[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "kctl-tui config error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "doctor":
|
||||||
|
if err := runDoctor(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "kctl-tui doctor error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", filtered[0])
|
||||||
|
printUsage()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m := newFullModel()
|
m := newFullModel()
|
||||||
@@ -23,3 +82,186 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Print(`kctl-tui — Kubernetes entry-point TUI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
kctl-tui [flags] start the TUI (full navigation mode)
|
||||||
|
kctl-tui doctor check tools, config and connections
|
||||||
|
kctl-tui config check validate ~/.kctl-tui/config.yaml
|
||||||
|
kctl-tui panel [options] control pane (called internally by tmux)
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--verbose log all kubectl/aws commands to stderr
|
||||||
|
--version print version
|
||||||
|
--help show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
kctl-tui # start the TUI
|
||||||
|
kctl-tui doctor # verify everything is installed
|
||||||
|
kctl-tui --verbose 2>debug.log # log commands to a file
|
||||||
|
kctl-tui config check # validate config
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfig(args []string) error {
|
||||||
|
if len(args) == 0 || args[0] != "check" {
|
||||||
|
return fmt.Errorf("usage: kctl-tui config check")
|
||||||
|
}
|
||||||
|
cfgPath, err := config.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine config path: %w", err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load %s: %w", cfgPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := true
|
||||||
|
if len(cfg.Contexts) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: no 'contexts' configured")
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if len(cfg.Envs) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: no 'envs' configured")
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if cfg.ContextTemplate == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: 'context_template' is empty")
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if cfg.SecretNameTemplate == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: 'secret_name_template' is empty")
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if cfg.TeamLabelKey == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "WARNING: 'team_label_key' is empty — team selection will have no groups")
|
||||||
|
}
|
||||||
|
if cfg.AWSRegion == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "WARNING: 'aws_region' is empty — secrets workflow will fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try resolving one context to verify the template works.
|
||||||
|
if len(cfg.Contexts) > 0 && len(cfg.Envs) > 0 && cfg.ContextTemplate != "" {
|
||||||
|
ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0])
|
||||||
|
fmt.Printf("Resolved context example: %s\n", ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
fmt.Println("Config OK")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, "Config has errors — see above")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDoctor() error {
|
||||||
|
pass := "ok"
|
||||||
|
fail := "FAIL"
|
||||||
|
warn := "WARN"
|
||||||
|
status := pass
|
||||||
|
errs := 0
|
||||||
|
|
||||||
|
check := func(label string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" [%s] %s: %v\n", fail, label, err)
|
||||||
|
status = fail
|
||||||
|
errs++
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] %s\n", pass, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warnCheck := func(label string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" [%s] %s: %v\n", warn, label, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] %s\n", pass, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tools ---
|
||||||
|
fmt.Println("\nTools:")
|
||||||
|
check("kubectl", kubeexec.CheckTool("kubectl"))
|
||||||
|
check("tmux/psmux", kubeexec.CheckTool("tmux"))
|
||||||
|
check("k9s", kubeexec.CheckTool("k9s"))
|
||||||
|
warnCheck("aws CLI (optional)", kubeexec.CheckTool("aws"))
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
fmt.Println("\nConfig:")
|
||||||
|
cfgPath, err := config.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" [%s] config path: %v\n", fail, err)
|
||||||
|
errs++
|
||||||
|
status = fail
|
||||||
|
} else {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" [%s] load config: %v\n", fail, err)
|
||||||
|
errs++
|
||||||
|
status = fail
|
||||||
|
} else {
|
||||||
|
check("config file exists", nil)
|
||||||
|
if len(cfg.Contexts) == 0 {
|
||||||
|
fmt.Printf(" [%s] contexts configured\n", fail)
|
||||||
|
errs++
|
||||||
|
status = fail
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] contexts configured (%d)\n", pass, len(cfg.Contexts))
|
||||||
|
}
|
||||||
|
if len(cfg.Envs) == 0 {
|
||||||
|
fmt.Printf(" [%s] envs configured\n", fail)
|
||||||
|
errs++
|
||||||
|
status = fail
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] envs configured (%d)\n", pass, len(cfg.Envs))
|
||||||
|
}
|
||||||
|
if cfg.ContextTemplate != "" {
|
||||||
|
ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0])
|
||||||
|
fmt.Printf(" [%s] context_template resolves to: %s\n", pass, ctx)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] context_template is empty\n", fail)
|
||||||
|
errs++
|
||||||
|
status = fail
|
||||||
|
}
|
||||||
|
if cfg.SecretNameTemplate != "" && len(cfg.Envs) > 0 {
|
||||||
|
secret := cfg.ResolveSecretName("example-ns", cfg.Envs[0])
|
||||||
|
fmt.Printf(" [%s] secret_name_template resolves to: %s\n", pass, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connections ---
|
||||||
|
fmt.Println("\nConnections:")
|
||||||
|
if kubeexec.CheckTool("kubectl") == nil {
|
||||||
|
err := kubeexec.CheckAWSAuth()
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf(" [%s] kubectl cluster reachable\n", pass)
|
||||||
|
} else {
|
||||||
|
// Not fatal — cluster might be unreachable from this machine
|
||||||
|
fmt.Printf(" [%s] kubectl cluster: %v\n", warn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kubeexec.CheckTool("aws") == nil {
|
||||||
|
err := kubeexec.CheckAWSAuth()
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf(" [%s] AWS credentials valid\n", pass)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] AWS credentials: %v\n", warn, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [%s] AWS credentials (aws CLI not installed)\n", warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
fmt.Println()
|
||||||
|
if status == pass {
|
||||||
|
fmt.Println("All checks passed. kctl-tui is ready to use.")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%d error(s) found. Fix the issues above and re-run: kctl-tui doctor\n", errs)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+72
-7
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -51,6 +54,7 @@ type panelModel struct {
|
|||||||
awsValues map[string]string
|
awsValues map[string]string
|
||||||
k8sValues map[string]string
|
k8sValues map[string]string
|
||||||
diffEntries []kctl.SecretDiffEntry
|
diffEntries []kctl.SecretDiffEntry
|
||||||
|
diffOffset int // scroll position for diff table
|
||||||
|
|
||||||
message string
|
message string
|
||||||
err error
|
err error
|
||||||
@@ -76,13 +80,18 @@ func newPanelModel(context, ns, team string) *panelModel {
|
|||||||
ti.Focus()
|
ti.Focus()
|
||||||
|
|
||||||
cfgPath, _ := config.DefaultPath()
|
cfgPath, _ := config.DefaultPath()
|
||||||
cfg, _ := config.Load(cfgPath)
|
cfg, loadErr := config.Load(cfgPath)
|
||||||
|
|
||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
|
|
||||||
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
|
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
|
||||||
m.showEnvMenu()
|
if loadErr != nil {
|
||||||
|
m.err = fmt.Errorf("config load failed: %w", loadErr)
|
||||||
|
m.step = stepError
|
||||||
|
} else {
|
||||||
|
m.showEnvMenu()
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +148,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +267,9 @@ func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
|
|||||||
// interactively instead of letting the user hit a confusing failure
|
// interactively instead of letting the user hit a confusing failure
|
||||||
// several steps later.
|
// several steps later.
|
||||||
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
|
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 {
|
if err := kubeexec.CheckAWSAuth(); err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
@@ -358,7 +378,8 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.k8sValues = k8sValues
|
m.k8sValues = k8sValues
|
||||||
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
||||||
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries)
|
m.diffOffset = 0
|
||||||
|
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
|
||||||
|
|
||||||
if anyMismatch(m.diffEntries) {
|
if anyMismatch(m.diffEntries) {
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
@@ -373,16 +394,52 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
|
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
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName)
|
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")
|
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
|
||||||
for _, e := range entries {
|
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"
|
status := "OK"
|
||||||
if !e.Match {
|
if !e.Match {
|
||||||
status = "MISMATCH"
|
status = "MISMATCH"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(e.Left, 20), truncate(e.Right, 20), status)
|
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()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -410,7 +467,15 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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())
|
||||||
|
if name == "" {
|
||||||
|
return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
|
||||||
|
}
|
||||||
|
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()
|
ts := time.Now().Unix()
|
||||||
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
|
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
||||||
|
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
|
||||||
|
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
|
||||||
|
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
|
||||||
|
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
|
||||||
|
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
|
||||||
|
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
||||||
|
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||||
|
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||||
|
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# Install script for kctl-tui on Windows.
|
||||||
|
# Downloads the latest GitHub release binary matching the current architecture
|
||||||
|
# and installs it to your PATH.
|
||||||
|
#
|
||||||
|
# Usage (PowerShell):
|
||||||
|
# irm https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.ps1 | iex
|
||||||
|
#
|
||||||
|
# Or save and run locally:
|
||||||
|
# .\install.ps1
|
||||||
|
#
|
||||||
|
# Requires: PowerShell 5.1+, internet access.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Repo = "skoelle/kctl-tui"
|
||||||
|
$BinName = "kctl-tui"
|
||||||
|
|
||||||
|
# --- Detect architecture ---
|
||||||
|
$arch = $env:PROCESSOR_ARCHITECTURE
|
||||||
|
switch ($arch) {
|
||||||
|
"AMD64" { $goarch = "amd64" }
|
||||||
|
"ARM64" { $goarch = "arm64" }
|
||||||
|
default {
|
||||||
|
Write-Error "Unsupported architecture: $arch"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Determine install directory ---
|
||||||
|
$installDir = "$env:USERPROFILE\bin"
|
||||||
|
if (-not (Test-Path $installDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $installDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add to PATH if not already there
|
||||||
|
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
if ($currentPath -notlike "*$installDir*") {
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$installDir", "User")
|
||||||
|
$env:Path = "$env:Path;$installDir"
|
||||||
|
Write-Host "Added $installDir to your PATH."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Query GitHub API for latest release ---
|
||||||
|
Write-Host "Detecting latest release for $Repo ..."
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to reach the GitHub API (network error). Check your internet connection and try again."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = $release.tag_name
|
||||||
|
if (-not $tag) {
|
||||||
|
Write-Error "Could not find a published release for $Repo. No release has been tagged yet."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Latest release: $tag"
|
||||||
|
|
||||||
|
# --- Download binary ---
|
||||||
|
$asset = "kctl-tui-windows-${goarch}.exe"
|
||||||
|
$url = "https://github.com/$Repo/releases/download/$tag/$asset"
|
||||||
|
$outFile = "$installDir\$BinName.exe"
|
||||||
|
|
||||||
|
Write-Host "Downloading $asset ($tag) ..."
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing
|
||||||
|
} catch {
|
||||||
|
Write-Error "Download failed: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installed $BinName to $outFile"
|
||||||
|
Write-Host "Done. Run '$BinName' to get started."
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
# Install script for kctl-tui.
|
# Install script for kctl-tui.
|
||||||
# Downloads the latest GitHub release binary matching the current OS/arch
|
# Downloads the latest GitHub release binary matching the current OS/arch
|
||||||
# and installs it to /usr/local/bin (or $INSTALL_DIR if set).
|
# and installs it to /usr/local/bin (or $INSTALL_DIR if set).
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
// Package config loads the user-specific, non-versioned kctl-tui
|
// Package config loads the user-specific, non-versioned kctl-tui
|
||||||
// configuration (contexts, envs, templates) from a YAML file.
|
// configuration (contexts, envs, templates) from a YAML file.
|
||||||
package config
|
package config
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+31
-5
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package kctl
|
package kctl
|
||||||
|
|
||||||
import "sort"
|
import "sort"
|
||||||
@@ -5,10 +8,27 @@ import "sort"
|
|||||||
// SecretDiffEntry represents the comparison of one key between two secret
|
// SecretDiffEntry represents the comparison of one key between two secret
|
||||||
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
|
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
|
||||||
type SecretDiffEntry struct {
|
type SecretDiffEntry struct {
|
||||||
Key string
|
Key string
|
||||||
Left string // e.g. the AWS Secrets Manager value
|
Left string // e.g. the AWS Secrets Manager value
|
||||||
Right string // e.g. the decoded Kubernetes secret value
|
Right string // e.g. the decoded Kubernetes secret value
|
||||||
Match bool
|
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
|
// DiffSecretValues compares two key/value maps and returns a sorted list of
|
||||||
@@ -35,7 +55,13 @@ func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
|
|||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
l := left[k]
|
l := left[k]
|
||||||
r := right[k]
|
r := right[k]
|
||||||
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: l == r})
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package kctl
|
package kctl
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
@@ -57,3 +60,43 @@ func TestDiffSecretValues_EmptyMaps(t *testing.T) {
|
|||||||
t.Fatalf("expected no mismatch for empty maps")
|
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'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package kctl
|
package kctl
|
||||||
|
|
||||||
import "sort"
|
import "sort"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package kctl
|
package kctl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package kctl
|
package kctl
|
||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
package kctl
|
package kctl
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
// Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui.
|
// Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui.
|
||||||
// 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
|
||||||
@@ -19,12 +22,27 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// kubectlArgs prepends a --context flag when context is non-empty.
|
// kubectlArgs prepends a --context flag when context is non-empty.
|
||||||
@@ -162,3 +180,16 @@ func RunAWSLogin(loginCommand string) *exec.Cmd {
|
|||||||
}
|
}
|
||||||
return exec.Command(parts[0], parts[1:]...)
|
return exec.Command(parts[0], parts[1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckTool verifies that a named executable is available in PATH.
|
||||||
|
// Returns nil if found, or a descriptive error if not.
|
||||||
|
func CheckTool(name string) error {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if name == "tmux" {
|
||||||
|
return fmt.Errorf("%q not found in PATH — install tmux (Linux/macOS) or psmux (Windows: scoop install psmux or cargo install psmux)", name)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%q not found in PATH — please install it first", name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
package kubeexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
verbose bool
|
||||||
|
logOut io.Writer = io.Discard
|
||||||
|
mu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetVerbose enables or disables debug logging of executed commands.
|
||||||
|
// When enabled, commands and their outputs are written to the provided
|
||||||
|
// writer (typically os.Stderr). When disabled (the default), all logging
|
||||||
|
// is discarded.
|
||||||
|
func SetVerbose(enabled bool, w io.Writer) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
verbose = enabled
|
||||||
|
if w != nil {
|
||||||
|
logOut = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerboseLog writes a message to the verbose log if enabled.
|
||||||
|
func VerboseLog(format string, args ...interface{}) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(logOut, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logCmd(name string, args ...string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(logOut, "[cmd] %s %s\n", name, strings.Join(args, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func logOutput(name string, output string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if output != "" {
|
||||||
|
fmt.Fprintf(logOut, "[out] %s: %s\n", name, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logErr(name string, err error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(logOut, "[err] %s: %v\n", name, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user