mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-18 04:20:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4be10b6432 | ||
|
|
e57d58aecc | ||
|
|
b7d553c966 | ||
|
|
793c28afff | ||
|
|
9162eebccc |
@@ -10,44 +10,59 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
|||||||
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
|
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
|
||||||
attaches binaries to GitHub Releases on version tags.
|
attaches binaries to GitHub Releases on version tags.
|
||||||
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release
|
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release
|
||||||
asset.
|
asset, with clear diagnostics if no release exists yet or the GitHub
|
||||||
|
API is unreachable.
|
||||||
- [x] `README.md`, `config.example.yaml`.
|
- [x] `README.md`, `config.example.yaml`.
|
||||||
|
|
||||||
## Phase 1 — Core logic + navigation (done, initial version)
|
## Phase 1 — Core logic + navigation (done, initial version)
|
||||||
|
|
||||||
- [x] `internal/kctl`: pure, unit-tested logic —
|
- [x] `internal/kctl`: pure, unit-tested logic —
|
||||||
context-pair matching (`FindNextContext`) and namespace/label
|
context-pair matching (`FindNextContext`), namespace/label filtering
|
||||||
filtering (`DistinctLabelValues`, `NamespacesForLabelValue`).
|
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
|
||||||
|
(`DiffSecretValues`, `AnyMismatch`).
|
||||||
- [x] `internal/config`: YAML config loading (`context_pairs`,
|
- [x] `internal/config`: YAML config loading (`context_pairs`,
|
||||||
`team_label_key`), with safe defaults when no config file exists yet.
|
`team_label_key`), with safe defaults when no config file exists yet.
|
||||||
- [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
|
- [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
|
||||||
(contexts, namespaces, deployments, rollout restart/status, secret
|
(contexts, namespaces, deployments, rollout restart/status, listing
|
||||||
read, ExternalSecret annotation).
|
AWS secrets, reading all fields of a Kubernetes secret, ExternalSecret
|
||||||
|
annotation).
|
||||||
- [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
|
- [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
|
||||||
context -> team -> namespace, with `Esc` correctly popping back one
|
context -> team -> namespace, with `Esc` correctly popping back one
|
||||||
level at a time, defaults pre-selected from the currently active
|
level at a time, defaults pre-selected from the currently active
|
||||||
context/namespace.
|
context/namespace.
|
||||||
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
|
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
|
||||||
session (control pane + two `k9s` panes) via `tea.ExecProcess` and
|
session (control pane + two `k9s` panes, `even-vertical` layout,
|
||||||
resumes at the namespace screen once the session ends.
|
`remain-on-exit` so a crashing control pane stays visible) via
|
||||||
- [x] `cmd/kctl-tui` "panel" mode: menu for Redeploy and the AWS/Kubernetes
|
`tea.ExecProcess` and resumes at the namespace screen once the
|
||||||
secrets diff + force-sync wizard, with `Esc` closing the whole tmux
|
session ends.
|
||||||
session (`tmux kill-session`).
|
- [x] `cmd/kctl-tui` "panel" mode:
|
||||||
|
- Redeploy: pick a deployment from a list, confirm, then
|
||||||
|
`rollout restart` + `rollout status`.
|
||||||
|
- Secrets: pick an AWS region, then pick the actual secret from a
|
||||||
|
**list of all AWS Secrets Manager secrets** in that region (no more
|
||||||
|
manual secret-ID typing), enter the matching Kubernetes secret
|
||||||
|
name, and automatically diff **every field** of both secrets in one
|
||||||
|
table (key / AWS value / Kubernetes value / match status). If any
|
||||||
|
field differs, offer a single force-sync request for the **whole
|
||||||
|
secret** (one ExternalSecret annotation), not per individual field.
|
||||||
|
- `Esc` closes the whole tmux session (`tmux kill-session`).
|
||||||
|
|
||||||
## Phase 2 — Hardening (open)
|
## Phase 2 — Hardening (open)
|
||||||
|
|
||||||
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value
|
- [ ] Handle non-JSON AWS secrets and Kubernetes secrets with binary
|
||||||
formatting with a proper typed decode, and handle secrets that are
|
(non-UTF8) values more gracefully in the diff table (currently
|
||||||
plain strings rather than JSON.
|
falls back to a single "value" key or may render oddly).
|
||||||
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
|
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
|
||||||
CI for the `kubeexec` wrappers currently excluded from automated
|
CI for the `kubeexec` wrappers currently excluded from automated
|
||||||
testing.
|
testing.
|
||||||
- [ ] Input validation for the free-text steps in "panel" mode (empty
|
- [ ] Input validation for the free-text steps in "panel" mode (empty
|
||||||
secret ID/region/name, invalid characters).
|
region/secret name, invalid characters).
|
||||||
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
|
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
|
||||||
(currently surfaces the raw exec error).
|
(currently surfaces the raw exec error).
|
||||||
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
|
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
|
||||||
`kubectl` calls.
|
`kubectl`/`aws` calls.
|
||||||
|
- [ ] Paginate/scroll the secrets diff table for secrets with many fields
|
||||||
|
instead of relying on terminal wrapping.
|
||||||
|
|
||||||
## Phase 3 — Windows-native support (open, secondary priority)
|
## Phase 3 — Windows-native support (open, secondary priority)
|
||||||
|
|
||||||
@@ -67,6 +82,9 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
|||||||
kubeconfig.
|
kubeconfig.
|
||||||
- [ ] Homebrew tap / `scoop` manifest as additional install options
|
- [ ] Homebrew tap / `scoop` manifest as additional install options
|
||||||
alongside `install.sh`.
|
alongside `install.sh`.
|
||||||
|
- [ ] Optional heuristic to suggest a matching Kubernetes secret name for
|
||||||
|
a chosen AWS secret (e.g. by common naming convention), instead of
|
||||||
|
always asking for it manually.
|
||||||
|
|
||||||
## Notes for contributors
|
## Notes for contributors
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
|
|||||||
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
|
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
|
||||||
return kctl.FindNextContext(current, pairs)
|
return kctl.FindNextContext(current, pairs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
|
||||||
|
return kctl.DiffSecretValues(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyMismatch(entries []kctl.SecretDiffEntry) bool {
|
||||||
|
return kctl.AnyMismatch(entries)
|
||||||
|
}
|
||||||
|
|||||||
+102
-74
@@ -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
|
||||||
@@ -40,14 +40,12 @@ type panelModel struct {
|
|||||||
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
|
||||||
@@ -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.step = stepDiffResult
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
m.message = "DIFFERENT\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
|
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
simpleItem{label: "Yes, request force-sync", value: "yes"},
|
simpleItem{label: "Yes, request force-sync for this secret", value: "yes"},
|
||||||
simpleItem{label: "No", value: "no"},
|
simpleItem{label: "No", value: "no"},
|
||||||
})
|
})
|
||||||
m.list.Title = "Values differ - request ExternalSecret force-sync?"
|
m.list.Title = "Values differ - request ExternalSecret force-sync?"
|
||||||
m.step = stepForceSyncConfirm
|
m.step = stepForceSyncConfirm
|
||||||
|
} else {
|
||||||
|
m.step = stepDiffResult
|
||||||
|
}
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,5 +97,4 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}"
|
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}"
|
||||||
"${INSTALL_DIR}/${BIN_NAME}" --help >/dev/null 2>&1 || true
|
|
||||||
echo "Done. Run '${BIN_NAME}' to get started."
|
echo "Done. Run '${BIN_NAME}' to get started."
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package kctl
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// SecretDiffEntry represents the comparison of one key between two secret
|
||||||
|
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
|
||||||
|
type SecretDiffEntry struct {
|
||||||
|
Key string
|
||||||
|
Left string // e.g. the AWS Secrets Manager value
|
||||||
|
Right string // e.g. the decoded Kubernetes secret value
|
||||||
|
Match bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffSecretValues compares two key/value maps and returns a sorted list of
|
||||||
|
// diff entries covering the union of keys present in either map. A key that
|
||||||
|
// only exists on one side is still reported, with the missing side left as
|
||||||
|
// an empty string and Match set to false (unless both sides happen to be
|
||||||
|
// empty strings).
|
||||||
|
func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for k := range left {
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
for k := range right {
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(seen))
|
||||||
|
for k := range seen {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
result := make([]SecretDiffEntry, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
l := left[k]
|
||||||
|
r := right[k]
|
||||||
|
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: l == r})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyMismatch reports whether at least one diff entry does not match.
|
||||||
|
func AnyMismatch(entries []SecretDiffEntry) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package kctl
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDiffSecretValues_AllMatch(t *testing.T) {
|
||||||
|
left := map[string]string{"a": "1", "b": "2"}
|
||||||
|
right := map[string]string{"a": "1", "b": "2"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected no mismatch, got %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_Mismatch(t *testing.T) {
|
||||||
|
left := map[string]string{"a": "1", "b": "2"}
|
||||||
|
right := map[string]string{"a": "1", "b": "different"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
if !AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected a mismatch, got %v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bEntry *SecretDiffEntry
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Key == "b" {
|
||||||
|
bEntry = &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bEntry == nil || bEntry.Match {
|
||||||
|
t.Fatalf("expected key 'b' to be a mismatch, got %v", bEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_KeyOnlyOnOneSide(t *testing.T) {
|
||||||
|
left := map[string]string{"a": "1", "only-left": "x"}
|
||||||
|
right := map[string]string{"a": "1", "only-right": "y"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("expected 3 entries (union of keys), got %d: %v", len(entries), entries)
|
||||||
|
}
|
||||||
|
if !AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected mismatch due to keys only present on one side")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_EmptyMaps(t *testing.T) {
|
||||||
|
entries := DiffSecretValues(nil, nil)
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected no entries for empty maps, got %v", entries)
|
||||||
|
}
|
||||||
|
if AnyMismatch(entries) {
|
||||||
|
t.Fatalf("expected no mismatch for empty maps")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,30 @@ func GetSecretValueBase64(namespace, secretName, field string) (string, error) {
|
|||||||
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path)
|
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSecretAllFields returns all fields of a Kubernetes secret, already
|
||||||
|
// base64-decoded into plain values.
|
||||||
|
func GetSecretAllFields(namespace, secretName string) (map[string]string, error) {
|
||||||
|
out, err := runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(parsed.Data))
|
||||||
|
for k, v := range parsed.Data {
|
||||||
|
decoded, err := DecodeBase64(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode field %q: %w", k, err)
|
||||||
|
}
|
||||||
|
result[k] = decoded
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DecodeBase64 decodes a base64-encoded Kubernetes secret value.
|
// DecodeBase64 decodes a base64-encoded Kubernetes secret value.
|
||||||
func DecodeBase64(value string) (string, error) {
|
func DecodeBase64(value string) (string, error) {
|
||||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||||
@@ -138,6 +162,21 @@ func AnnotateForceSync(namespace, externalSecretName string, unixTimestamp int64
|
|||||||
externalSecretName, annotation, "--overwrite")
|
externalSecretName, annotation, "--overwrite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAWSSecrets returns all AWS Secrets Manager secret names/IDs visible
|
||||||
|
// in the given region (subject to the caller's IAM permissions).
|
||||||
|
func ListAWSSecrets(region string) ([]string, error) {
|
||||||
|
out, err := runOutput("aws", "secretsmanager", "list-secrets",
|
||||||
|
"--region", region, "--query", "SecretList[].Name", "--output", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
if err := json.Unmarshal([]byte(out), &names); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
|
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
|
||||||
// secret via the aws-cli.
|
// secret via the aws-cli.
|
||||||
func GetAWSSecretString(secretID, region string) (string, error) {
|
func GetAWSSecretString(secretID, region string) (string, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user