Redesign panel: env-first menu (quit/beta/prod, each with secrets sync + redeploy), templated AWS secret ID instead of listing/region input

This commit is contained in:
Stefan Koelle
2026-08-09 14:05:33 +02:00
parent f7606011bb
commit ff898f727a
+142 -131
View File
@@ -18,16 +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 stepK8sSecretName
stepDiffResult stepDiffResult
stepForceSyncConfirm stepForceSyncConfirm
@@ -37,14 +36,17 @@ 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 awsSecretID string
awsValues map[string]string awsValues map[string]string
k8sSecretName string k8sSecretName string
@@ -57,39 +59,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 }
@@ -129,34 +156,42 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *panelModel) usesTextInput() bool { func (m *panelModel) usesTextInput() bool {
switch m.step { switch m.step {
case stepSecretRegion, stepK8sSecretName, stepExternalSecretName: case stepK8sSecretName, stepExternalSecretName:
return true return true
} }
return false 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: case stepK8sSecretName:
m.k8sSecretName = m.input.Value() m.k8sSecretName = m.input.Value()
return m.compareAllFields() return m.compareAllFields()
@@ -165,20 +200,12 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
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 +215,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 +244,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 +268,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 +293,17 @@ 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 from the configured
item, ok := m.list.SelectedItem().(simpleItem) // template (namespace + env) and fetches it directly - no more listing
if !ok { // secrets or asking for a region, both now driven by config.
return m, nil func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
} m.awsSecretID = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
m.k8sSecretName = item.value // reused as "deployment name" here raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.cfg.AWSRegion)
m.list.SetItems([]list.Item{
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) {
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.awsSecretID, 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 {
@@ -347,17 +322,51 @@ func (m *panelModel) fromSecretList() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
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
}
// 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 templated 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(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.awsSecretID, m.k8sSecretName, m.diffEntries)
if anyMismatch(m.diffEntries) { if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
@@ -372,9 +381,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, awsSecretID, 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, awsSecretID, 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 +420,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 +431,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,25 +446,23 @@ 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 { switch m.step {
case stepSecretRegion:
return "AWS region to list secrets from"
case stepK8sSecretName: case stepK8sSecretName:
return "Kubernetes secret name (in namespace " + m.ns + ")" return fmt.Sprintf("Kubernetes secret name (env=%s, namespace=%s)", m.currentEnv, m.ns)
case stepExternalSecretName: case stepExternalSecretName:
return "ExternalSecret object name to annotate" return "ExternalSecret object name to annotate"
} }