Compare commits

...
12 Commits
Author SHA1 Message Date
Stefan Koelle 79c861682b Add AWS auth check before secrets workflow, with interactive SSO login prompt on expired session 2026-08-09 12:40:02 +02:00
Stefan Koelle c229f4d5d8 Add AWS auth check (sts get-caller-identity) and configurable SSO login command 2026-08-09 12:37:51 +02:00
Stefan Koelle 9daba2a3f0 fix: show a visible error screen instead of silently swallowing errors on resetToMenu 2026-08-09 12:31:43 +02:00
Stefan Koelle 4be10b6432 docs: update PLAN.md to reflect the redesigned secrets workflow (list + diff-all-fields + whole-secret force-sync) 2026-08-09 12:15:05 +02:00
Stefan Koelle e57d58aecc Redesign secrets workflow: pick AWS secret from a list, diff all fields at once, offer force-sync for the whole secret 2026-08-09 12:14:01 +02:00
Stefan Koelle b7d553c966 Add ListAWSSecrets and GetSecretAllFields for the redesigned secrets diff flow 2026-08-09 12:10:59 +02:00
Stefan Koelle 793c28afff Add pure secret-diff logic (kctl.DiffSecretValues) with unit tests 2026-08-09 12:09:25 +02:00
stefankoelle 9162eebccc fix install.sh 2026-08-09 12:03:05 +02:00
Stefan Koelle 26b5754f60 fix: use even-vertical tmux layout and remain-on-exit so panes stack correctly and errors stay visible 2026-08-09 12:01:50 +02:00
Stefan Koelle 76a389f52d install.sh: prevent silent abort from pipefail in tag_name extraction, add diagnostics 2026-08-09 11:57:06 +02:00
Stefan Koelle 73b38ef52c install.sh: read GitHub API response into a variable instead of a temp file (fixes curl exit 23) 2026-08-09 11:50:49 +02:00
Stefan Koelle c10b553b48 install.sh: fail with a clear message when no GitHub release exists yet 2026-08-09 11:48:06 +02:00
10 changed files with 476 additions and 118 deletions
+33 -15
View File
@@ -10,44 +10,59 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
attaches binaries to GitHub Releases on version tags.
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release
asset.
asset, with clear diagnostics if no release exists yet or the GitHub
API is unreachable.
- [x] `README.md`, `config.example.yaml`.
## Phase 1 — Core logic + navigation (done, initial version)
- [x] `internal/kctl`: pure, unit-tested logic —
context-pair matching (`FindNextContext`) and namespace/label
filtering (`DistinctLabelValues`, `NamespacesForLabelValue`).
context-pair matching (`FindNextContext`), namespace/label filtering
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
(`DiffSecretValues`, `AnyMismatch`).
- [x] `internal/config`: YAML config loading (`context_pairs`,
`team_label_key`), with safe defaults when no config file exists yet.
- [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
(contexts, namespaces, deployments, rollout restart/status, secret
read, ExternalSecret annotation).
(contexts, namespaces, deployments, rollout restart/status, listing
AWS secrets, reading all fields of a Kubernetes secret, ExternalSecret
annotation).
- [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
context -> team -> namespace, with `Esc` correctly popping back one
level at a time, defaults pre-selected from the currently active
context/namespace.
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
session (control pane + two `k9s` panes) via `tea.ExecProcess` and
resumes at the namespace screen once the session ends.
- [x] `cmd/kctl-tui` "panel" mode: menu for Redeploy and the AWS/Kubernetes
secrets diff + force-sync wizard, with `Esc` closing the whole tmux
session (`tmux kill-session`).
session (control pane + two `k9s` panes, `even-vertical` layout,
`remain-on-exit` so a crashing control pane stays visible) via
`tea.ExecProcess` and resumes at the namespace screen once the
session ends.
- [x] `cmd/kctl-tui` "panel" mode:
- Redeploy: pick a deployment from a list, confirm, then
`rollout restart` + `rollout status`.
- Secrets: pick an AWS region, then pick the actual secret from a
**list of all AWS Secrets Manager secrets** in that region (no more
manual secret-ID typing), enter the matching Kubernetes secret
name, and automatically diff **every field** of both secrets 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), not per individual field.
- `Esc` closes the whole tmux session (`tmux kill-session`).
## Phase 2 — Hardening (open)
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value
formatting with a proper typed decode, and handle secrets that are
plain strings rather than JSON.
- [ ] Handle non-JSON AWS secrets and Kubernetes secrets with binary
(non-UTF8) values more gracefully in the diff table (currently
falls back to a single "value" key or may render oddly).
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
CI for the `kubeexec` wrappers currently excluded from automated
testing.
- [ ] Input validation for the free-text steps in "panel" mode (empty
secret ID/region/name, invalid characters).
region/secret name, invalid characters).
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
(currently surfaces the raw exec error).
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
`kubectl` calls.
`kubectl`/`aws` calls.
- [ ] Paginate/scroll the secrets diff table for secrets with many fields
instead of relying on terminal wrapping.
## Phase 3 — Windows-native support (open, secondary priority)
@@ -67,6 +82,9 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
kubeconfig.
- [ ] Homebrew tap / `scoop` manifest as additional install options
alongside `install.sh`.
- [ ] Optional heuristic to suggest a matching Kubernetes secret name for
a chosen AWS secret (e.g. by common naming convention), instead of
always asking for it manually.
## Notes for contributors
+10 -3
View File
@@ -230,6 +230,11 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
// startTmuxSession builds the 3-pane tmux command (control pane running
// this binary in "panel" mode, plus two k9s status panes) and runs it via
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal.
//
// Layout: even-vertical stacks all three panes evenly from top to bottom
// (control pane, then the two k9s status panes). remain-on-exit keeps a
// pane visible (showing its exit status/output) instead of tmux silently
// closing it if the control pane's process crashes on startup.
func (m *fullModel) startTmuxSession() tea.Cmd {
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s",
@@ -244,9 +249,11 @@ func (m *fullModel) startTmuxSession() tea.Cmd {
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
panelCmd, ";",
"split-window", "-v", k9sCmdA, ";",
"split-window", "-v", k9sCmdB, ";",
"select-layout", "main-horizontal", ";",
"set-option", "-t", "kctl", "remain-on-exit", "on", ";",
"split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";",
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";",
"select-layout", "-t", "kctl", "even-vertical", ";",
"select-pane", "-t", "kctl:0.0", ";",
"attach", "-t", "kctl",
)
+8
View File
@@ -13,3 +13,11 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
return kctl.FindNextContext(current, pairs)
}
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
return kctl.DiffSecretValues(left, right)
}
func anyMismatch(entries []kctl.SecretDiffEntry) bool {
return kctl.AnyMismatch(entries)
}
+185 -88
View File
@@ -6,12 +6,15 @@ import (
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/skoelle/kctl-tui/internal/config"
"github.com/skoelle/kctl-tui/internal/kctl"
"github.com/skoelle/kctl-tui/internal/kubeexec"
)
@@ -22,32 +25,31 @@ const (
stepMenu panelStep = iota
stepRedeployList
stepRedeployConfirm
stepSecretID
stepAWSAuthPrompt
stepSecretRegion
stepSecretKeyList
stepSecretList
stepK8sSecretName
stepK8sFieldName
stepDiffResult
stepForceSyncConfirm
stepExternalSecretName
stepDone
stepError
)
type panelModel struct {
ctx, ns, team string
cfg config.Config
step panelStep
list list.Model
input textinput.Model
awsSecretID string
awsRegion string
awsSecretID string
awsValues map[string]string
selectedKey string
awsValue string
k8sSecretName string
k8sFieldName string
k8sValue string
k8sValues map[string]string
diffEntries []kctl.SecretDiffEntry
message string
err error
@@ -76,7 +78,10 @@ func newPanelModel(ctx, ns, team string) *panelModel {
ti := textinput.New()
ti.Focus()
return &panelModel{ctx: ctx, ns: ns, team: team, step: stepMenu, list: l, input: ti}
cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath)
return &panelModel{ctx: ctx, ns: ns, team: team, cfg: cfg, step: stepMenu, list: l, input: ti}
}
func menuItems() []list.Item {
@@ -89,12 +94,17 @@ func menuItems() []list.Item {
func (m *panelModel) Init() tea.Cmd { return nil }
type awsLoginDoneMsg struct{ err error }
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
return m, nil
case awsLoginDoneMsg:
return m.afterAWSLogin(msg.err)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
@@ -119,7 +129,7 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *panelModel) usesTextInput() bool {
switch m.step {
case stepSecretID, stepSecretRegion, stepK8sSecretName, stepK8sFieldName, stepExternalSecretName:
case stepSecretRegion, stepK8sSecretName, stepExternalSecretName:
return true
}
return false
@@ -140,29 +150,21 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
return m.fromRedeployList()
case stepRedeployConfirm:
return m.fromRedeployConfirm()
case stepSecretID:
m.awsSecretID = m.input.Value()
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
return m, nil
case stepAWSAuthPrompt:
return m.fromAWSAuthPrompt()
case stepSecretRegion:
m.awsRegion = m.input.Value()
return m.fetchAWSSecret()
case stepSecretKeyList:
return m.fromSecretKeyList()
return m.fetchSecretList()
case stepSecretList:
return m.fromSecretList()
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()
return m.compareAllFields()
case stepForceSyncConfirm:
return m.fromForceSyncConfirm()
case stepExternalSecretName:
return m.doForceSync()
case stepDiffResult, stepDone:
case stepDiffResult, stepDone, stepError:
m.resetToMenu()
return m, nil
}
@@ -173,6 +175,17 @@ func (m *panelModel) resetToMenu() {
m.step = stepMenu
m.list.SetItems(menuItems())
m.list.Title = "kctl-tui panel"
m.message = ""
m.err = nil
}
// showError switches to a dedicated error screen so failures from
// kubectl/aws calls stay visible until the user explicitly acknowledges
// them with Enter, instead of being silently discarded.
func (m *panelModel) showError(err error) (tea.Model, tea.Cmd) {
m.err = err
m.step = stepError
return m, nil
}
func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
@@ -184,8 +197,7 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
case "redeploy":
deployments, err := kubeexec.GetDeployments(m.ns)
if err != nil {
m.err = err
return m, nil
return m.showError(err)
}
items := make([]list.Item, 0, len(deployments))
for _, d := range deployments {
@@ -195,21 +207,69 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
m.list.Title = "Select deployment to restart (esc = back)"
m.step = stepRedeployList
case "secrets":
m.step = stepSecretID
m.input.SetValue("")
m.input.Placeholder = "AWS secret ID"
return m.checkAWSAuthAndProceed()
case "quit":
return m.handleEsc()
}
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.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
}
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
return m, nil
}
func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "login" {
m.resetToMenu()
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))
}
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
return m, nil
}
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedKey = item.value // reused as "deployment name" here
m.k8sSecretName = item.value // reused as "deployment name" here
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"},
@@ -225,86 +285,117 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
m.resetToMenu()
return m, nil
}
_, err := kubeexec.RolloutRestart(m.ns, m.selectedKey)
deployment := m.k8sSecretName // set in fromRedeployList
_, err := kubeexec.RolloutRestart(m.ns, deployment)
if err != nil {
m.err = err
return m.showError(err)
}
status, err := kubeexec.RolloutStatus(m.ns, deployment)
if err != nil {
return m.showError(err)
}
status, _ := kubeexec.RolloutStatus(m.ns, m.selectedKey)
m.message = "Rollout status: " + status
m.step = stepDone
return m, nil
}
func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) {
// fetchSecretList lists all AWS Secrets Manager secrets in the chosen
// region so the user can pick one instead of typing the exact secret ID.
func (m *panelModel) fetchSecretList() (tea.Model, tea.Cmd) {
names, err := kubeexec.ListAWSSecrets(m.awsRegion)
if err != nil {
return m.showError(err)
}
if len(names) == 0 {
return m.showError(fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion))
}
items := make([]list.Item, 0, len(names))
for _, n := range names {
items = append(items, simpleItem{label: n, value: n})
}
m.list.SetItems(items)
m.list.Title = "Select AWS secret (esc = back to menu)"
m.step = stepSecretList
return m, nil
}
func (m *panelModel) fromSecretList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.awsSecretID = item.value
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
return m.showError(err)
}
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
m.awsValues = map[string]string{"__raw__": raw}
// 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)
}
}
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"
m.input.Placeholder = "Kubernetes secret name (in namespace " + m.ns + ")"
return m, nil
}
func (m *panelModel) compareSecret() (tea.Model, tea.Cmd) {
b64, err := kubeexec.GetSecretValueBase64(m.ns, m.k8sSecretName, m.k8sFieldName)
// compareAllFields fetches every field of the Kubernetes secret and diffs
// it against every key of the AWS secret in one go, instead of requiring
// the user to pick a single field.
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
k8sValues, err := kubeexec.GetSecretAllFields(m.ns, m.k8sSecretName)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
return m.showError(err)
}
decoded, err := kubeexec.DecodeBase64(b64)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
}
m.k8sValue = decoded
m.k8sValues = k8sValues
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.message = renderDiffTable(m.awsSecretID, m.k8sSecretName, m.diffEntries)
if m.awsValue == m.k8sValue {
m.message = "IDENTICAL\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
m.step = stepDiffResult
return m, nil
}
m.message = "DIFFERENT\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync", value: "yes"},
simpleItem{label: "Yes, request force-sync for this secret", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
} else {
m.step = stepDiffResult
}
return m, nil
}
func renderDiffTable(awsSecretID, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
var b strings.Builder
fmt.Fprintf(&b, "AWS secret: %s Kubernetes secret: %s\n\n", awsSecretID, k8sSecretName)
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
for _, e := range entries {
status := "OK"
if !e.Match {
status = "MISMATCH"
}
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(e.Left, 20), truncate(e.Right, 20), status)
}
return b.String()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}
func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" {
@@ -312,7 +403,7 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
return m, nil
}
m.step = stepExternalSecretName
m.input.SetValue("")
m.input.SetValue(m.k8sSecretName)
m.input.Placeholder = "ExternalSecret object name"
return m, nil
}
@@ -322,23 +413,33 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts)
if err != nil {
m.err = err
return m.showError(err)
}
m.message = "Force-sync requested (timestamp " + strconv.FormatInt(ts, 10) + ")."
m.message += fmt.Sprintf("\nForce-sync requested for %s (timestamp %s).", name, strconv.FormatInt(ts, 10))
m.step = stepDone
return m, nil
}
func (m *panelModel) View() string {
switch m.step {
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretKeyList, stepForceSyncConfirm:
v := m.list.View()
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList:
return m.list.View()
case stepAWSAuthPrompt:
errText := ""
if m.err != nil {
v += "\nerror: " + m.err.Error()
errText = m.err.Error() + "\n\n"
}
return v
return errText + m.list.View()
case stepForceSyncConfirm:
return m.message + "\n\n" + m.list.View()
case stepDiffResult, stepDone:
return m.message + "\n\n(press enter to return to menu, esc to close session)"
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 menu, esc to close session)"
default:
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)",
m.stepPrompt(), m.input.View())
@@ -347,14 +448,10 @@ func (m *panelModel) View() string {
func (m *panelModel) stepPrompt() string {
switch m.step {
case stepSecretID:
return "AWS Secrets Manager: enter secret ID"
case stepSecretRegion:
return "AWS region"
return "AWS region to list secrets from"
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"
}
+7
View File
@@ -17,3 +17,10 @@ context_pairs:
# team-selection screen. Adjust this to whatever label your organization
# actually uses (can contain a domain prefix, e.g. "example.org/team").
team_label_key: "example.org/team"
# Command used to (re-)authenticate with AWS before the Secrets workflow,
# if 'aws sts get-caller-identity' fails (e.g. an expired AWS SSO session).
# Defaults to "aws sso login" if omitted. Override this if your organization
# wraps SSO login in a custom script or needs a specific --profile, e.g.:
# aws_sso_login_command: "aws sso login --profile my-profile"
aws_sso_login_command: "aws sso login"
+40 -4
View File
@@ -37,13 +37,50 @@ case "$os" in
esac
echo "Detecting latest release for $REPO ..."
latest_tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
if [ -z "$latest_tag" ]; then
echo "Could not determine latest release tag. Is there at least one published release?" >&2
release_json="$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest")" || {
echo "Failed to reach the GitHub API (network error). Check your internet connection and try again." >&2
exit 1
}
echo "Received ${#release_json} bytes from the GitHub API."
if echo "$release_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then
echo "" >&2
echo "Could not find a published release for ${REPO}." >&2
echo "This usually means no release has been tagged yet." >&2
echo "" >&2
echo "Options:" >&2
echo " 1) Ask the maintainer to push a version tag (e.g. 'git tag v0.1.0 && git push origin v0.1.0')," >&2
echo " which triggers the release build via GitHub Actions." >&2
echo " 2) Build from source instead:" >&2
echo " git clone https://github.com/${REPO}.git" >&2
echo " cd $(basename "$REPO")" >&2
echo " go build -o ${BIN_NAME} ./cmd/${BIN_NAME}" >&2
echo " sudo mv ${BIN_NAME} ${INSTALL_DIR}/" >&2
exit 1
fi
if echo "$release_json" | grep -qi 'rate limit exceeded'; then
echo "GitHub API rate limit exceeded. Wait a bit and try again, or authenticate with a GitHub token." >&2
exit 1
fi
# Note: '|| true' below prevents 'set -o pipefail' + 'set -e' from aborting the
# script silently if grep finds no match; the emptiness check right after
# gives a proper diagnostic instead.
latest_tag="$(echo "$release_json" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" || true
if [ -z "$latest_tag" ]; then
echo "Could not parse the latest release tag from the GitHub API response." >&2
echo "Raw response (truncated):" >&2
echo "$release_json" | head -c 800 >&2
echo "" >&2
exit 1
fi
echo "Latest release tag: ${latest_tag}"
asset="kctl-tui-${os}-${arch}"
url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}"
@@ -60,5 +97,4 @@ else
fi
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}"
"${INSTALL_DIR}/${BIN_NAME}" --help >/dev/null 2>&1 || true
echo "Done. Run '${BIN_NAME}' to get started."
+16
View File
@@ -11,10 +11,26 @@ import (
"github.com/skoelle/kctl-tui/internal/kctl"
)
// DefaultAWSSSOLoginCommand is used when the user has not configured a
// custom login command in their config.yaml.
const DefaultAWSSSOLoginCommand = "aws sso login"
// Config is the root structure of ~/.kctl-tui/config.yaml
type Config struct {
ContextPairs []kctl.ContextPair `yaml:"context_pairs"`
TeamLabelKey string `yaml:"team_label_key"`
AWSSSOLoginCommand string `yaml:"aws_sso_login_command"`
}
// LoginCommand returns the configured AWS SSO login command, falling back
// to DefaultAWSSSOLoginCommand if none is set. Organizations that wrap
// SSO login in a custom script (e.g. to select a specific profile) can
// override this via aws_sso_login_command in config.yaml.
func (c Config) LoginCommand() string {
if c.AWSSSOLoginCommand == "" {
return DefaultAWSSSOLoginCommand
}
return c.AWSSSOLoginCommand
}
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
+51
View File
@@ -0,0 +1,51 @@
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
}
// 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]
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: l == r})
}
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
}
+59
View File
@@ -0,0 +1,59 @@
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")
}
}
+59
View File
@@ -121,6 +121,30 @@ func GetSecretValueBase64(namespace, secretName, field string) (string, error) {
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path)
}
// GetSecretAllFields returns all fields of a Kubernetes secret, already
// base64-decoded into plain values.
func GetSecretAllFields(namespace, secretName string) (map[string]string, error) {
out, err := runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", "json")
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.
func DecodeBase64(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
@@ -138,6 +162,21 @@ func AnnotateForceSync(namespace, externalSecretName string, unixTimestamp int64
externalSecretName, annotation, "--overwrite")
}
// ListAWSSecrets returns all AWS Secrets Manager secret names/IDs visible
// in the given region (subject to the caller's IAM permissions).
func ListAWSSecrets(region string) ([]string, error) {
out, err := runOutput("aws", "secretsmanager", "list-secrets",
"--region", region, "--query", "SecretList[].Name", "--output", "json")
if err != nil {
return nil, err
}
var names []string
if err := json.Unmarshal([]byte(out), &names); err != nil {
return nil, err
}
return names, nil
}
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
// secret via the aws-cli.
func GetAWSSecretString(secretID, region string) (string, error) {
@@ -145,3 +184,23 @@ func GetAWSSecretString(secretID, region string) (string, error) {
"--secret-id", secretID, "--region", region,
"--query", "SecretString", "--output", "text")
}
// CheckAWSAuth performs a cheap, fast call to verify the current AWS
// credentials/SSO session are valid. Returns nil if authenticated, or the
// underlying error (e.g. an expired SSO session) otherwise.
func CheckAWSAuth() error {
_, err := runOutput("aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
return err
}
// RunAWSLogin returns an *exec.Cmd for the given login command (e.g.
// "aws sso login"), split on whitespace. The caller is responsible for
// running it interactively (e.g. via tea.ExecProcess) since SSO login
// typically requires opening a browser and confirming a device code.
func RunAWSLogin(loginCommand string) *exec.Cmd {
parts := strings.Fields(loginCommand)
if len(parts) == 0 {
parts = []string{"aws", "sso", "login"}
}
return exec.Command(parts[0], parts[1:]...)
}