Compare commits

...
11 Commits
Author SHA1 Message Date
Stefan Koelle 4bb3507aec Use separate templates for AWS secret ID and Kubernetes secret name 2026-08-09 14:39:32 +02:00
Stefan Koelle 145288971a Add separate k8s_secret_name_template, since Kubernetes secret names follow a different pattern than AWS secret names 2026-08-09 14:36:44 +02:00
Stefan Koelle 75e0b832a2 fix: compute Kubernetes secret name from secret_name_template too, no more manual prompt 2026-08-09 14:31:24 +02:00
Stefan Koelle 0f889de86f docs: update README for the redesigned env-menu control pane and template-based config 2026-08-09 14:14:34 +02:00
Stefan Koelle ff898f727a Redesign panel: env-first menu (quit/beta/prod, each with secrets sync + redeploy), templated AWS secret ID instead of listing/region input 2026-08-09 14:05:33 +02:00
Stefan Koelle f7606011bb Redesign full-mode navigation: start at team selection with default context, resolve k9s panes from config envs instead of context-pairs 2026-08-09 14:02:55 +02:00
Stefan Koelle c02e145e4a Rework kubeexec to take explicit --context per call instead of mutating global kubectl state; drop functions superseded by config templates 2026-08-09 14:01:06 +02:00
Stefan Koelle 929b073382 Redesign config schema: contexts + envs + AWS region + secret/context templates instead of live kubectl discovery and context-pairs 2026-08-09 13:58:19 +02:00
Stefan Koelle 3af468de98 Remove context-pair tests, superseded by env-template based context resolution 2026-08-09 13:54:01 +02:00
Stefan Koelle 0a607bab28 Remove context-pair logic, superseded by env-template based context resolution 2026-08-09 13:51:36 +02:00
Stefan Koelle 1f87cdbf08 Add pure template-resolution logic (kctl.ResolveTemplate) with unit tests 2026-08-09 13:49:38 +02:00
12 changed files with 632 additions and 444 deletions
+88 -48
View File
@@ -1,27 +1,35 @@
# kctl-tui # kctl-tui
A small terminal entry point for everyday Kubernetes work: pick a context, A small terminal entry point for everyday Kubernetes work: pick a context
a team, and a namespace once, then drive status (via k9s), rollout and a namespace once, then drive rollout restarts and an AWS Secrets
restarts, and an AWS Secrets Manager <-> Kubernetes Secret diff/force-sync Manager <-> Kubernetes Secret diff/force-sync per environment from one
workflow from one place instead of retyping long `kubectl` commands. place instead of retyping long `kubectl` commands.
## Why ## Why
Working with several clusters, many namespaces per team, and paired Working with several clusters, many namespaces per team, and paired
environments (e.g. staging/production) quickly turns into a lot of repeated environments (e.g. beta/prod) quickly turns into a lot of repeated typing
typing with plain `kubectl`/`k9s`. kctl-tui adds: with plain `kubectl`/`k9s`. kctl-tui adds:
- A guided **context -> team -> namespace** selection with sensible - A guided **context -> team -> namespace** selection that starts
defaults (the currently active context/namespace is pre-selected). directly at team selection (using a configured default context), with
the context screen just one `Esc` away.
- Namespace grouping by an arbitrary, configurable **label** instead of - Namespace grouping by an arbitrary, configurable **label** instead of
scrolling through every namespace in the cluster. scrolling through every namespace in the cluster.
- A **3-pane view** (via `tmux`): one control pane for actions, two status - A **3-pane view** (via `tmux`): one control pane for actions, two status
panes running `k9s` for the current namespace across two related panes running `k9s` for the current namespace across your two
contexts. configured environments (e.g. beta/prod), shown side by side.
- A guided **rollout restart** that lists deployments instead of requiring - A control-pane menu organized **by environment**: pick beta or prod,
you to know/type the exact deployment name. then Secrets sync or Redeploy for that environment specifically.
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison, - AWS Secrets Manager secret IDs and Kubernetes context names/ARNs are
including an optional ExternalSecret force-sync annotation. **computed from configurable templates** (namespace + environment),
instead of listing secrets or discovering contexts live from
`kubectl`/`aws-cli`.
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison of
every field at once, with a force-sync request for the whole secret if
anything differs.
- An **AWS auth check** before the secrets workflow, offering to run your
configured SSO login command interactively if the session has expired.
See [SPEC.md](SPEC.md) for the full requirements and design rationale, and See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
[PLAN.md](PLAN.md) for the implementation roadmap and current status. [PLAN.md](PLAN.md) for the implementation roadmap and current status.
@@ -31,38 +39,41 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
``` ```
+--------------------------------------------------+ +--------------------------------------------------+
| Control pane: kctl-tui panel | | Control pane: kctl-tui panel |
| -> Redeploy, Secrets diff/force-sync | | -> 1) Quit 2) beta 3) prod |
| each with: a) Secrets sync b) Redeploy |
+--------------------------------------------------+ +--------------------------------------------------+
| k9s --context <context-a> -n <namespace> | | k9s --context <resolved beta context> -n <ns> |
+--------------------------------------------------+ +--------------------------------------------------+
| k9s --context <context-b> -n <namespace> | | k9s --context <resolved prod context> -n <ns> |
+--------------------------------------------------+ +--------------------------------------------------+
``` ```
1. Run `kctl-tui`. It walks you through context, team, and namespace 1. Run `kctl-tui`. It loads your config, applies the default context, and
selection. jumps straight to team selection; press `Esc` there to pick a
2. Once a namespace is confirmed, it opens a `tmux` session with the layout different context first.
above and attaches to it. 2. Pick a team (namespace label filter), then a namespace.
3. Inside the control pane you can trigger a rollout restart or compare/ 3. It opens a `tmux` session with the layout above: the control pane runs
force-sync a secret. The two status panes keep showing live pod state this binary in "panel" mode, the two status panes run `k9s` against
via `k9s`, so there is no separate "status" menu entry. your first two configured environments (e.g. beta and prod), resolved
4. Pressing `Esc` in the control pane closes the whole `tmux` session from `context_template`.
(including both `k9s` panes) and returns you to the namespace 4. In the control pane, pick an environment, then Secrets sync or
selection. Redeploy for that environment. `Esc` goes back one level (action menu
5. Pressing `Tab` in the control pane switches both status panes to the -> environment menu -> closes the whole tmux session, including both
paired context configured in `context_pairs` (see Configuration), `k9s` panes, and returns you to namespace selection).
keeping the same namespace.
## Requirements ## Requirements
- `kubectl`, configured with access to your cluster(s). - `kubectl`, configured with access to your cluster(s) (the actual
context names/ARNs are resolved from your `context_template`, see
Configuration below - they must already exist in your kubeconfig, e.g.
added via `aws eks update-kubeconfig`).
- `k9s` (used for the two status panes). - `k9s` (used for the two status panes).
- `tmux` (used for the 3-pane layout). On Windows, this means running - `tmux` (used for the 3-pane layout). On Windows, this means running
kctl-tui inside **WSL**`tmux` has no native Windows port. Native kctl-tui inside **WSL**`tmux` has no native Windows port. Native
Windows Terminal has its own split-pane feature, but it cannot be Windows Terminal has its own split-pane feature, but it cannot be
scripted from inside a pane the way `tmux` can, so the automated 3-pane scripted from inside a pane the way `tmux` can, so the automated 3-pane
layout and the `Tab`/`Esc` session handling described above are only layout and the `Esc` session handling described above are only fully
fully supported under Linux/WSL. See SPEC.md section 3.6 for details. supported under Linux/WSL. See SPEC.md section 3.6 for details.
- `aws` CLI, configured with credentials, only needed for the secrets - `aws` CLI, configured with credentials, only needed for the secrets
workflow. workflow.
@@ -98,29 +109,58 @@ matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/rele
## Configuration ## Configuration
Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml` Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml`
and adjust it to your own cluster setup: and adjust it to your own setup:
```yaml ```yaml
context_pairs:
- name: "example-environment-pair"
contexts: contexts:
- "example-context-a" - "internal"
- "example-context-b" - "external"
default_context: "internal"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
team_label_key: "example.org/team" team_label_key: "example.org/team"
aws_sso_login_command: "aws sso login"
``` ```
- `context_pairs`: groups of related `kubectl` contexts. `Tab` in the - `contexts` / `default_context`: the top-level grouping the tool starts
control pane cycles through the contexts of whichever group the current from (e.g. a network boundary such as internal/external-facing
context belongs to. clusters). This is the outermost navigation level, one `Esc` above team
selection.
- `envs`: the environments switchable from the control panel (e.g.
"beta"/"prod"). The **first two** entries are also used for the two k9s
status panes shown side by side.
- `aws_region` / `aws_account_id`: used for AWS Secrets Manager calls and
to fill the `{account_id}` placeholder in `context_template`.
`123456789012` is a placeholder, not a real account.
- `secret_name_template`: builds the AWS Secrets Manager secret ID from
the chosen namespace and environment. Placeholders: `{namespace}`,
`{env}`.
- `context_template`: builds the actual kubectl context name/ARN from
region, account ID, environment, and context. Placeholders: `{region}`,
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
`-1`, cluster naming, ARN shape) to match how your own clusters/contexts
are actually named — the resolved value must match an existing context
in your kubeconfig.
- `team_label_key`: the Kubernetes namespace label used to group - `team_label_key`: the Kubernetes namespace label used to group
namespaces by team/ownership in the team-selection screen. This is namespaces by team/ownership in the team-selection screen. This is
entirely up to your organization's labeling convention; kctl-tui ships entirely up to your organization's labeling convention; kctl-tui ships
with no default team label of its own. with no default team label of its own.
- `aws_sso_login_command`: run interactively if `aws sts
get-caller-identity` fails before the secrets workflow (e.g. an expired
SSO session). Defaults to `aws sso login`.
`~/.kctl-tui/config.yaml` is not part of this repository and should stay `~/.kctl-tui/config.yaml` is not part of this repository and should stay
that way — it typically contains your organization's internal context and that way — it typically contains your organization's internal account ID,
label names. context naming, and label names.
## WSL setup notes ## WSL setup notes
@@ -141,11 +181,11 @@ go vet ./...
go build ./cmd/kctl-tui go build ./cmd/kctl-tui
``` ```
Pure logic (context-pair matching, label filtering, config parsing) lives Pure logic (template resolution, label filtering, config parsing, secret
in `internal/kctl` and `internal/config` and is covered by unit tests. Code diffing) lives in `internal/kctl` and `internal/config` and is covered by
that shells out to `kubectl`/`aws`/`tmux` lives in `internal/kubeexec` and unit tests. Code that shells out to `kubectl`/`aws`/`tmux` lives in
in `cmd/kctl-tui` and is intentionally kept thin and untested, since it has `internal/kubeexec` and in `cmd/kctl-tui` and is intentionally kept thin
no meaningful behavior without a live cluster. and untested, since it has no meaningful behavior without a live cluster.
## License ## License
+68 -48
View File
@@ -20,9 +20,12 @@ const (
screenNamespace screenNamespace
) )
// fullModel drives the interactive context -> team -> namespace navigation // fullModel drives the interactive navigation. It starts directly at the
// and, once a namespace is chosen, launches the 3-pane tmux session // team-selection screen using the configured default context, and only
// (control pane + two k9s panes) via tea.ExecProcess. // shows the context screen when the user explicitly goes back via Esc.
// Once a namespace is chosen, it launches the 3-pane tmux session
// (control pane + two k9s panes, one per configured env) via
// tea.ExecProcess.
type fullModel struct { type fullModel struct {
list list.Model list list.Model
state screenState state screenState
@@ -42,48 +45,43 @@ func newFullModel() *fullModel {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "kctl-tui" l.Title = "kctl-tui"
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
return &fullModel{list: l, state: screenContext} return &fullModel{list: l}
} }
func (m *fullModel) Init() tea.Cmd { func (m *fullModel) Init() tea.Cmd {
return m.loadContexts return m.bootstrap
} }
func (m *fullModel) loadContexts() tea.Msg { // bootstrap loads the config and applies the default context so the tool
contexts, err := kubeexec.GetContexts() // can jump straight to the team-selection screen.
func (m *fullModel) bootstrap() tea.Msg {
cfgPath, _ := config.DefaultPath()
cfg, err := config.Load(cfgPath)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
current := kubeexec.GetCurrentContext() if len(cfg.Contexts) == 0 {
return errMsg{fmt.Errorf("no 'contexts' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath)
items := make([]list.Item, 0, len(contexts))
if current != "" {
items = append(items, simpleItem{label: "(current) " + current, value: current})
} }
for _, c := range contexts { if len(cfg.Envs) == 0 {
if c != current { return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
items = append(items, simpleItem{label: c, value: c})
} }
} return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
return contextsLoadedMsg{items: items, cfg: cfg}
} }
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 +98,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 +112,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 +159,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 +180,6 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
switch m.state { switch m.state {
case screenContext: case screenContext:
m.selectedContext = item.value m.selectedContext = item.value
if err := kubeexec.UseContext(item.value); err != nil {
return m, func() tea.Msg { return errMsg{err} }
}
return m, m.loadTeams return m, m.loadTeams
case screenTeam: case screenTeam:
@@ -177,16 +188,24 @@ 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 {
return m, func() tea.Msg { return errMsg{err} }
}
return m, m.startTmuxSession() return m, m.startTmuxSession()
} }
return m, nil return m, nil
} }
// bootstrapContext resolves a kubectl context purely to discover
// namespaces/labels for the team/namespace screens. The first configured
// env is used as a stable default for this discovery step, since
// namespace names are assumed to be identical across envs.
func (m *fullModel) bootstrapContext() string {
if len(m.cfg.Envs) == 0 {
return ""
}
return m.cfg.ResolveContext(m.cfg.Envs[0], m.selectedContext)
}
func (m *fullModel) loadTeams() tea.Msg { func (m *fullModel) loadTeams() tea.Msg {
namespaces, err := kubeexec.GetNamespacesWithLabels() namespaces, err := kubeexec.GetNamespacesWithLabels(m.bootstrapContext())
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -227,25 +246,26 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
} }
} }
// startTmuxSession builds the 3-pane tmux command (control pane running // startTmuxSession builds the 3-pane tmux command: the control pane runs
// this binary in "panel" mode, plus two k9s status panes) and runs it via // this binary in "panel" mode (letting the user pick an env and an
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal. // action), and the two status panes run k9s against the first two
// // configured envs, resolved via the context template, so both are
// Layout: even-vertical stacks all three panes evenly from top to bottom // visible side by side.
// (control pane, then the two k9s status panes). remain-on-exit keeps a
// pane visible (showing its exit status/output) instead of tmux silently
// closing it if the control pane's process crashes on startup.
func (m *fullModel) startTmuxSession() tea.Cmd { func (m *fullModel) startTmuxSession() tea.Cmd {
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s", panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam) selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
secondCtx := m.selectedContext envA := m.cfg.Envs[0]
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok { envB := m.cfg.Envs[0]
secondCtx = next if len(m.cfg.Envs) > 1 {
envB = m.cfg.Envs[1]
} }
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace) ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace)
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl", c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
panelCmd, ";", panelCmd, ";",
-4
View File
@@ -10,10 +10,6 @@ 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) {
return kctl.FindNextContext(current, pairs)
}
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry { func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
return kctl.DiffSecretValues(left, right) return kctl.DiffSecretValues(left, right)
} }
+147 -147
View File
@@ -18,17 +18,15 @@ import (
"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
stepAWSAuthPrompt stepAWSAuthPrompt
stepSecretRegion
stepSecretList
stepK8sSecretName
stepDiffResult stepDiffResult
stepForceSyncConfirm stepForceSyncConfirm
stepExternalSecretName stepExternalSecretName
@@ -37,17 +35,20 @@ const (
) )
type panelModel struct { type panelModel struct {
ctx, ns, team string context, ns, team string
cfg config.Config cfg config.Config
currentEnv string
step panelStep step panelStep
list list.Model list list.Model
input textinput.Model input textinput.Model
awsRegion string deploymentName string
awsSecretID string
awsSecretName string // resolved via secret_name_template (namespace + env)
k8sSecretName string // resolved via k8s_secret_name_template (namespace only)
awsValues map[string]string awsValues map[string]string
k8sSecretName string
k8sValues map[string]string k8sValues map[string]string
diffEntries []kctl.SecretDiffEntry diffEntries []kctl.SecretDiffEntry
@@ -57,39 +58,64 @@ type panelModel struct {
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()
cfgPath, _ := config.DefaultPath() cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath) cfg, _ := config.Load(cfgPath)
return &panelModel{ctx: ctx, ns: ns, team: team, cfg: cfg, step: stepMenu, list: l, input: ti} l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.SetShowStatusBar(false)
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
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 }
@@ -128,57 +154,50 @@ 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 stepSecretRegion, stepK8sSecretName, 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) {
switch m.step {
case stepEnvMenu:
exec.Command("tmux", "kill-session", "-t", "kctl").Run() exec.Command("tmux", "kill-session", "-t", "kctl").Run()
return m, tea.Quit 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 stepAWSAuthPrompt: case stepAWSAuthPrompt:
return m.fromAWSAuthPrompt() return m.fromAWSAuthPrompt()
case stepSecretRegion:
m.awsRegion = m.input.Value()
return m.fetchSecretList()
case stepSecretList:
return m.fromSecretList()
case stepK8sSecretName:
m.k8sSecretName = m.input.Value()
return m.compareAllFields()
case stepForceSyncConfirm: case stepForceSyncConfirm:
return m.fromForceSyncConfirm() return m.fromForceSyncConfirm()
case stepExternalSecretName: case stepExternalSecretName:
return m.doForceSync() return m.doForceSync()
case stepDiffResult, stepDone, stepError: case stepDiffResult, stepDone, stepError:
m.resetToMenu() m.showActionMenu()
return m, nil return m, nil
} }
return m, nil return m, nil
} }
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 // showError switches to a dedicated error screen so failures from
// kubectl/aws calls stay visible until the user explicitly acknowledges // kubectl/aws calls stay visible until the user explicitly acknowledges
// them with Enter, instead of being silently discarded. // them with Enter, instead of being silently discarded.
@@ -188,14 +207,27 @@ func (m *panelModel) showError(err error) (tea.Model, tea.Cmd) {
return m, nil 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 {
return m.showError(err) return m.showError(err)
} }
@@ -204,12 +236,10 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
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":
return m.checkAWSAuthAndProceed() return m.checkAWSAuthAndProceed()
case "quit":
return m.handleEsc()
} }
return m, nil return m, nil
} }
@@ -230,16 +260,13 @@ func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
m.step = stepAWSAuthPrompt m.step = stepAWSAuthPrompt
return m, nil return m, nil
} }
m.step = stepSecretRegion return m.startSecretsFlow()
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
return m, nil
} }
func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) { func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "login" { if !ok || item.value != "login" {
m.resetToMenu() m.showActionMenu()
return m, nil return m, nil
} }
cmd := kubeexec.RunAWSLogin(m.cfg.LoginCommand()) cmd := kubeexec.RunAWSLogin(m.cfg.LoginCommand())
@@ -258,77 +285,20 @@ func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
if err := kubeexec.CheckAWSAuth(); err != nil { 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.showError(fmt.Errorf("still not authenticated with AWS after running '%s': %w", m.cfg.LoginCommand(), err))
} }
m.step = stepSecretRegion return m.startSecretsFlow()
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
return m, nil
} }
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) { // startSecretsFlow computes the AWS secret ID (namespace + env) and the
item, ok := m.list.SelectedItem().(simpleItem) // Kubernetes secret name (namespace only) from their respective
if !ok { // templates and fetches the AWS side directly - no manual input required
return m, nil // for either name.
} func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
m.k8sSecretName = item.value // reused as "deployment name" here m.awsSecretName = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
m.list.SetItems([]list.Item{ m.k8sSecretName = m.cfg.ResolveK8sSecretName(m.ns)
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"},
})
m.list.Title = "Confirm rollout restart"
m.step = stepRedeployConfirm
return m, nil
}
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) { raw, err := kubeexec.GetAWSSecretString(m.awsSecretName, m.cfg.AWSRegion)
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" {
m.resetToMenu()
return m, nil
}
deployment := m.k8sSecretName // set in fromRedeployList
_, err := kubeexec.RolloutRestart(m.ns, deployment)
if err != nil { if err != nil {
return m.showError(err) return m.showError(fmt.Errorf("failed to fetch AWS secret %q: %w", m.awsSecretName, err))
}
status, err := kubeexec.RolloutStatus(m.ns, deployment)
if err != nil {
return m.showError(err)
}
m.message = "Rollout status: " + status
m.step = stepDone
return m, nil
}
// 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 {
return m.showError(err)
} }
var parsed map[string]interface{} var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil { if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
@@ -341,23 +311,54 @@ func (m *panelModel) fromSecretList() (tea.Model, tea.Cmd) {
} }
} }
m.step = stepK8sSecretName return m.compareAllFields()
m.input.SetValue("") }
m.input.Placeholder = "Kubernetes secret name (in namespace " + m.ns + ")"
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.deploymentName = item.value
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"},
})
m.list.Title = fmt.Sprintf("Confirm rollout restart [env=%s]", m.currentEnv)
m.step = stepRedeployConfirm
return m, nil
}
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" {
m.showActionMenu()
return m, nil
}
ctx := m.resolvedContext()
_, err := kubeexec.RolloutRestart(ctx, m.ns, m.deploymentName)
if err != nil {
return m.showError(err)
}
status, err := kubeexec.RolloutStatus(ctx, m.ns, m.deploymentName)
if err != nil {
return m.showError(err)
}
m.message = fmt.Sprintf("[env=%s] Rollout status: %s", m.currentEnv, status)
m.step = stepDone
return m, nil return m, nil
} }
// compareAllFields fetches every field of the Kubernetes secret and diffs // compareAllFields fetches every field of the Kubernetes secret and diffs
// it against every key of the AWS secret in one go, instead of requiring // it against every key of the AWS secret in one go.
// the user to pick a single field.
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) { func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
k8sValues, err := kubeexec.GetSecretAllFields(m.ns, m.k8sSecretName) k8sValues, err := kubeexec.GetSecretAllFields(m.resolvedContext(), m.ns, m.k8sSecretName)
if err != nil { if err != nil {
return m.showError(err) return m.showError(fmt.Errorf("failed to fetch Kubernetes secret %q: %w", m.k8sSecretName, err))
} }
m.k8sValues = k8sValues m.k8sValues = k8sValues
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues) m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.message = renderDiffTable(m.awsSecretID, m.k8sSecretName, m.diffEntries) m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries)
if anyMismatch(m.diffEntries) { if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
@@ -372,9 +373,9 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func renderDiffTable(awsSecretID, k8sSecretName string, entries []kctl.SecretDiffEntry) string { func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "AWS secret: %s Kubernetes secret: %s\n\n", awsSecretID, k8sSecretName) fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName)
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS") fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
for _, e := range entries { for _, e := range entries {
status := "OK" status := "OK"
@@ -411,7 +412,7 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) { func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
name := m.input.Value() name := m.input.Value()
ts := time.Now().Unix() ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts) _, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
if err != nil { if err != nil {
return m.showError(err) return m.showError(err)
} }
@@ -422,8 +423,12 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
func (m *panelModel) View() string { func (m *panelModel) View() string {
switch m.step { switch m.step {
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList: case stepEnvMenu, stepActionMenu, stepRedeployList, stepRedeployConfirm:
return m.list.View() v := m.list.View()
if m.err != nil {
v += "\nerror: " + m.err.Error()
}
return v
case stepAWSAuthPrompt: case stepAWSAuthPrompt:
errText := "" errText := ""
if m.err != nil { if m.err != nil {
@@ -433,26 +438,21 @@ func (m *panelModel) View() string {
case stepForceSyncConfirm: case stepForceSyncConfirm:
return m.message + "\n\n" + m.list.View() 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: case stepError:
errText := "unknown error" errText := "unknown error"
if m.err != nil { if m.err != nil {
errText = m.err.Error() errText = m.err.Error()
} }
return "ERROR:\n\n" + errText + "\n\n(press enter to return to menu, esc to close session)" 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 stepSecretRegion:
return "AWS region to list secrets from"
case stepK8sSecretName:
return "Kubernetes secret name (in namespace " + m.ns + ")"
case stepExternalSecretName:
return "ExternalSecret object name to annotate" return "ExternalSecret object name to annotate"
} }
return "" return ""
+49 -13
View File
@@ -1,21 +1,57 @@
# 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.
context_pairs:
- name: "example-environment-pair"
contexts: contexts:
- "example-context-a" - "internal"
- "example-context-b" - "external"
# The namespace label key used to group namespaces by team/ownership in the # Pre-selected on startup so the tool can jump straight to team selection
# team-selection screen. Adjust this to whatever label your organization # instead of asking for the context every time. Falls back to the first
# actually uses (can contain a domain prefix, e.g. "example.org/team"). # entry of 'contexts' if omitted.
default_context: "internal"
# Environments switchable from the control panel (e.g. "1) beta" /
# "2) prod"). The first two entries are also used for the two k9s status
# panes shown side by side.
envs:
- "beta"
- "prod"
# AWS region used for all AWS Secrets Manager calls.
aws_region: "eu-central-1"
# AWS account ID, used to fill the {account_id} placeholder below.
# 123456789012 is a placeholder, not a real account.
aws_account_id: "123456789012"
# Builds the AWS Secrets Manager secret ID from the chosen namespace and
# environment. Available placeholders: {namespace}, {env}.
secret_name_template: "tf-{namespace}-{env}-secrets"
# Builds the Kubernetes secret name from the chosen namespace. Kept as a
# separate template from secret_name_template above because the AWS side
# and the Kubernetes side commonly follow different naming conventions
# (e.g. the Kubernetes secret is per-namespace only, without an env
# segment, because each environment already has its own cluster).
# Available placeholders: {namespace}.
k8s_secret_name_template: "{namespace}-common-secrets"
# Builds the actual kubectl context name/ARN from region, account ID, env,
# and context. Available placeholders: {region}, {account_id}, {env},
# {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN
# shape) to match how your own EKS clusters/contexts are actually named.
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
# The namespace label key used to group namespaces by team/ownership in
# the team-selection screen. Adjust this to whatever label your
# organization actually uses (can contain a domain prefix, e.g.
# "example.org/team").
team_label_key: "example.org/team" team_label_key: "example.org/team"
# Command used to (re-)authenticate with AWS before the Secrets workflow, # Command used to (re-)authenticate with AWS before the Secrets workflow,
+94 -8
View File
@@ -1,5 +1,5 @@
// Package config loads the user-specific, non-versioned kctl-tui // Package config loads the user-specific, non-versioned kctl-tui
// configuration (context pairs, team label key) from a YAML file. // configuration (contexts, envs, templates) from a YAML file.
package config package config
import ( import (
@@ -17,15 +17,52 @@ 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.
// "internal"/"external". This is the outermost navigation level.
Contexts []string `yaml:"contexts"`
// DefaultContext is pre-selected on startup so the tool can jump
// straight to team selection; falls back to the first entry of
// Contexts if empty.
DefaultContext string `yaml:"default_context"`
// Envs are the environments switchable from the control panel, e.g.
// "beta"/"prod". The first two entries are used for the two k9s
// status panes.
Envs []string `yaml:"envs"`
// AWSRegion is used for all AWS Secrets Manager calls.
AWSRegion string `yaml:"aws_region"`
// AWSAccountID fills the {account_id} placeholder in ContextTemplate.
AWSAccountID string `yaml:"aws_account_id"`
// SecretNameTemplate builds the AWS Secrets Manager secret ID from a
// namespace and env, e.g. "tf-{namespace}-{env}-secrets".
SecretNameTemplate string `yaml:"secret_name_template"`
// K8sSecretNameTemplate builds the Kubernetes secret name from a
// namespace, e.g. "{namespace}-common-secrets". Kept separate from
// SecretNameTemplate because the two sides commonly follow different
// naming conventions.
K8sSecretNameTemplate string `yaml:"k8s_secret_name_template"`
// ContextTemplate builds the actual kubectl context name/ARN from
// region, account_id, env, and context, e.g.
// "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1".
ContextTemplate string `yaml:"context_template"`
// TeamLabelKey is the namespace label used to group namespaces by
// team/ownership in the team-selection screen.
TeamLabelKey string `yaml:"team_label_key"` 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"` AWSSSOLoginCommand string `yaml:"aws_sso_login_command"`
} }
// LoginCommand returns the configured AWS SSO login command, falling back // LoginCommand returns the configured AWS SSO login command, falling back
// to DefaultAWSSSOLoginCommand if none is set. Organizations that wrap // to DefaultAWSSSOLoginCommand if none is set.
// 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 { func (c Config) LoginCommand() string {
if c.AWSSSOLoginCommand == "" { if c.AWSSSOLoginCommand == "" {
return DefaultAWSSSOLoginCommand return DefaultAWSSSOLoginCommand
@@ -33,6 +70,54 @@ func (c Config) LoginCommand() string {
return c.AWSSSOLoginCommand return c.AWSSSOLoginCommand
} }
// EffectiveDefaultContext returns DefaultContext if set, otherwise the
// first entry of Contexts, otherwise an empty string.
func (c Config) EffectiveDefaultContext() string {
if c.DefaultContext != "" {
return c.DefaultContext
}
if len(c.Contexts) > 0 {
return c.Contexts[0]
}
return ""
}
// ResolveContext builds the actual kubectl context name/ARN for a given
// env + context (e.g. "beta" + "internal") using ContextTemplate.
func (c Config) ResolveContext(env, context string) string {
return kctl.ResolveTemplate(c.ContextTemplate, map[string]string{
"region": c.AWSRegion,
"account_id": c.AWSAccountID,
"env": env,
"context": context,
})
}
// ResolveSecretName builds the AWS Secrets Manager secret ID for a given
// namespace + env using SecretNameTemplate.
func (c Config) ResolveSecretName(namespace, env string) string {
return kctl.ResolveTemplate(c.SecretNameTemplate, map[string]string{
"namespace": namespace,
"env": env,
})
}
// ResolveK8sSecretName builds the Kubernetes secret name for a given
// namespace using K8sSecretNameTemplate. Falls back to
// SecretNameTemplate resolved without an env placeholder if
// K8sSecretNameTemplate is not configured, so existing configs keep
// working, though setting it explicitly is recommended since the two
// naming conventions usually differ.
func (c Config) ResolveK8sSecretName(namespace string) string {
template := c.K8sSecretNameTemplate
if template == "" {
template = c.SecretNameTemplate
}
return kctl.ResolveTemplate(template, map[string]string{
"namespace": namespace,
})
}
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml // DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@@ -43,9 +128,10 @@ func DefaultPath() (string, error) {
} }
// Load reads and parses the config file at path. If the file does not // Load reads and parses the config file at path. If the file does not
// exist, it returns a zero-value Config (no pairs, empty label key) and no // exist, it returns a zero-value Config and no error, so the tool can
// error, so the tool can run with sane defaults before the user has set up // still start (with an explanatory error surfaced later where a required
// a config file. // field turns out to be missing) before the user has set up a config
// file.
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Config{} cfg := Config{}
+88 -10
View File
@@ -11,19 +11,25 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(cfg.ContextPairs) != 0 { if len(cfg.Contexts) != 0 || len(cfg.Envs) != 0 {
t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs) t.Fatalf("expected no contexts/envs, got %+v", cfg)
}
if cfg.TeamLabelKey != "" {
t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey)
} }
} }
func TestLoad_ValidFile(t *testing.T) { func TestLoad_ValidFile(t *testing.T) {
content := []byte(` content := []byte(`
context_pairs: contexts:
- name: "env-pair-1" - "internal"
contexts: ["ctx-a", "ctx-b"] - "external"
default_context: "internal"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
team_label_key: "example.org/team" team_label_key: "example.org/team"
`) `)
path := filepath.Join(t.TempDir(), "config.yaml") path := filepath.Join(t.TempDir(), "config.yaml")
@@ -38,7 +44,79 @@ team_label_key: "example.org/team"
if cfg.TeamLabelKey != "example.org/team" { if cfg.TeamLabelKey != "example.org/team" {
t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey) t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey)
} }
if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" { if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 {
t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs) t.Fatalf("unexpected contexts/envs: %+v", cfg)
}
if cfg.K8sSecretNameTemplate != "{namespace}-common-secrets" {
t.Fatalf("unexpected k8s secret name template: %q", cfg.K8sSecretNameTemplate)
}
}
func TestEffectiveDefaultContext(t *testing.T) {
cfg := Config{Contexts: []string{"internal", "external"}}
if got := cfg.EffectiveDefaultContext(); got != "internal" {
t.Fatalf("expected first context as fallback default, got %q", got)
}
cfg.DefaultContext = "external"
if got := cfg.EffectiveDefaultContext(); got != "external" {
t.Fatalf("expected explicit default_context to win, got %q", got)
}
empty := Config{}
if got := empty.EffectiveDefaultContext(); got != "" {
t.Fatalf("expected empty string when no contexts configured, got %q", got)
}
}
func TestResolveContext(t *testing.T) {
cfg := Config{
AWSRegion: "eu-central-1",
AWSAccountID: "123456789012",
ContextTemplate: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1",
}
got := cfg.ResolveContext("beta", "internal")
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveSecretName(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveSecretName("example-ns", "beta")
want := "tf-example-ns-beta-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveK8sSecretName_ExplicitTemplate(t *testing.T) {
cfg := Config{K8sSecretNameTemplate: "{namespace}-common-secrets"}
got := cfg.ResolveK8sSecretName("example-ns")
want := "example-ns-common-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveK8sSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveK8sSecretName("example-ns")
want := "tf-example-ns-{env}-secrets" // {env} intentionally left unresolved here
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestLoginCommand_DefaultsWhenUnset(t *testing.T) {
cfg := Config{}
if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand {
t.Fatalf("got %q, want default %q", got, DefaultAWSSSOLoginCommand)
}
cfg.AWSSSOLoginCommand = "aws sso login --profile custom"
if got := cfg.LoginCommand(); got != "aws sso login --profile custom" {
t.Fatalf("expected custom login command to be used, got %q", got)
} }
} }
-35
View File
@@ -1,35 +0,0 @@
// Package kctl contains the core, non-interactive logic of kctl-tui.
// Functions here are pure (no kubectl/tmux side effects) so they can be
// unit tested without a live cluster.
package kctl
// ContextPair groups a set of related kubectl contexts that should be
// switchable via TAB while keeping the same namespace (e.g. staging/prod).
type ContextPair struct {
Name string `yaml:"name"`
Contexts []string `yaml:"contexts"`
}
// FindNextContext returns the next context in the same pair/group as
// current, cycling through the group. Returns "", false if current is not
// part of any configured pair.
func FindNextContext(current string, pairs []ContextPair) (string, bool) {
for _, pair := range pairs {
idx := indexOf(pair.Contexts, current)
if idx == -1 {
continue
}
next := pair.Contexts[(idx+1)%len(pair.Contexts)]
return next, true
}
return "", false
}
func indexOf(items []string, target string) int {
for i, v := range items {
if v == target {
return i
}
}
return -1
}
-48
View File
@@ -1,48 +0,0 @@
package kctl
import "testing"
func TestFindNextContext_TwoWayToggle(t *testing.T) {
pairs := []ContextPair{
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
}
next, ok := FindNextContext("ctx-a", pairs)
if !ok || next != "ctx-b" {
t.Fatalf("expected ctx-b, got %q (ok=%v)", next, ok)
}
next, ok = FindNextContext("ctx-b", pairs)
if !ok || next != "ctx-a" {
t.Fatalf("expected ctx-a, got %q (ok=%v)", next, ok)
}
}
func TestFindNextContext_Rotation(t *testing.T) {
pairs := []ContextPair{
{Name: "rotation", Contexts: []string{"a", "b", "c"}},
}
next, ok := FindNextContext("c", pairs)
if !ok || next != "a" {
t.Fatalf("expected wraparound to a, got %q (ok=%v)", next, ok)
}
}
func TestFindNextContext_NotConfigured(t *testing.T) {
pairs := []ContextPair{
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
}
_, ok := FindNextContext("unrelated-context", pairs)
if ok {
t.Fatalf("expected ok=false for a context with no configured pair")
}
}
func TestFindNextContext_NoPairsConfigured(t *testing.T) {
_, ok := FindNextContext("ctx-a", nil)
if ok {
t.Fatalf("expected ok=false when no pairs are configured")
}
}
+16
View File
@@ -0,0 +1,16 @@
package kctl
import "strings"
// ResolveTemplate replaces "{key}" placeholders in template with the
// corresponding value from values. Placeholders with no matching key are
// left untouched, so a misconfigured template is visible (e.g. a literal
// "{typo}" in the result) instead of silently collapsing to an empty
// string.
func ResolveTemplate(template string, values map[string]string) string {
result := template
for k, v := range values {
result = strings.ReplaceAll(result, "{"+k+"}", v)
}
return result
}
+41
View File
@@ -0,0 +1,41 @@
package kctl
import "testing"
func TestResolveTemplate_SinglePlaceholder(t *testing.T) {
got := ResolveTemplate("secret-{namespace}", map[string]string{"namespace": "example-ns"})
want := "secret-example-ns"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_MultiplePlaceholders(t *testing.T) {
template := "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
values := map[string]string{
"region": "eu-central-1",
"account_id": "123456789012",
"env": "beta",
"context": "internal",
}
got := ResolveTemplate(template, values)
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_UnknownPlaceholderLeftAsIs(t *testing.T) {
got := ResolveTemplate("secret-{unknown}", map[string]string{"namespace": "example-ns"})
want := "secret-{unknown}"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_EmptyTemplate(t *testing.T) {
got := ResolveTemplate("", map[string]string{"namespace": "example-ns"})
if got != "" {
t.Fatalf("expected empty result, got %q", got)
}
}
+33 -75
View File
@@ -2,6 +2,12 @@
// All functions here have side effects (they run external processes) and // All functions here have side effects (they run external processes) and
// are therefore not covered by unit tests; the pure logic they depend on // are therefore not covered by unit tests; the pure logic they depend on
// lives in the kctl package instead. // lives in the kctl package instead.
//
// Every kubectl-related function takes an explicit context argument
// (passed as --context) instead of relying on/mutating the globally
// active kubectl context. This lets the panel act on multiple resolved
// contexts (e.g. beta and prod) without switching global state back and
// forth.
package kubeexec package kubeexec
import ( import (
@@ -21,45 +27,12 @@ func runOutput(name string, args ...string) (string, error) {
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(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 +47,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 +65,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,26 +80,22 @@ 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
// single field in a Kubernetes secret.
func GetSecretValueBase64(namespace, secretName, field string) (string, error) {
path := fmt.Sprintf("jsonpath={.data.%s}", field)
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path)
} }
// GetSecretAllFields returns all fields of a Kubernetes secret, already // GetSecretAllFields returns all fields of a Kubernetes secret, already
// base64-decoded into plain values. // base64-decoded into plain values.
func GetSecretAllFields(namespace, secretName string) (map[string]string, error) { func GetSecretAllFields(context, namespace, secretName string) (map[string]string, error) {
out, err := runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", "json") args := kubectlArgs(context, "-n", namespace, "get", "secret", secretName, "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -156,29 +127,16 @@ 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...)
// 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 // 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,