mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-17 20:10:24 +00:00
Compare commits
75
Commits
v0.1.0
...
3729a0301d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3729a0301d | ||
|
|
a1760b7b01 | ||
|
|
bd23403e6c | ||
|
|
6a0405640d | ||
|
|
c6880e92ce | ||
|
|
209e93646c | ||
|
|
720064454b | ||
|
|
205f8906df | ||
|
|
c0ad7e803e | ||
|
|
7f2c9b2ba8 | ||
|
|
fed2b44c28 | ||
|
|
d89efc55d9 | ||
|
|
d743422d28 | ||
|
|
b9439e428a | ||
|
|
4afb20c740 | ||
|
|
10f996cfef | ||
|
|
15e4aa64f7 | ||
|
|
50ad7d7335 | ||
|
|
28eeeda58e | ||
|
|
0d33a57118 | ||
|
|
0dbce4769b | ||
|
|
406d65ca33 | ||
|
|
d066733c20 | ||
|
|
2e7d48a2bb | ||
|
|
be4ad209db | ||
|
|
d8d656504c | ||
|
|
a3480f41bf | ||
|
|
9521f47377 | ||
|
|
a1e8894222 | ||
|
|
43aef3b76c | ||
|
|
84dbd9c230 | ||
|
|
2911f97991 | ||
|
|
7db58b6fe8 | ||
|
|
201168e8cd | ||
|
|
ffbf4341fa | ||
|
|
37be0707ad | ||
|
|
9e6acdedc2 | ||
|
|
07662dc9b7 | ||
|
|
38b28f64fb | ||
|
|
6a749811c7 | ||
|
|
313dffcbbf | ||
|
|
8546ac9bf2 | ||
|
|
4c0650c43b | ||
|
|
e36ac3568f | ||
|
|
697017e496 | ||
|
|
36a7cc5e3a | ||
|
|
e48bd790bd | ||
|
|
6795ee1f4f | ||
|
|
edc02d48d4 | ||
|
|
2c8e8016ff | ||
|
|
886efa5958 | ||
|
|
c4d54d153f | ||
|
|
4bb3507aec | ||
|
|
145288971a | ||
|
|
75e0b832a2 | ||
|
|
0f889de86f | ||
|
|
ff898f727a | ||
|
|
f7606011bb | ||
|
|
c02e145e4a | ||
|
|
929b073382 | ||
|
|
3af468de98 | ||
|
|
0a607bab28 | ||
|
|
1f87cdbf08 | ||
|
|
79c861682b | ||
|
|
c229f4d5d8 | ||
|
|
9daba2a3f0 | ||
|
|
4be10b6432 | ||
|
|
e57d58aecc | ||
|
|
b7d553c966 | ||
|
|
793c28afff | ||
|
|
9162eebccc | ||
|
|
26b5754f60 | ||
|
|
76a389f52d | ||
|
|
73b38ef52c | ||
|
|
c10b553b48 |
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report a bug to help us improve kctl-tui
|
||||||
|
title: ""
|
||||||
|
labels: bug
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
## Describe the Bug
|
||||||
|
|
||||||
|
A clear description of what the bug is.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
1. Run `kctl-tui ...`
|
||||||
|
2. Select '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
What you expected to happen.
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
|
||||||
|
What actually happened.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS: [e.g. Windows 11, Ubuntu 24.04, macOS 15]
|
||||||
|
- Go version: [e.g. 1.22.5]
|
||||||
|
- kctl-tui version: [e.g. v0.2.0]
|
||||||
|
- kubectl version: [e.g. v1.30.0]
|
||||||
|
- tmux/psmux version: [e.g. tmux 3.4, psmux latest]
|
||||||
|
- Terminal: [e.g. Windows Terminal, iTerm2, GNOME Terminal]
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Config file (without secrets), error logs, or screenshots.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature or improvement
|
||||||
|
title: ""
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What problem does this feature solve?
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Describe the solution you'd like.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
Any alternative solutions or workarounds you considered.
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Any mockups, examples, or references.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
Brief description of what this PR does.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- [ ] `go vet ./...` passes
|
||||||
|
- [ ] `go test ./...` passes
|
||||||
|
- [ ] Manually tested on [OS]
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows existing style
|
||||||
|
- [ ] No new comments added (unless necessary)
|
||||||
|
- [ ] CHANGELOG.md updated (if applicable)
|
||||||
+25
-11
@@ -15,11 +15,11 @@ jobs:
|
|||||||
name: Test
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Tidy dependencies (generates/updates go.sum)
|
- name: Tidy dependencies (generates/updates go.sum)
|
||||||
@@ -40,11 +40,11 @@ jobs:
|
|||||||
goos: [linux, windows, darwin]
|
goos: [linux, windows, darwin]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Tidy dependencies (generates/updates go.sum)
|
- name: Tidy dependencies (generates/updates go.sum)
|
||||||
@@ -60,11 +60,11 @@ 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
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}
|
name: kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
path: dist/*
|
path: dist/*
|
||||||
@@ -75,13 +75,27 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract release notes from commit message
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
MSG=$(git log -1 --pretty=format:"%B" "${{ github.sha }}")
|
||||||
|
{
|
||||||
|
echo 'body<<EOF'
|
||||||
|
echo "$MSG"
|
||||||
|
echo 'EOF'
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Create release and upload binaries
|
- name: Create release and upload binaries
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
|
body: ${{ steps.release.outputs.body }}
|
||||||
files: dist/*
|
files: dist/*
|
||||||
generate_release_notes: true
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
title: "kctl-tui"
|
||||||
|
emoji: "🐳"
|
||||||
|
category: code
|
||||||
|
subcategory: "Dev Tools"
|
||||||
|
status: active
|
||||||
|
stack: [Go, Bubbletea, Bubbles, Lipgloss, YAML]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: Create and push a release tag (e.g. /create-release 1.0.0)
|
||||||
|
---
|
||||||
|
|
||||||
|
Create a release tag and push it to origin. The GitHub Action will automatically build for all platforms and create the GitHub Release.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Validate the version argument ($ARGUMENTS):
|
||||||
|
- Must be provided, otherwise show error and stop
|
||||||
|
- Must match semver format (e.g. 1.0.0, 0.9.1, 2.0.0-beta.1)
|
||||||
|
|
||||||
|
2. Check for uncommitted changes:
|
||||||
|
- Run `git status --porcelain`
|
||||||
|
- If any output, warn the user and stop (commit first)
|
||||||
|
|
||||||
|
3. Analyze changes since last release:
|
||||||
|
- Run `git log --oneline $(git describe --tags --abbrev=0 HEAD)..HEAD` to list all commits
|
||||||
|
- Read the changed files to understand context
|
||||||
|
- Write a concise, well-structured release summary in English with:
|
||||||
|
- A one-line overview
|
||||||
|
- Bullet points for each notable change (features, fixes, breaking changes)
|
||||||
|
- Keep it developer-friendly, no fluff
|
||||||
|
|
||||||
|
4. Create release commit with the summary as message:
|
||||||
|
- Run `git commit --allow-empty -m "release v$ARGUMENTS\n\n<summary>"`
|
||||||
|
- The commit message IS the release notes — the GitHub Action picks it up automatically
|
||||||
|
|
||||||
|
5. Create annotated tag on that commit:
|
||||||
|
- Run `git tag -a v$ARGUMENTS -m "Release v$ARGUMENTS"`
|
||||||
|
|
||||||
|
6. Push commit and tag separately (pushing both in one step causes GitHub Actions to skip the release workflow):
|
||||||
|
- Run `git push origin main` (or current branch)
|
||||||
|
- Run `git push origin v$ARGUMENTS`
|
||||||
|
|
||||||
|
7. Confirm success with the version number
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to kctl-tui will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `kctl-tui update` — self-update command that downloads the latest release
|
||||||
|
from GitHub and replaces the running binary
|
||||||
|
- Automatic update check on startup — prompts to update interactively when
|
||||||
|
a newer version is available
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-08-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Windows support** via [psmux](https://github.com/marlocarlo/psmux) as tmux-compatible multiplexer
|
||||||
|
- `install.ps1` PowerShell install script for Windows
|
||||||
|
- `--help` flag with full usage documentation
|
||||||
|
- `--version` / `-v` flag (set via `-ldflags` at build time)
|
||||||
|
- `kctl-tui doctor` command to verify tools, config and cluster connectivity
|
||||||
|
- `kctl-tui config check` command to validate `~/.kctl-tui/config.yaml`
|
||||||
|
- `--verbose` flag for debug logging of all kubectl/aws commands to stderr
|
||||||
|
- Binary secret value detection (`IsBinary`) — base64 or non-UTF-8 content is flagged
|
||||||
|
- ExternalSecret name validation before force-sync
|
||||||
|
- `CheckTool()` and `CheckAWSAuth()` helpers for pre-flight checks
|
||||||
|
- Diff table scroll support (j/k, up/down arrows)
|
||||||
|
- `--command pods` flag for k9s to start directly in pod view
|
||||||
|
- `--namespace` flag for k9s (Windows compatibility)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tmux session cleanup: stale sessions are killed before creating new ones
|
||||||
|
- Panel path: uses `os.Executable()` instead of PATH lookup for correct binary
|
||||||
|
- k9s on Windows: `--namespace` instead of `-n`, direct pods view
|
||||||
|
- `--help` always shows usage and exits (no fallthrough to TUI)
|
||||||
|
- Unknown subcommands show error message + usage (exit 1)
|
||||||
|
- Panel quit: properly closes tmux session and exits
|
||||||
|
- Bubbles list filter disabled to prevent stuck filter state after tmux return
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Config uses `k8s_secret_name_template` for Kubernetes secret names (separate from AWS `secret_name_template`)
|
||||||
|
- Contexts resolved via template (`context_template`) instead of live kubectl discovery
|
||||||
|
- Panel redesigned: env-first menu (quit/beta/prod) with secrets sync + redeploy
|
||||||
|
- Full-mode navigation starts at team selection with default context
|
||||||
|
- `tea.ExecProcess` only used for `tmux attach` — setup commands run synchronously
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Context-pair logic (superseded by env-template based context resolution)
|
||||||
|
- Live kubectl discovery (superseded by config templates)
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release with Bubble Tea TUI
|
||||||
|
- 3-pane tmux orchestration (control panel + 2x k9s)
|
||||||
|
- Team/namespace navigation with kubectl context switching
|
||||||
|
- Panel mode with redeploy and secrets diff/force-sync
|
||||||
|
- AWS SSO integration with interactive login prompt
|
||||||
|
- CI/CD pipeline with Go build and release
|
||||||
|
- Linux/macOS install script
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/skoelle/kctl-tui/compare/v0.2.0...HEAD
|
||||||
|
[0.2.0]: https://github.com/skoelle/kctl-tui/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://github.com/skoelle/kctl-tui/releases/tag/v0.1.0
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Contributing to kctl-tui
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This document explains how to get
|
||||||
|
started.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/skoelle/kctl-tui.git
|
||||||
|
cd kctl-tui
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.25+
|
||||||
|
- kubectl, k9s, tmux (or psmux on Windows)
|
||||||
|
- An active Kubernetes cluster for integration testing
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/kctl-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o kctl-tui ./cmd/kctl-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Version Tag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -ldflags "-X main.version=v0.2.0" -o kctl-tui ./cmd/kctl-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/kctl-tui/ Entry points (main, full mode, panel mode)
|
||||||
|
internal/config/ YAML config loading and template resolution
|
||||||
|
internal/kctl/ Pure logic (secret diffing, template engine)
|
||||||
|
internal/kubeexec/ kubectl/aws/tmux wrappers (side effects only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture Rules
|
||||||
|
|
||||||
|
- **Pure logic** goes into `internal/kctl` or `internal/config` — no exec, no I/O.
|
||||||
|
- **Side effects** (running kubectl, aws, tmux) go into `internal/kubeexec`.
|
||||||
|
- **UI** lives in `cmd/kctl-tui/` — Bubble Tea models, views, handlers.
|
||||||
|
- Unit tests cover pure logic only. Side-effect packages are tested via
|
||||||
|
integration/manual tests.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./... # static analysis
|
||||||
|
go test ./... # unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
There are no integration tests yet. Manual testing against a real cluster is
|
||||||
|
expected for UI and kubeexec changes.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Standard Go formatting (`gofmt`).
|
||||||
|
- No comments unless the logic is non-obvious.
|
||||||
|
- Error messages should be actionable — tell the user what to fix.
|
||||||
|
- Log commands with `kubeexec.VerboseLog()` when `--verbose` is active.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- One logical change per commit.
|
||||||
|
- Imperative mood in commit messages ("Add ...", "Fix ...", "Remove ...").
|
||||||
|
- No co-authors in commits.
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Fork the repo and create a feature branch.
|
||||||
|
2. Make your changes following the style guide above.
|
||||||
|
3. Run `go vet` and `go test`.
|
||||||
|
4. Open a PR against `main` with a clear description of what changed and why.
|
||||||
|
5. Reference any related issues.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
- Use the provided issue templates.
|
||||||
|
- Include your OS, Go version, and kctl-tui version.
|
||||||
|
- For bugs: steps to reproduce, expected vs actual behavior.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the
|
||||||
|
MIT License.
|
||||||
@@ -1,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
|
||||||
|
|||||||
@@ -10,63 +10,148 @@ 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)
|
||||||
|
|
||||||
- [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 (done)
|
||||||
|
|
||||||
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value
|
- [x] 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.
|
||||||
plain strings rather than JSON.
|
|
||||||
- [ ] 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. *(Deferred — superseded by client-go in v0.3.0)*
|
||||||
- [ ] Input validation for the free-text steps in "panel" mode (empty
|
- [x] Input validation for the free-text steps in "panel" mode.
|
||||||
secret ID/region/name, invalid characters).
|
- [x] Graceful handling when `tmux`, `k9s`, or `aws` are not installed.
|
||||||
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
|
- [x] Structured logging / `--verbose` flag for troubleshooting failed
|
||||||
(currently surfaces the raw exec error).
|
`kubectl`/`aws` calls.
|
||||||
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
|
- [x] Paginate/scroll the secrets diff table for secrets with many fields.
|
||||||
`kubectl` calls.
|
|
||||||
|
|
||||||
## 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.
|
- [x] `install.ps1` — PowerShell install script for Windows.
|
||||||
- [ ] Document/implement that `Tab`-based context switching and
|
- [x] Updated README and SPEC with Windows + psmux setup instructions.
|
||||||
`Esc`-triggered session close are **not** available in the native
|
|
||||||
Windows fallback, per SPEC.md 3.6 — the panes must be closed
|
|
||||||
manually there.
|
|
||||||
|
|
||||||
## Phase 4 — Nice-to-haves (open, not committed)
|
## Phase 4 — Nice-to-haves (done for v0.2.0)
|
||||||
|
|
||||||
- [ ] Optional direct use of `client-go` instead of shelling out to
|
- [x] `--version` flag — prints version, set via `-ldflags` at build time.
|
||||||
`kubectl`, for faster context/namespace/label queries.
|
- [x] Config validation command (`kctl-tui config check`) — validates
|
||||||
- [ ] Config validation command (`kctl-tui config check`) that reports
|
required fields and shows a resolved context example.
|
||||||
unknown label keys or context names not present in the current
|
- [x] `kctl-tui doctor` — health check for tools, config, and connectivity.
|
||||||
kubeconfig.
|
- [x] `--help` flag with full usage documentation.
|
||||||
- [ ] Homebrew tap / `scoop` manifest as additional install options
|
- [x] CHANGELOG.md, CONTRIBUTING.md, GitHub Issue/PR templates.
|
||||||
alongside `install.sh`.
|
|
||||||
|
## Phase 5 — Self-update (done)
|
||||||
|
|
||||||
|
- [x] `kctl-tui update` — self-update command using `go-selfupdate` library.
|
||||||
|
Downloads the matching OS/arch asset from GitHub Releases and replaces
|
||||||
|
the running binary atomically.
|
||||||
|
- [x] Interactive update check on startup — queries GitHub Releases in the
|
||||||
|
background, prompts the user to update when a newer version is found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugfix Sprint — between v0.2.0 and v0.3.0
|
||||||
|
|
||||||
|
- [ ] **Dead code** `cmd/kctl-tui/main.go:47-49` — empty
|
||||||
|
`if len(filtered) == 0` block with comment. Remove.
|
||||||
|
- [ ] **Redundant logic** `internal/kctl/diff.go:60-63` —
|
||||||
|
`if lb || rb { match = l == r }` is identical to the line above.
|
||||||
|
Either dead or misunderstood.
|
||||||
|
- [ ] **Diff-scroll is a no-op** `cmd/kctl-tui/panel.go:398-413` —
|
||||||
|
`renderDiffTable` is always called with `visibleHeight=0`, so
|
||||||
|
`end = len(entries)` is always true. j/k/arrows only change the
|
||||||
|
offset text but the table is always fully rendered. The CHANGELOG
|
||||||
|
promises "Diff table scroll support" but the feature is incomplete.
|
||||||
|
- [ ] **README duplicate** `README.md:98-102` — "This downloads the latest
|
||||||
|
release binary..." appears twice (once "to your PATH", once
|
||||||
|
"to /usr/local/bin"). Edit leftover.
|
||||||
|
- [ ] **go mod tidy in CI** `build.yml` — mutates `go.sum` during the
|
||||||
|
build instead of enforcing a tidy check. If someone forgets to tidy,
|
||||||
|
it's silently fixed instead of blocking the PR.
|
||||||
|
- [ ] **Bubbles filter disabled** `full.go:53`, `panel.go:87` — workaround
|
||||||
|
for the stuck-filter bug (commit 7db58b6). Users can no longer
|
||||||
|
type-to-filter. Worth restoring with a proper fix later.
|
||||||
|
- [ ] **Kleinkram:**
|
||||||
|
- `fmt.Errorf("%s", msg)` → `errors.New(msg)` in `kubeexec.go:41`
|
||||||
|
- `helpers.go` is a pointless 1:1 passthrough to the `kctl` package
|
||||||
|
- `IsBinary` also marks UTF-8 special chars (>0x7F) as "binary"
|
||||||
|
|
||||||
|
- [ ] **SECURITY.md** — fehlt, besonders wichtig für ein Tool mit
|
||||||
|
Secret-Workflows.
|
||||||
|
- [ ] **dependabot.yml** — automatische Dependency-Updates.
|
||||||
|
- [ ] **Checksummen für Release-Assets** — CI erzeugt Binaries aber keine
|
||||||
|
`.sha256`-Dateien; für `curl | bash`-Install wichtig.
|
||||||
|
- [ ] **Makefile / justfile** — Build/Test/Vet-Komfort.
|
||||||
|
- [ ] **golangci-lint** — `go vet` allein ist dünn; optional aber empfohlen.
|
||||||
|
- [ ] **PLAN.md aufräumen** — erledigte Phasen als „Done" markieren,
|
||||||
|
offene Items konsolidieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### v0.3.0 — client-go integration
|
||||||
|
|
||||||
|
Replace kubectl shell-outs with direct API calls via `client-go`.
|
||||||
|
|
||||||
|
- [ ] Add `internal/kubeclient` package using `client-go` for:
|
||||||
|
- Context/namespace/label queries (faster than kubectl JSON parsing)
|
||||||
|
- Deployment list and rollout restart/status
|
||||||
|
- Secret fetch (AWS Secrets Manager via SDK, K8s secrets via API)
|
||||||
|
- ExternalSecret annotation update
|
||||||
|
- [ ] Keep `internal/kubeexec` as fallback for operations not yet
|
||||||
|
covered by `client-go`
|
||||||
|
- [ ] Remove `kind`/`k3d` integration test plan (client-go has its own
|
||||||
|
test coverage)
|
||||||
|
- [ ] Add unit tests with `fake.Clientset` for the new package
|
||||||
|
|
||||||
|
### v1.0 — Stable release
|
||||||
|
|
||||||
|
Production-ready with package manager support and documentation.
|
||||||
|
|
||||||
|
- [ ] Homebrew tap (`skoelle/homebrew-tap`) with `kctl-tui` formula
|
||||||
|
- [ ] Scoop manifest (`skoelle/scoop-bucket`) for Windows
|
||||||
|
- [ ] Full test coverage for `internal/kubeclient`
|
||||||
|
- [ ] Documentation: architecture diagram, config reference, troubleshooting
|
||||||
|
- [ ] Semantic versioning policy documented
|
||||||
|
- [ ] Deprecation policy for config schema changes
|
||||||
|
|
||||||
## Notes for contributors
|
## Notes for contributors
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,104 @@
|
|||||||
# 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
|
||||||
- Namespace grouping by an arbitrary, configurable **label** instead of
|
the context screen just one `Esc` away.
|
||||||
|
- 🏷️ 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.
|
||||||
|
|
||||||
## How it works
|
## 🔧 How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| 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> --namespace <ns> --command pods |
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| k9s --context <context-b> -n <namespace> |
|
| k9s --context <resolved prod context> --namespace <ns> --command pods |
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
- `k9s` (used for the two status panes).
|
context names/ARNs are resolved from your `context_template`, see
|
||||||
- `tmux` (used for the 3-pane layout). On Windows, this means running
|
Configuration below - they must already exist in your kubeconfig, e.g.
|
||||||
kctl-tui inside **WSL** — `tmux` has no native Windows port. Native
|
added via `aws eks update-kubeconfig`).
|
||||||
Windows Terminal has its own split-pane feature, but it cannot be
|
- 👀 `k9s` (used for the two status panes).
|
||||||
scripted from inside a pane the way `tmux` can, so the automated 3-pane
|
- 📺 `tmux` (used for the 3-pane layout). On **Linux/macOS**, install
|
||||||
layout and the `Tab`/`Esc` session handling described above are only
|
`tmux` via your package manager. On **Windows**, install
|
||||||
fully supported under Linux/WSL. See SPEC.md section 3.6 for details.
|
[psmux](https://github.com/marlocarlo/psmux) — a native,
|
||||||
- `aws` CLI, configured with credentials, only needed for the secrets
|
tmux-compatible terminal multiplexer:
|
||||||
|
```powershell
|
||||||
|
scoop install psmux
|
||||||
|
# or
|
||||||
|
cargo install psmux
|
||||||
|
```
|
||||||
|
psmux provides a `tmux` command, so kctl-tui works without changes.
|
||||||
|
- ☁️ `aws` CLI, configured with credentials, only needed for the secrets
|
||||||
workflow.
|
workflow.
|
||||||
|
|
||||||
## Installation
|
## 📥 Installation
|
||||||
|
|
||||||
### Quick install (Linux/macOS/WSL)
|
### 🚀 Quick install (Linux/macOS/WSL)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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 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`.
|
||||||
|
|
||||||
### From source
|
### 🛠️ From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/skoelle/kctl-tui.git
|
git clone https://github.com/skoelle/kctl-tui.git
|
||||||
@@ -86,54 +107,122 @@ go build -o kctl-tui ./cmd/kctl-tui
|
|||||||
sudo mv kctl-tui /usr/local/bin/
|
sudo mv kctl-tui /usr/local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.22+.
|
Requires Go 1.25+.
|
||||||
|
|
||||||
### Prebuilt binaries
|
### 📦 Prebuilt binaries
|
||||||
|
|
||||||
Every tagged release (`vX.Y.Z`) is built for `linux`, `darwin`, and
|
Every tagged release (`vX.Y.Z`) is built for `linux`, `darwin`, and
|
||||||
`windows`, each for `amd64` and `arm64`, via the GitHub Actions workflow in
|
`windows`, each for `amd64` and `arm64`, via the GitHub Actions workflow in
|
||||||
[.github/workflows/build.yml](.github/workflows/build.yml). Download the
|
[.github/workflows/build.yml](.github/workflows/build.yml). Download the
|
||||||
matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/releases).
|
matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/releases).
|
||||||
|
|
||||||
## 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 — or use `kctl-tui config edit` to open the
|
||||||
|
file directly in your editor (creates the file and directory if needed):
|
||||||
|
|
||||||
```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"
|
||||||
|
external_secret_name_template: "{namespace}"
|
||||||
|
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
|
||||||
- `team_label_key`: the Kubernetes namespace label used to group
|
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}`.
|
||||||
|
- 🎯 `external_secret_name_template`: builds the ExternalSecret CRD object
|
||||||
|
name to annotate when a force-sync is requested. This is often different
|
||||||
|
from the Kubernetes secret name because the ExternalSecret CRD and the
|
||||||
|
resulting Secret are separate objects (e.g. ExternalSecret `"job-apply"`
|
||||||
|
produces Secret `"job-apply-common-secrets"`). Falls back to
|
||||||
|
`k8s_secret_name_template` if omitted. Placeholders: `{namespace}`.
|
||||||
|
- 🔗 `context_template`: builds the actual kubectl context name/ARN from
|
||||||
|
region, account ID, environment, and context. Placeholders: `{region}`,
|
||||||
|
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
|
||||||
|
`-1`, cluster naming, ARN shape) to match how your own clusters/contexts
|
||||||
|
are actually named — the resolved value must match an existing context
|
||||||
|
in your kubeconfig.
|
||||||
|
- 👥 `team_label_key`: the Kubernetes namespace label used to group
|
||||||
namespaces by team/ownership in the team-selection screen. This is
|
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
|
## 🪟 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## 📖 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 update # update to the latest release
|
||||||
|
kctl-tui doctor # check if all tools, config and connections are OK
|
||||||
|
kctl-tui config edit # open ~/.kctl-tui/config.yaml in your editor
|
||||||
|
kctl-tui config check # validate ~/.kctl-tui/config.yaml
|
||||||
|
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
|
||||||
|
```
|
||||||
|
|
||||||
|
When starting `kctl-tui` without a subcommand, a background check queries
|
||||||
|
GitHub Releases for a newer version. If one is found, you are prompted to
|
||||||
|
update interactively before the TUI starts.
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./...
|
go test ./...
|
||||||
@@ -141,12 +230,12 @@ 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
|
||||||
|
|
||||||
[MIT](LICENSE)
|
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
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 / Windows** (primary usage scenario, since
|
||||||
panes require a real terminal multiplexer). Native Windows (without WSL)
|
split panes require a real terminal multiplexer). On Windows, use
|
||||||
is possible but with reduced split-view functionality (see 3.6).
|
[psmux](https://github.com/marlocarlo/psmux) as tmux-compatible
|
||||||
|
multiplexer.
|
||||||
|
|
||||||
**Technology decision: Go + Bubble Tea** (see section 5).
|
**Technology decision: Go + Bubble Tea** (see section 5).
|
||||||
|
|
||||||
@@ -49,9 +50,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 +62,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:
|
||||||
@@ -78,9 +76,9 @@ Once start navigation is complete, the tool opens a tmux session with
|
|||||||
| -> runs the kctl-tui binary in "panel" mode |
|
| -> runs the kctl-tui binary in "panel" mode |
|
||||||
| -> menu: Redeploy, secrets diff |
|
| -> menu: Redeploy, secrets diff |
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| Pane 1 (middle): k9s --context <context-a> -n <ns>|
|
| Pane 1 (middle): k9s --context <context-a> --namespace <ns> --command pods|
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| Pane 2 (bottom): k9s --context <context-b> -n <ns>|
|
| Pane 2 (bottom): k9s --context <context-b> --namespace <ns> --command pods|
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,11 +90,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 --namespace $NS --command pods" \; \
|
||||||
select-layout main-horizontal \; \
|
split-window -v -t kctl:0.1 "k9s --context $CTX_B --namespace $NS --command pods" \; \
|
||||||
|
select-layout -t kctl even-vertical \; \
|
||||||
|
select-pane -t kctl:0.0 \; \
|
||||||
attach -t kctl
|
attach -t kctl
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,63 +117,72 @@ 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
|
- **Platforms**: Linux, macOS, Windows (via psmux or WSL).
|
||||||
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`/`psmux`, `k9s`,
|
||||||
depending on the action used.
|
`aws-cli` depending on the action used.
|
||||||
- **Low startup time**, noticeably faster than the current `kubens`
|
- **Low startup time**, noticeably faster than the current `kubens`
|
||||||
experience.
|
experience.
|
||||||
- **No destructive actions without confirmation** (redeploy, force-sync).
|
- **No destructive actions without confirmation** (redeploy, force-sync).
|
||||||
@@ -233,16 +244,14 @@ Rejected options (see discussion history):
|
|||||||
- These fixes are a prerequisite before the tool can be meaningfully
|
- These fixes are a prerequisite before the tool can be meaningfully
|
||||||
tested, since it builds directly on `kubectl config`.
|
tested, since it builds directly on `kubectl config`.
|
||||||
|
|
||||||
## 8. Open items / out of scope (v1)
|
## 8. Open items / out of scope
|
||||||
|
|
||||||
- No automatic label setup for namespaces (migration is a separate,
|
- No automatic label setup for namespaces (migration is a separate,
|
||||||
one-time task).
|
one-time task).
|
||||||
- Split view v1 fixed at 2 status panes + 1 control pane (3 panes total).
|
- Split view 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
|
|
||||||
pane handling instead of an automated tmux lifecycle.
|
|
||||||
|
|||||||
+168
-56
@@ -1,8 +1,14 @@
|
|||||||
|
// 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"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
@@ -20,9 +26,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
|
||||||
@@ -39,51 +48,50 @@ type fullModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newFullModel() *fullModel {
|
func newFullModel() *fullModel {
|
||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, newCompactDelegate(), 0, 0)
|
||||||
l.Title = "kctl-tui"
|
l.Title = "kctl-tui"
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
return &fullModel{list: l, state: screenContext}
|
l.SetFilteringEnabled(false)
|
||||||
|
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 +108,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 +122,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 +169,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 +190,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,8 +198,17 @@ 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 {
|
tool := "tmux"
|
||||||
return m, func() tea.Msg { return errMsg{err} }
|
if runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt" {
|
||||||
|
tool = "wt"
|
||||||
|
}
|
||||||
|
if err := kubeexec.CheckTool(tool); 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()
|
||||||
}
|
}
|
||||||
@@ -186,11 +216,20 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadTeams() tea.Msg {
|
func (m *fullModel) loadTeams() tea.Msg {
|
||||||
namespaces, err := kubeexec.GetNamespacesWithLabels()
|
merged := map[string]map[string]string{}
|
||||||
if err != nil {
|
for _, env := range m.cfg.Envs {
|
||||||
return errMsg{err}
|
ctx := m.cfg.ResolveContext(env, m.selectedContext)
|
||||||
|
namespaces, err := kubeexec.GetNamespacesWithLabels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for ns, labels := range namespaces {
|
||||||
|
if _, exists := merged[ns]; !exists {
|
||||||
|
merged[ns] = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return *toTeamsLoadedMsg(namespaces, m.cfg.TeamLabelKey)
|
return *toTeamsLoadedMsg(merged, m.cfg.TeamLabelKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadTeamsFor(namespaces map[string]map[string]string) tea.Cmd {
|
func (m *fullModel) loadTeamsFor(namespaces map[string]map[string]string) tea.Cmd {
|
||||||
@@ -216,6 +255,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,29 +267,101 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTmuxSession builds the 3-pane tmux command (control pane running
|
// startTmuxSession builds the 3-pane session: 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
|
||||||
|
// visible side by side. On Windows with multiplexer: "wt", this uses
|
||||||
|
// Windows Terminal's native split-pane instead of tmux/psmux.
|
||||||
func (m *fullModel) startTmuxSession() tea.Cmd {
|
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||||
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
if runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt" {
|
||||||
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s",
|
return m.startWtSession()
|
||||||
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
|
||||||
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
|
|
||||||
|
|
||||||
secondCtx := m.selectedContext
|
|
||||||
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok {
|
|
||||||
secondCtx = next
|
|
||||||
}
|
}
|
||||||
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace)
|
return m.startTmuxSessionTmux()
|
||||||
|
}
|
||||||
|
|
||||||
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
// startTmuxSessionTmux creates the session using tmux/psmux.
|
||||||
panelCmd, ";",
|
func (m *fullModel) startTmuxSessionTmux() tea.Cmd {
|
||||||
"split-window", "-v", k9sCmdA, ";",
|
selfPath, err := os.Executable()
|
||||||
"split-window", "-v", k9sCmdB, ";",
|
if err != nil {
|
||||||
"select-layout", "main-horizontal", ";",
|
selfPath = "kctl-tui" // fallback to PATH lookup
|
||||||
"attach", "-t", "kctl",
|
}
|
||||||
|
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
|
||||||
|
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
||||||
|
|
||||||
|
envA := m.cfg.Envs[0]
|
||||||
|
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
|
||||||
|
k9sCmdA := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxA, m.selectedNamespace)
|
||||||
|
|
||||||
|
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
|
||||||
|
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
|
||||||
|
|
||||||
|
// Kill stale session first (ignore error if none exists).
|
||||||
|
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
|
||||||
|
|
||||||
|
// Run setup commands individually — this avoids the ; separator
|
||||||
|
// issue on Windows where psmux doesn't handle chained args.
|
||||||
|
setup := [][]string{
|
||||||
|
{"new-session", "-d", "-s", "kctl", panelCmd},
|
||||||
|
{"set-option", "-t", "kctl", "remain-on-exit", "on"},
|
||||||
|
{"split-window", "-v", "-t", "kctl:0.0", k9sCmdA},
|
||||||
|
}
|
||||||
|
if len(m.cfg.Envs) > 1 {
|
||||||
|
envB := m.cfg.Envs[1]
|
||||||
|
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
|
||||||
|
k9sCmdB := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxB, m.selectedNamespace)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdB=%s\n", k9sCmdB)
|
||||||
|
setup = append(setup, []string{"split-window", "-v", "-t", "kctl:0.1", k9sCmdB})
|
||||||
|
}
|
||||||
|
setup = append(setup,
|
||||||
|
[]string{"select-layout", "-t", "kctl", "even-vertical"},
|
||||||
|
[]string{"select-pane", "-t", "kctl:0.0"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for _, args := range setup {
|
||||||
|
if out, err := exec.Command("tmux", args...).CombinedOutput(); err != nil {
|
||||||
|
kubeexec.VerboseLog("[debug] tmux %s failed: %v\n%s\n", args[0], err, out)
|
||||||
|
return func() tea.Msg { return tmuxDoneMsg{err: fmt.Errorf("tmux %s: %w", args[0], err)} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only attach uses tea.ExecProcess so it takes over the terminal.
|
||||||
|
c := exec.Command("tmux", "attach", "-t", "kctl")
|
||||||
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||||
|
return tmuxDoneMsg{err: err}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWtSession creates the session using Windows Terminal's native
|
||||||
|
// split-pane feature. This avoids the psmux focus-freeze issue on Windows.
|
||||||
|
func (m *fullModel) startWtSession() tea.Cmd {
|
||||||
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
selfPath = "kctl-tui"
|
||||||
|
}
|
||||||
|
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
|
||||||
|
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
||||||
|
|
||||||
|
envA := m.cfg.Envs[0]
|
||||||
|
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
|
||||||
|
k9sCmdA := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxA, m.selectedNamespace)
|
||||||
|
|
||||||
|
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
|
||||||
|
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
|
||||||
|
|
||||||
|
args := []string{"new-tab", panelCmd, ";", "split-pane", "-V", k9sCmdA}
|
||||||
|
|
||||||
|
if len(m.cfg.Envs) > 1 {
|
||||||
|
envB := m.cfg.Envs[1]
|
||||||
|
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
|
||||||
|
k9sCmdB := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxB, m.selectedNamespace)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdB=%s\n", k9sCmdB)
|
||||||
|
args = append(args, ";", "split-pane", "-V", k9sCmdB)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := exec.Command("wt", 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}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -10,6 +13,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
// 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/charmbracelet/bubbles/list"
|
||||||
|
|
||||||
// simpleItem is a minimal implementation of list.Item used for all
|
// simpleItem is a minimal implementation of list.Item used for all
|
||||||
// selection screens (contexts, teams, namespaces, menu actions).
|
// selection screens (contexts, teams, namespaces, menu actions).
|
||||||
type simpleItem struct {
|
type simpleItem struct {
|
||||||
@@ -10,3 +15,13 @@ type simpleItem struct {
|
|||||||
func (i simpleItem) Title() string { return i.label }
|
func (i simpleItem) Title() string { return i.label }
|
||||||
func (i simpleItem) Description() string { return "" }
|
func (i simpleItem) Description() string { return "" }
|
||||||
func (i simpleItem) FilterValue() string { return i.label }
|
func (i simpleItem) FilterValue() string { return i.label }
|
||||||
|
|
||||||
|
// newCompactDelegate returns a list delegate that renders each item as a
|
||||||
|
// single line with no extra spacing, maximizing the number of visible
|
||||||
|
// entries in the terminal.
|
||||||
|
func newCompactDelegate() list.DefaultDelegate {
|
||||||
|
d := list.NewDefaultDelegate()
|
||||||
|
d.ShowDescription = false
|
||||||
|
d.SetSpacing(0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|||||||
+317
-4
@@ -1,19 +1,103 @@
|
|||||||
|
// 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"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
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)
|
||||||
|
fmt.Println("Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)")
|
||||||
|
fmt.Println("Licensed under the MIT License")
|
||||||
|
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
|
||||||
|
case "update":
|
||||||
|
if err := runUpdate(verbose); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "kctl-tui update 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
|
}
|
||||||
|
|
||||||
|
// Load config early to check auto_update_update and validate.
|
||||||
|
cfgPath, err := config.DefaultPath()
|
||||||
|
if err == nil {
|
||||||
|
cfg, cfgErr := config.Load(cfgPath)
|
||||||
|
if cfgErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARNING: failed to load config: %v\n", cfgErr)
|
||||||
|
}
|
||||||
|
if cfgErr == nil && cfg.IsAutoUpdateCheckEnabled() {
|
||||||
|
if checkForUpdateInteractive(verbose) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m := newFullModel()
|
m := newFullModel()
|
||||||
@@ -23,3 +107,232 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Print(`kctl-tui — Kubernetes entry-point TUI
|
||||||
|
https://github.com/skoelle/kctl-tui
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
kctl-tui [flags] start the TUI (full navigation mode)
|
||||||
|
kctl-tui doctor check tools, config and connections
|
||||||
|
kctl-tui update update to the latest release
|
||||||
|
kctl-tui config edit open ~/.kctl-tui/config.yaml in editor
|
||||||
|
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 update # update to the latest version
|
||||||
|
kctl-tui --verbose 2>debug.log # log commands to a file
|
||||||
|
kctl-tui config edit # open config in editor
|
||||||
|
kctl-tui config check # validate config
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfig(args []string) error {
|
||||||
|
if len(args) > 0 && args[0] == "check" {
|
||||||
|
return runConfigCheck()
|
||||||
|
}
|
||||||
|
if len(args) == 0 || args[0] == "edit" {
|
||||||
|
return runConfigEdit()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("usage: kctl-tui config [check|edit]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigEdit() error {
|
||||||
|
cfgPath, err := config.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine config path: %w", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(cfgPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("cannot create config directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("# kctl-tui configuration\n# See https://github.com/skoelle/kctl-tui for examples.\n"), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("cannot create config file %s: %w", cfgPath, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created new config file: %s\n", cfgPath)
|
||||||
|
}
|
||||||
|
editor := os.Getenv("VISUAL")
|
||||||
|
if editor == "" {
|
||||||
|
editor = os.Getenv("EDITOR")
|
||||||
|
}
|
||||||
|
if editor == "" {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
editor = "notepad"
|
||||||
|
default:
|
||||||
|
editor = "vim"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd := exec.Command(editor, cfgPath)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigCheck() error {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+330
-165
@@ -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 (
|
||||||
@@ -6,95 +9,139 @@ 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)
|
||||||
|
externalSecretName string // resolved via external_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, newCompactDelegate(), 0, 0)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
|
||||||
|
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
|
||||||
|
if loadErr != nil {
|
||||||
|
m.err = fmt.Errorf("config load failed: %w", loadErr)
|
||||||
|
m.step = stepError
|
||||||
|
} else {
|
||||||
|
m.showEnvMenu()
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuItems() []list.Item {
|
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 +150,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 +173,181 @@ 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)
|
||||||
|
m.externalSecretName = m.cfg.ResolveExternalSecretName(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 +355,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 +464,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.externalSecretName)
|
||||||
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 ""
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Masterminds/semver/v3"
|
||||||
|
"github.com/creativeprojects/go-selfupdate"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
githubSlug = "skoelle/kctl-tui"
|
||||||
|
updateTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func initUpdater(verbose bool) (*selfupdate.Updater, error) {
|
||||||
|
if verbose {
|
||||||
|
selfupdate.SetLogger(&verboseLogger{})
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to init GitHub source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return selfupdate.NewUpdater(selfupdate.Config{
|
||||||
|
Source: source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUpdate(verbose bool) error {
|
||||||
|
if version == "dev" {
|
||||||
|
fmt.Fprintln(os.Stderr, "WARNING: running dev build — cannot compare versions")
|
||||||
|
fmt.Fprintln(os.Stderr, "Skipping version check. Build from a tagged release to enable self-update.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updater, err := initUpdater(verbose)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repo := selfupdate.ParseSlug(githubSlug)
|
||||||
|
rel, found, err := updater.DetectLatest(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for updates: %w", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
fmt.Println("Already up-to-date.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
current, _ := semver.NewVersion(version)
|
||||||
|
newVersion := rel.Version()
|
||||||
|
|
||||||
|
newVer, _ := semver.NewVersion(newVersion)
|
||||||
|
|
||||||
|
if current != nil && !current.LessThan(newVer) {
|
||||||
|
fmt.Println("Already up-to-date.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Current version: %s\n", version)
|
||||||
|
fmt.Printf("Found version %s. Updating...\n", newVersion)
|
||||||
|
|
||||||
|
if err := updater.UpdateTo(ctx, rel, ""); err != nil {
|
||||||
|
return fmt.Errorf("update failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Updated from %s to %s\n", current, newVersion)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForUpdateInteractive checks for a new version and prompts the user to update.
|
||||||
|
// Returns true if an update was applied.
|
||||||
|
func checkForUpdateInteractive(verbose bool) bool {
|
||||||
|
if version == "dev" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
updater, err := initUpdater(verbose)
|
||||||
|
if err != nil {
|
||||||
|
if verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repo := selfupdate.ParseSlug(githubSlug)
|
||||||
|
rel, found, err := updater.DetectLatest(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
if verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
current, _ := semver.NewVersion(version)
|
||||||
|
newVersion := rel.Version()
|
||||||
|
newVer, _ := semver.NewVersion(newVersion)
|
||||||
|
|
||||||
|
if current != nil && !current.LessThan(newVer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("New version %s available (current: %s). Update now? [y/N] ", newVersion, version)
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
answer, _ := reader.ReadString('\n')
|
||||||
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||||
|
|
||||||
|
if answer != "y" && answer != "yes" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Updating...")
|
||||||
|
if err := updater.UpdateTo(ctx, rel, ""); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Update failed: %v\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Updated to %s. Please restart kctl-tui.\n", newVersion)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type verboseLogger struct{}
|
||||||
|
|
||||||
|
func (l *verboseLogger) Print(v ...any) {
|
||||||
|
fmt.Fprint(os.Stderr, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *verboseLogger) Printf(format string, v ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format, v...)
|
||||||
|
}
|
||||||
+74
-14
@@ -1,19 +1,79 @@
|
|||||||
# 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 ExternalSecret CRD object name to annotate when a force-sync
|
||||||
|
# is requested. This is often different from the Kubernetes secret name
|
||||||
|
# because the ExternalSecret CRD and the resulting Secret are separate
|
||||||
|
# objects (e.g. ExternalSecret "job-apply" produces Secret
|
||||||
|
# "job-apply-common-secrets"). Falls back to k8s_secret_name_template
|
||||||
|
# if omitted. Available placeholders: {namespace}.
|
||||||
|
external_secret_name_template: "{namespace}"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Check for updates on startup and prompt to update if a newer version is
|
||||||
|
# available. Set to false to disable. Defaults to true if omitted.
|
||||||
|
# auto_update_check: true
|
||||||
|
|
||||||
|
# Terminal multiplexer backend. "tmux" (default) uses tmux/psmux.
|
||||||
|
# "wt" uses Windows Terminal's native split-pane — avoids the psmux
|
||||||
|
# focus-freeze issue on Windows. Only effective on Windows.
|
||||||
|
# multiplexer: "tmux"
|
||||||
|
|||||||
@@ -1,10 +1,52 @@
|
|||||||
module github.com/skoelle/kctl-tui
|
module github.com/skoelle/kctl-tui
|
||||||
|
|
||||||
go 1.22
|
go 1.25.12
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.20.0
|
github.com/Masterminds/semver/v3 v3.5.0
|
||||||
github.com/charmbracelet/bubbletea v1.1.1
|
github.com/charmbracelet/bubbles v0.21.1
|
||||||
github.com/charmbracelet/lipgloss v1.0.0
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/creativeprojects/go-selfupdate v1.6.0
|
||||||
|
golang.org/x/term v0.44.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
code.gitea.io/sdk/gitea v0.23.2 // indirect
|
||||||
|
github.com/42wim/httpsig v1.2.4 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
|
github.com/google/go-github/v86 v86.0.0 // indirect
|
||||||
|
github.com/google/go-querystring v1.2.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.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.19 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect
|
||||||
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||||
|
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||||
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw=
|
||||||
|
github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
|
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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA=
|
||||||
|
github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8=
|
||||||
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
|
github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls=
|
||||||
|
github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||||
|
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||||
|
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
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.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
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=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24=
|
||||||
|
gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||||
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
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."
|
||||||
+42
-4
@@ -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).
|
||||||
@@ -37,13 +39,50 @@ case "$os" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
echo "Detecting latest release for $REPO ..."
|
echo "Detecting latest release for $REPO ..."
|
||||||
latest_tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
|
|
||||||
|
|
||||||
if [ -z "$latest_tag" ]; then
|
release_json="$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest")" || {
|
||||||
echo "Could not determine latest release tag. Is there at least one published release?" >&2
|
echo "Failed to reach the GitHub API (network error). Check your internet connection and try again." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Received ${#release_json} bytes from the GitHub API."
|
||||||
|
|
||||||
|
if echo "$release_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "Could not find a published release for ${REPO}." >&2
|
||||||
|
echo "This usually means no release has been tagged yet." >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Options:" >&2
|
||||||
|
echo " 1) Ask the maintainer to push a version tag (e.g. 'git tag v0.1.0 && git push origin v0.1.0')," >&2
|
||||||
|
echo " which triggers the release build via GitHub Actions." >&2
|
||||||
|
echo " 2) Build from source instead:" >&2
|
||||||
|
echo " git clone https://github.com/${REPO}.git" >&2
|
||||||
|
echo " cd $(basename "$REPO")" >&2
|
||||||
|
echo " go build -o ${BIN_NAME} ./cmd/${BIN_NAME}" >&2
|
||||||
|
echo " sudo mv ${BIN_NAME} ${INSTALL_DIR}/" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if echo "$release_json" | grep -qi 'rate limit exceeded'; then
|
||||||
|
echo "GitHub API rate limit exceeded. Wait a bit and try again, or authenticate with a GitHub token." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Note: '|| true' below prevents 'set -o pipefail' + 'set -e' from aborting the
|
||||||
|
# script silently if grep finds no match; the emptiness check right after
|
||||||
|
# gives a proper diagnostic instead.
|
||||||
|
latest_tag="$(echo "$release_json" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" || true
|
||||||
|
|
||||||
|
if [ -z "$latest_tag" ]; then
|
||||||
|
echo "Could not parse the latest release tag from the GitHub API response." >&2
|
||||||
|
echo "Raw response (truncated):" >&2
|
||||||
|
echo "$release_json" | head -c 800 >&2
|
||||||
|
echo "" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Latest release tag: ${latest_tag}"
|
||||||
|
|
||||||
asset="kctl-tui-${os}-${arch}"
|
asset="kctl-tui-${os}-${arch}"
|
||||||
url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}"
|
url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}"
|
||||||
|
|
||||||
@@ -60,5 +99,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."
|
||||||
|
|||||||
+162
-6
@@ -1,5 +1,8 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
// Package config loads the user-specific, non-versioned kctl-tui
|
// 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 +14,162 @@ 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"`
|
||||||
|
|
||||||
|
// ExternalSecretNameTemplate builds the ExternalSecret CRD object
|
||||||
|
// name that should be annotated when a force-sync is requested. This
|
||||||
|
// is often different from the Kubernetes secret name because the
|
||||||
|
// ExternalSecret CRD and the resulting Secret are separate objects.
|
||||||
|
// Falls back to K8sSecretNameTemplate if empty.
|
||||||
|
ExternalSecretNameTemplate string `yaml:"external_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"`
|
||||||
|
|
||||||
|
// AutoUpdateCheck controls whether kctl-tui checks for updates on
|
||||||
|
// startup. Defaults to true when omitted.
|
||||||
|
AutoUpdateCheck *bool `yaml:"auto_update_check"`
|
||||||
|
|
||||||
|
// Multiplexer selects the terminal multiplexer for the session.
|
||||||
|
// "tmux" (default) uses tmux/psmux. "wt" uses Windows Terminal's
|
||||||
|
// native split-pane (only effective on Windows).
|
||||||
|
Multiplexer string `yaml:"multiplexer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAutoUpdateCheckEnabled returns true unless the user has explicitly set
|
||||||
|
// auto_update_check to false in their config.
|
||||||
|
func (c Config) IsAutoUpdateCheckEnabled() bool {
|
||||||
|
if c.AutoUpdateCheck == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *c.AutoUpdateCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiplexerBackend returns the configured multiplexer backend,
|
||||||
|
// falling back to "tmux" if not set.
|
||||||
|
func (c Config) MultiplexerBackend() string {
|
||||||
|
if c.Multiplexer == "" {
|
||||||
|
return "tmux"
|
||||||
|
}
|
||||||
|
return c.Multiplexer
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveExternalSecretName builds the ExternalSecret CRD object name for
|
||||||
|
// a given namespace using ExternalSecretNameTemplate. Falls back to
|
||||||
|
// K8sSecretNameTemplate (or SecretNameTemplate if that is also empty) so
|
||||||
|
// that existing configs keep working without changes.
|
||||||
|
func (c Config) ResolveExternalSecretName(namespace string) string {
|
||||||
|
template := c.ExternalSecretNameTemplate
|
||||||
|
if template == "" {
|
||||||
|
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 +182,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{}
|
||||||
|
|
||||||
|
|||||||
+118
-10
@@ -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 (
|
||||||
@@ -11,19 +14,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 +47,106 @@ 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 TestResolveExternalSecretName_ExplicitTemplate(t *testing.T) {
|
||||||
|
cfg := Config{ExternalSecretNameTemplate: "{namespace}"}
|
||||||
|
got := cfg.ResolveExternalSecretName("job-apply")
|
||||||
|
want := "job-apply"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveExternalSecretName_FallsBackToK8sSecretNameTemplate(t *testing.T) {
|
||||||
|
cfg := Config{K8sSecretNameTemplate: "{namespace}-common-secrets"}
|
||||||
|
got := cfg.ResolveExternalSecretName("job-apply")
|
||||||
|
want := "job-apply-common-secrets"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveExternalSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
|
||||||
|
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
|
||||||
|
got := cfg.ResolveExternalSecretName("job-apply")
|
||||||
|
want := "tf-job-apply-{env}-secrets" // {env} intentionally left unresolved
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
package kctl
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// SecretDiffEntry represents the comparison of one key between two secret
|
||||||
|
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
|
||||||
|
type SecretDiffEntry struct {
|
||||||
|
Key string
|
||||||
|
Left string // e.g. the AWS Secrets Manager value
|
||||||
|
Right string // e.g. the decoded Kubernetes secret value
|
||||||
|
Match bool
|
||||||
|
LeftBin bool // true if Left contains non-printable (binary) data
|
||||||
|
RightBin bool // true if Right contains non-printable (binary) data
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBinary reports whether s contains non-printable bytes (i.e. is likely
|
||||||
|
// binary data rather than human-readable text). Control characters below
|
||||||
|
// space (0x20) are excluded, except for common whitespace (\t, \n, \r).
|
||||||
|
func IsBinary(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 0x7f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r < 0x20 && r != '\t' && r != '\n' && r != '\r' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffSecretValues compares two key/value maps and returns a sorted list of
|
||||||
|
// diff entries covering the union of keys present in either map. A key that
|
||||||
|
// only exists on one side is still reported, with the missing side left as
|
||||||
|
// an empty string and Match set to false (unless both sides happen to be
|
||||||
|
// empty strings).
|
||||||
|
func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for k := range left {
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
for k := range right {
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(seen))
|
||||||
|
for k := range seen {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
result := make([]SecretDiffEntry, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
l := left[k]
|
||||||
|
r := right[k]
|
||||||
|
lb := IsBinary(l)
|
||||||
|
rb := IsBinary(r)
|
||||||
|
match := l == r
|
||||||
|
if lb || rb {
|
||||||
|
match = l == r
|
||||||
|
}
|
||||||
|
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: match, LeftBin: lb, RightBin: rb})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyMismatch reports whether at least one diff entry does not match.
|
||||||
|
func AnyMismatch(entries []SecretDiffEntry) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
package kctl
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDiffSecretValues_AllMatch(t *testing.T) {
|
||||||
|
left := map[string]string{"a": "1", "b": "2"}
|
||||||
|
right := map[string]string{"a": "1", "b": "2"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected no mismatch, got %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_Mismatch(t *testing.T) {
|
||||||
|
left := map[string]string{"a": "1", "b": "2"}
|
||||||
|
right := map[string]string{"a": "1", "b": "different"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
if !AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected a mismatch, got %v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bEntry *SecretDiffEntry
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Key == "b" {
|
||||||
|
bEntry = &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bEntry == nil || bEntry.Match {
|
||||||
|
t.Fatalf("expected key 'b' to be a mismatch, got %v", bEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_KeyOnlyOnOneSide(t *testing.T) {
|
||||||
|
left := map[string]string{"a": "1", "only-left": "x"}
|
||||||
|
right := map[string]string{"a": "1", "only-right": "y"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("expected 3 entries (union of keys), got %d: %v", len(entries), entries)
|
||||||
|
}
|
||||||
|
if !AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected mismatch due to keys only present on one side")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_EmptyMaps(t *testing.T) {
|
||||||
|
entries := DiffSecretValues(nil, nil)
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected no entries for empty maps, got %v", entries)
|
||||||
|
}
|
||||||
|
if AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected no mismatch for empty maps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBinary_Plaintext(t *testing.T) {
|
||||||
|
if IsBinary("hello world") {
|
||||||
|
t.Fatal("expected plaintext to not be binary")
|
||||||
|
}
|
||||||
|
if IsBinary("line1\nline2\ttab") {
|
||||||
|
t.Fatal("expected newline/tab to not be binary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBinary_BinaryData(t *testing.T) {
|
||||||
|
if !IsBinary("hello\x00world") {
|
||||||
|
t.Fatal("expected null byte to be binary")
|
||||||
|
}
|
||||||
|
if !IsBinary("key=\xff\xfe") {
|
||||||
|
t.Fatal("expected non-ASCII bytes to be binary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_BinaryDetection(t *testing.T) {
|
||||||
|
left := map[string]string{"ok": "text", "bin": "data\x00here"}
|
||||||
|
right := map[string]string{"ok": "text", "bin": "data\x00here"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Key == "bin" {
|
||||||
|
if !e.LeftBin || !e.RightBin {
|
||||||
|
t.Fatalf("expected binary flags set for key 'bin', got LeftBin=%v RightBin=%v", e.LeftBin, e.RightBin)
|
||||||
|
}
|
||||||
|
if !e.Match {
|
||||||
|
t.Fatalf("expected binary values to match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Key == "ok" {
|
||||||
|
if e.LeftBin || e.RightBin {
|
||||||
|
t.Fatalf("expected binary flags unset for key 'ok'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
package kctl
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ResolveTemplate replaces "{key}" placeholders in template with the
|
||||||
|
// corresponding value from values. Placeholders with no matching key are
|
||||||
|
// left untouched, so a misconfigured template is visible (e.g. a literal
|
||||||
|
// "{typo}" in the result) instead of silently collapsing to an empty
|
||||||
|
// string.
|
||||||
|
func ResolveTemplate(template string, values map[string]string) string {
|
||||||
|
result := template
|
||||||
|
for k, v := range values {
|
||||||
|
result = strings.ReplaceAll(result, "{"+k+"}", v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
package kctl
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveTemplate_SinglePlaceholder(t *testing.T) {
|
||||||
|
got := ResolveTemplate("secret-{namespace}", map[string]string{"namespace": "example-ns"})
|
||||||
|
want := "secret-example-ns"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplate_MultiplePlaceholders(t *testing.T) {
|
||||||
|
template := "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
|
||||||
|
values := map[string]string{
|
||||||
|
"region": "eu-central-1",
|
||||||
|
"account_id": "123456789012",
|
||||||
|
"env": "beta",
|
||||||
|
"context": "internal",
|
||||||
|
}
|
||||||
|
got := ResolveTemplate(template, values)
|
||||||
|
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplate_UnknownPlaceholderLeftAsIs(t *testing.T) {
|
||||||
|
got := ResolveTemplate("secret-{unknown}", map[string]string{"namespace": "example-ns"})
|
||||||
|
want := "secret-{unknown}"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplate_EmptyTemplate(t *testing.T) {
|
||||||
|
got := ResolveTemplate("", map[string]string{"namespace": "example-ns"})
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("expected empty result, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-59
@@ -1,7 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
// Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui.
|
// 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
|
||||||
// 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 +22,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 +65,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 +83,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 +98,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 +145,51 @@ 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 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"schedule": ["before 6am on Monday"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "GitHub Actions",
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"groupName": "GitHub Actions (major)",
|
||||||
|
"labels": ["major-update"],
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "Go dependencies",
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"groupName": "Go dependencies (major)",
|
||||||
|
"labels": ["major-update"],
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"labels": ["major-update"],
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user