Redesign secrets workflow: pick AWS secret from a list, diff all fields at once, offer force-sync for the whole secret

This commit is contained in:
Stefan Koelle
2026-08-09 12:14:01 +02:00
parent b7d553c966
commit e57d58aecc
+108 -80
View File
@@ -6,12 +6,14 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/skoelle/kctl-tui/internal/kctl"
"github.com/skoelle/kctl-tui/internal/kubeexec" "github.com/skoelle/kctl-tui/internal/kubeexec"
) )
@@ -22,11 +24,9 @@ const (
stepMenu panelStep = iota stepMenu panelStep = iota
stepRedeployList stepRedeployList
stepRedeployConfirm stepRedeployConfirm
stepSecretID
stepSecretRegion stepSecretRegion
stepSecretKeyList stepSecretList
stepK8sSecretName stepK8sSecretName
stepK8sFieldName
stepDiffResult stepDiffResult
stepForceSyncConfirm stepForceSyncConfirm
stepExternalSecretName stepExternalSecretName
@@ -36,21 +36,19 @@ const (
type panelModel struct { type panelModel struct {
ctx, ns, team string ctx, ns, team string
step panelStep step panelStep
list list.Model list list.Model
input textinput.Model input textinput.Model
awsSecretID string
awsRegion string awsRegion string
awsSecretID string
awsValues map[string]string awsValues map[string]string
selectedKey string
awsValue string
k8sSecretName string k8sSecretName string
k8sFieldName string k8sValues map[string]string
k8sValue string diffEntries []kctl.SecretDiffEntry
message string message string
err error err error
} }
func runPanel(args []string) error { func runPanel(args []string) error {
@@ -119,7 +117,7 @@ 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 stepSecretID, stepSecretRegion, stepK8sSecretName, stepK8sFieldName, stepExternalSecretName: case stepSecretRegion, stepK8sSecretName, stepExternalSecretName:
return true return true
} }
return false return false
@@ -140,24 +138,14 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
return m.fromRedeployList() return m.fromRedeployList()
case stepRedeployConfirm: case stepRedeployConfirm:
return m.fromRedeployConfirm() return m.fromRedeployConfirm()
case stepSecretID:
m.awsSecretID = m.input.Value()
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
return m, nil
case stepSecretRegion: case stepSecretRegion:
m.awsRegion = m.input.Value() m.awsRegion = m.input.Value()
return m.fetchAWSSecret() return m.fetchSecretList()
case stepSecretKeyList: case stepSecretList:
return m.fromSecretKeyList() return m.fromSecretList()
case stepK8sSecretName: case stepK8sSecretName:
m.k8sSecretName = m.input.Value() m.k8sSecretName = m.input.Value()
m.step = stepK8sFieldName return m.compareAllFields()
m.input.SetValue("")
return m, nil
case stepK8sFieldName:
m.k8sFieldName = m.input.Value()
return m.compareSecret()
case stepForceSyncConfirm: case stepForceSyncConfirm:
return m.fromForceSyncConfirm() return m.fromForceSyncConfirm()
case stepExternalSecretName: case stepExternalSecretName:
@@ -173,6 +161,8 @@ func (m *panelModel) resetToMenu() {
m.step = stepMenu m.step = stepMenu
m.list.SetItems(menuItems()) m.list.SetItems(menuItems())
m.list.Title = "kctl-tui panel" m.list.Title = "kctl-tui panel"
m.message = ""
m.err = nil
} }
func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) { func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
@@ -195,9 +185,9 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
m.list.Title = "Select deployment to restart (esc = back)" m.list.Title = "Select deployment to restart (esc = back)"
m.step = stepRedeployList m.step = stepRedeployList
case "secrets": case "secrets":
m.step = stepSecretID m.step = stepSecretRegion
m.input.SetValue("") m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS secret ID" m.input.Placeholder = "AWS region"
case "quit": case "quit":
return m.handleEsc() return m.handleEsc()
} }
@@ -209,7 +199,7 @@ func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
if !ok { if !ok {
return m, nil 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{ m.list.SetItems([]list.Item{
simpleItem{label: "Yes, restart " + item.value, value: "yes"}, simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"}, simpleItem{label: "Cancel", value: "no"},
@@ -225,17 +215,48 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
m.resetToMenu() m.resetToMenu()
return m, nil 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 { if err != nil {
m.err = err m.err = err
} }
status, _ := kubeexec.RolloutStatus(m.ns, m.selectedKey) status, _ := kubeexec.RolloutStatus(m.ns, deployment)
m.message = "Rollout status: " + status m.message = "Rollout status: " + status
m.step = stepDone m.step = stepDone
return m, nil 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 {
m.err = err
m.resetToMenu()
return m, nil
}
if len(names) == 0 {
m.err = fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion)
m.resetToMenu()
return m, nil
}
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) raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion)
if err != nil { if err != nil {
m.err = err m.err = err
@@ -244,67 +265,72 @@ func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) {
} }
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 {
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 { } else {
m.awsValues = map[string]string{} m.awsValues = map[string]string{}
for k, v := range parsed { for k, v := range parsed {
m.awsValues[k] = fmt.Sprintf("%v", v) 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.step = stepK8sSecretName
m.input.SetValue("") m.input.SetValue("")
m.input.Placeholder = "Kubernetes secret name" m.input.Placeholder = "Kubernetes secret name (in namespace " + m.ns + ")"
return m, nil return m, nil
} }
func (m *panelModel) compareSecret() (tea.Model, tea.Cmd) { // compareAllFields fetches every field of the Kubernetes secret and diffs
b64, err := kubeexec.GetSecretValueBase64(m.ns, m.k8sSecretName, m.k8sFieldName) // 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 { if err != nil {
m.err = err m.err = err
m.resetToMenu() m.resetToMenu()
return m, nil return m, nil
} }
decoded, err := kubeexec.DecodeBase64(b64) m.k8sValues = k8sValues
if err != nil { m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.err = err m.message = renderDiffTable(m.awsSecretID, m.k8sSecretName, m.diffEntries)
m.resetToMenu()
return m, nil
}
m.k8sValue = decoded
if m.awsValue == m.k8sValue { if anyMismatch(m.diffEntries) {
m.message = "IDENTICAL\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync for this secret", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
} else {
m.step = stepDiffResult m.step = stepDiffResult
return m, nil
} }
m.message = "DIFFERENT\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
return m, nil return m, nil
} }
func 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) { func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" { if !ok || item.value != "yes" {
@@ -312,7 +338,7 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.step = stepExternalSecretName m.step = stepExternalSecretName
m.input.SetValue("") m.input.SetValue(m.k8sSecretName)
m.input.Placeholder = "ExternalSecret object name" m.input.Placeholder = "ExternalSecret object name"
return m, nil return m, nil
} }
@@ -324,21 +350,27 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
m.err = err m.err = 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 m.step = stepDone
return m, nil return m, nil
} }
func (m *panelModel) View() string { func (m *panelModel) View() string {
switch m.step { switch m.step {
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretKeyList, stepForceSyncConfirm: case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList:
v := m.list.View() v := m.list.View()
if m.err != nil { if m.err != nil {
v += "\nerror: " + m.err.Error() v += "\nerror: " + m.err.Error()
} }
return v return v
case stepForceSyncConfirm:
return m.message + "\n\n" + m.list.View()
case stepDiffResult, stepDone: case stepDiffResult, stepDone:
return m.message + "\n\n(press enter to return to menu, esc to close session)" v := m.message
if m.err != nil {
v += "\nerror: " + m.err.Error()
}
return v + "\n\n(press enter to return to menu, esc to close session)"
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 to menu/close session)",
m.stepPrompt(), m.input.View()) m.stepPrompt(), m.input.View())
@@ -347,14 +379,10 @@ func (m *panelModel) View() string {
func (m *panelModel) stepPrompt() string { func (m *panelModel) stepPrompt() string {
switch m.step { switch m.step {
case stepSecretID:
return "AWS Secrets Manager: enter secret ID"
case stepSecretRegion: case stepSecretRegion:
return "AWS region" return "AWS region to list secrets from"
case stepK8sSecretName: case stepK8sSecretName:
return "Kubernetes secret name (in namespace " + m.ns + ")" return "Kubernetes secret name (in namespace " + m.ns + ")"
case stepK8sFieldName:
return "Field name inside the Kubernetes secret for key \"" + m.selectedKey + "\""
case stepExternalSecretName: case stepExternalSecretName:
return "ExternalSecret object name to annotate" return "ExternalSecret object name to annotate"
} }