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 | ||
|
|
26b5754f60 | ||
|
|
76a389f52d | ||
|
|
73b38ef52c | ||
|
|
c10b553b48 |
@@ -10,44 +10,59 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
||||
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
|
||||
attaches binaries to GitHub Releases on version tags.
|
||||
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release
|
||||
asset.
|
||||
asset, with clear diagnostics if no release exists yet or the GitHub
|
||||
API is unreachable.
|
||||
- [x] `README.md`, `config.example.yaml`.
|
||||
|
||||
## Phase 1 — Core logic + navigation (done, initial version)
|
||||
|
||||
- [x] `internal/kctl`: pure, unit-tested logic —
|
||||
context-pair matching (`FindNextContext`) and namespace/label
|
||||
filtering (`DistinctLabelValues`, `NamespacesForLabelValue`).
|
||||
context-pair matching (`FindNextContext`), namespace/label filtering
|
||||
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
|
||||
(`DiffSecretValues`, `AnyMismatch`).
|
||||
- [x] `internal/config`: YAML config loading (`context_pairs`,
|
||||
`team_label_key`), with safe defaults when no config file exists yet.
|
||||
- [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
|
||||
(contexts, namespaces, deployments, rollout restart/status, secret
|
||||
read, ExternalSecret annotation).
|
||||
(contexts, namespaces, deployments, rollout restart/status, listing
|
||||
AWS secrets, reading all fields of a Kubernetes secret, ExternalSecret
|
||||
annotation).
|
||||
- [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
|
||||
context -> team -> namespace, with `Esc` correctly popping back one
|
||||
level at a time, defaults pre-selected from the currently active
|
||||
context/namespace.
|
||||
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
|
||||
session (control pane + two `k9s` panes) via `tea.ExecProcess` and
|
||||
resumes at the namespace screen once the session ends.
|
||||
- [x] `cmd/kctl-tui` "panel" mode: menu for Redeploy and the AWS/Kubernetes
|
||||
secrets diff + force-sync wizard, with `Esc` closing the whole tmux
|
||||
session (`tmux kill-session`).
|
||||
session (control pane + two `k9s` panes, `even-vertical` layout,
|
||||
`remain-on-exit` so a crashing control pane stays visible) via
|
||||
`tea.ExecProcess` and resumes at the namespace screen once the
|
||||
session ends.
|
||||
- [x] `cmd/kctl-tui` "panel" mode:
|
||||
- Redeploy: pick a deployment from a list, confirm, then
|
||||
`rollout restart` + `rollout status`.
|
||||
- Secrets: pick an AWS region, then pick the actual secret from a
|
||||
**list of all AWS Secrets Manager secrets** in that region (no more
|
||||
manual secret-ID typing), enter the matching Kubernetes secret
|
||||
name, and automatically diff **every field** of both secrets in one
|
||||
table (key / AWS value / Kubernetes value / match status). If any
|
||||
field differs, offer a single force-sync request for the **whole
|
||||
secret** (one ExternalSecret annotation), not per individual field.
|
||||
- `Esc` closes the whole tmux session (`tmux kill-session`).
|
||||
|
||||
## Phase 2 — Hardening (open)
|
||||
|
||||
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value
|
||||
formatting with a proper typed decode, and handle secrets that are
|
||||
plain strings rather than JSON.
|
||||
- [ ] Handle non-JSON AWS secrets and Kubernetes secrets with binary
|
||||
(non-UTF8) values more gracefully in the diff table (currently
|
||||
falls back to a single "value" key or may render oddly).
|
||||
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
|
||||
CI for the `kubeexec` wrappers currently excluded from automated
|
||||
testing.
|
||||
- [ ] Input validation for the free-text steps in "panel" mode (empty
|
||||
secret ID/region/name, invalid characters).
|
||||
region/secret name, invalid characters).
|
||||
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed
|
||||
(currently surfaces the raw exec error).
|
||||
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
|
||||
`kubectl` calls.
|
||||
`kubectl`/`aws` calls.
|
||||
- [ ] Paginate/scroll the secrets diff table for secrets with many fields
|
||||
instead of relying on terminal wrapping.
|
||||
|
||||
## Phase 3 — Windows-native support (open, secondary priority)
|
||||
|
||||
@@ -67,6 +82,9 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
|
||||
kubeconfig.
|
||||
- [ ] Homebrew tap / `scoop` manifest as additional install options
|
||||
alongside `install.sh`.
|
||||
- [ ] Optional heuristic to suggest a matching Kubernetes secret name for
|
||||
a chosen AWS secret (e.g. by common naming convention), instead of
|
||||
always asking for it manually.
|
||||
|
||||
## Notes for contributors
|
||||
|
||||
|
||||
+10
-3
@@ -230,6 +230,11 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
||||
// startTmuxSession builds the 3-pane tmux command (control pane running
|
||||
// this binary in "panel" mode, plus two k9s status panes) and runs it via
|
||||
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal.
|
||||
//
|
||||
// Layout: even-vertical stacks all three panes evenly from top to bottom
|
||||
// (control pane, then the two k9s status panes). remain-on-exit keeps a
|
||||
// pane visible (showing its exit status/output) instead of tmux silently
|
||||
// closing it if the control pane's process crashes on startup.
|
||||
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
||||
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s",
|
||||
@@ -244,9 +249,11 @@ func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||
|
||||
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
||||
panelCmd, ";",
|
||||
"split-window", "-v", k9sCmdA, ";",
|
||||
"split-window", "-v", k9sCmdB, ";",
|
||||
"select-layout", "main-horizontal", ";",
|
||||
"set-option", "-t", "kctl", "remain-on-exit", "on", ";",
|
||||
"split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";",
|
||||
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";",
|
||||
"select-layout", "-t", "kctl", "even-vertical", ";",
|
||||
"select-pane", "-t", "kctl:0.0", ";",
|
||||
"attach", "-t", "kctl",
|
||||
)
|
||||
|
||||
|
||||
@@ -13,3 +13,11 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
|
||||
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
|
||||
return kctl.FindNextContext(current, pairs)
|
||||
}
|
||||
|
||||
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
|
||||
return kctl.DiffSecretValues(left, right)
|
||||
}
|
||||
|
||||
func anyMismatch(entries []kctl.SecretDiffEntry) bool {
|
||||
return kctl.AnyMismatch(entries)
|
||||
}
|
||||
|
||||
+102
-74
@@ -6,12 +6,14 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/skoelle/kctl-tui/internal/kctl"
|
||||
"github.com/skoelle/kctl-tui/internal/kubeexec"
|
||||
)
|
||||
|
||||
@@ -22,11 +24,9 @@ const (
|
||||
stepMenu panelStep = iota
|
||||
stepRedeployList
|
||||
stepRedeployConfirm
|
||||
stepSecretID
|
||||
stepSecretRegion
|
||||
stepSecretKeyList
|
||||
stepSecretList
|
||||
stepK8sSecretName
|
||||
stepK8sFieldName
|
||||
stepDiffResult
|
||||
stepForceSyncConfirm
|
||||
stepExternalSecretName
|
||||
@@ -40,14 +40,12 @@ type panelModel struct {
|
||||
list list.Model
|
||||
input textinput.Model
|
||||
|
||||
awsSecretID string
|
||||
awsRegion string
|
||||
awsSecretID string
|
||||
awsValues map[string]string
|
||||
selectedKey string
|
||||
awsValue string
|
||||
k8sSecretName string
|
||||
k8sFieldName string
|
||||
k8sValue string
|
||||
k8sValues map[string]string
|
||||
diffEntries []kctl.SecretDiffEntry
|
||||
|
||||
message string
|
||||
err error
|
||||
@@ -119,7 +117,7 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *panelModel) usesTextInput() bool {
|
||||
switch m.step {
|
||||
case stepSecretID, stepSecretRegion, stepK8sSecretName, stepK8sFieldName, stepExternalSecretName:
|
||||
case stepSecretRegion, stepK8sSecretName, stepExternalSecretName:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -140,24 +138,14 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
|
||||
return m.fromRedeployList()
|
||||
case stepRedeployConfirm:
|
||||
return m.fromRedeployConfirm()
|
||||
case stepSecretID:
|
||||
m.awsSecretID = m.input.Value()
|
||||
m.step = stepSecretRegion
|
||||
m.input.SetValue("eu-central-1")
|
||||
return m, nil
|
||||
case stepSecretRegion:
|
||||
m.awsRegion = m.input.Value()
|
||||
return m.fetchAWSSecret()
|
||||
case stepSecretKeyList:
|
||||
return m.fromSecretKeyList()
|
||||
return m.fetchSecretList()
|
||||
case stepSecretList:
|
||||
return m.fromSecretList()
|
||||
case stepK8sSecretName:
|
||||
m.k8sSecretName = m.input.Value()
|
||||
m.step = stepK8sFieldName
|
||||
m.input.SetValue("")
|
||||
return m, nil
|
||||
case stepK8sFieldName:
|
||||
m.k8sFieldName = m.input.Value()
|
||||
return m.compareSecret()
|
||||
return m.compareAllFields()
|
||||
case stepForceSyncConfirm:
|
||||
return m.fromForceSyncConfirm()
|
||||
case stepExternalSecretName:
|
||||
@@ -173,6 +161,8 @@ func (m *panelModel) resetToMenu() {
|
||||
m.step = stepMenu
|
||||
m.list.SetItems(menuItems())
|
||||
m.list.Title = "kctl-tui panel"
|
||||
m.message = ""
|
||||
m.err = nil
|
||||
}
|
||||
|
||||
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.step = stepRedeployList
|
||||
case "secrets":
|
||||
m.step = stepSecretID
|
||||
m.input.SetValue("")
|
||||
m.input.Placeholder = "AWS secret ID"
|
||||
m.step = stepSecretRegion
|
||||
m.input.SetValue("eu-central-1")
|
||||
m.input.Placeholder = "AWS region"
|
||||
case "quit":
|
||||
return m.handleEsc()
|
||||
}
|
||||
@@ -209,7 +199,7 @@ func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedKey = item.value // reused as "deployment name" here
|
||||
m.k8sSecretName = item.value // reused as "deployment name" here
|
||||
m.list.SetItems([]list.Item{
|
||||
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
|
||||
simpleItem{label: "Cancel", value: "no"},
|
||||
@@ -225,17 +215,48 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
|
||||
m.resetToMenu()
|
||||
return m, nil
|
||||
}
|
||||
_, err := kubeexec.RolloutRestart(m.ns, m.selectedKey)
|
||||
deployment := m.k8sSecretName // set in fromRedeployList
|
||||
_, err := kubeexec.RolloutRestart(m.ns, deployment)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
}
|
||||
status, _ := kubeexec.RolloutStatus(m.ns, m.selectedKey)
|
||||
status, _ := kubeexec.RolloutStatus(m.ns, deployment)
|
||||
m.message = "Rollout status: " + status
|
||||
m.step = stepDone
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) {
|
||||
// fetchSecretList lists all AWS Secrets Manager secrets in the chosen
|
||||
// region so the user can pick one instead of typing the exact secret ID.
|
||||
func (m *panelModel) fetchSecretList() (tea.Model, tea.Cmd) {
|
||||
names, err := kubeexec.ListAWSSecrets(m.awsRegion)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
@@ -244,67 +265,72 @@ func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
m.awsValues = map[string]string{"__raw__": raw}
|
||||
// Not a JSON secret - treat the whole value as a single field.
|
||||
m.awsValues = map[string]string{"value": raw}
|
||||
} else {
|
||||
m.awsValues = map[string]string{}
|
||||
for k, v := range parsed {
|
||||
m.awsValues[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
items := make([]list.Item, 0, len(m.awsValues))
|
||||
for k := range m.awsValues {
|
||||
items = append(items, simpleItem{label: k, value: k})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.Title = "Select AWS secret key to compare"
|
||||
m.step = stepSecretKeyList
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *panelModel) fromSecretKeyList() (tea.Model, tea.Cmd) {
|
||||
item, ok := m.list.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedKey = item.value
|
||||
m.awsValue = m.awsValues[item.value]
|
||||
m.step = stepK8sSecretName
|
||||
m.input.SetValue("")
|
||||
m.input.Placeholder = "Kubernetes secret name"
|
||||
m.input.Placeholder = "Kubernetes secret name (in namespace " + m.ns + ")"
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *panelModel) compareSecret() (tea.Model, tea.Cmd) {
|
||||
b64, err := kubeexec.GetSecretValueBase64(m.ns, m.k8sSecretName, m.k8sFieldName)
|
||||
// compareAllFields fetches every field of the Kubernetes secret and diffs
|
||||
// it against every key of the AWS secret in one go, instead of requiring
|
||||
// the user to pick a single field.
|
||||
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
||||
k8sValues, err := kubeexec.GetSecretAllFields(m.ns, m.k8sSecretName)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
m.resetToMenu()
|
||||
return m, nil
|
||||
}
|
||||
decoded, err := kubeexec.DecodeBase64(b64)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
m.resetToMenu()
|
||||
return m, nil
|
||||
}
|
||||
m.k8sValue = decoded
|
||||
m.k8sValues = k8sValues
|
||||
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
||||
m.message = renderDiffTable(m.awsSecretID, m.k8sSecretName, m.diffEntries)
|
||||
|
||||
if m.awsValue == m.k8sValue {
|
||||
m.message = "IDENTICAL\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
|
||||
m.step = stepDiffResult
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.message = "DIFFERENT\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
|
||||
if anyMismatch(m.diffEntries) {
|
||||
m.list.SetItems([]list.Item{
|
||||
simpleItem{label: "Yes, request force-sync", value: "yes"},
|
||||
simpleItem{label: "Yes, request force-sync for this secret", value: "yes"},
|
||||
simpleItem{label: "No", value: "no"},
|
||||
})
|
||||
m.list.Title = "Values differ - request ExternalSecret force-sync?"
|
||||
m.step = stepForceSyncConfirm
|
||||
} else {
|
||||
m.step = stepDiffResult
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func renderDiffTable(awsSecretID, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "AWS secret: %s Kubernetes secret: %s\n\n", awsSecretID, k8sSecretName)
|
||||
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
|
||||
for _, e := range entries {
|
||||
status := "OK"
|
||||
if !e.Match {
|
||||
status = "MISMATCH"
|
||||
}
|
||||
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(e.Left, 20), truncate(e.Right, 20), status)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 3 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
|
||||
item, ok := m.list.SelectedItem().(simpleItem)
|
||||
if !ok || item.value != "yes" {
|
||||
@@ -312,7 +338,7 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.step = stepExternalSecretName
|
||||
m.input.SetValue("")
|
||||
m.input.SetValue(m.k8sSecretName)
|
||||
m.input.Placeholder = "ExternalSecret object name"
|
||||
return m, nil
|
||||
}
|
||||
@@ -324,21 +350,27 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
||||
if err != nil {
|
||||
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
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *panelModel) View() string {
|
||||
switch m.step {
|
||||
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretKeyList, stepForceSyncConfirm:
|
||||
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList:
|
||||
v := m.list.View()
|
||||
if m.err != nil {
|
||||
v += "\nerror: " + m.err.Error()
|
||||
}
|
||||
return v
|
||||
case stepForceSyncConfirm:
|
||||
return m.message + "\n\n" + m.list.View()
|
||||
case stepDiffResult, stepDone:
|
||||
return m.message + "\n\n(press enter to return to menu, esc to close session)"
|
||||
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:
|
||||
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)",
|
||||
m.stepPrompt(), m.input.View())
|
||||
@@ -347,14 +379,10 @@ func (m *panelModel) View() string {
|
||||
|
||||
func (m *panelModel) stepPrompt() string {
|
||||
switch m.step {
|
||||
case stepSecretID:
|
||||
return "AWS Secrets Manager: enter secret ID"
|
||||
case stepSecretRegion:
|
||||
return "AWS region"
|
||||
return "AWS region to list secrets from"
|
||||
case stepK8sSecretName:
|
||||
return "Kubernetes secret name (in namespace " + m.ns + ")"
|
||||
case stepK8sFieldName:
|
||||
return "Field name inside the Kubernetes secret for key \"" + m.selectedKey + "\""
|
||||
case stepExternalSecretName:
|
||||
return "ExternalSecret object name to annotate"
|
||||
}
|
||||
|
||||
+40
-4
@@ -37,13 +37,50 @@ case "$os" in
|
||||
esac
|
||||
|
||||
echo "Detecting latest release for $REPO ..."
|
||||
latest_tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "Could not determine latest release tag. Is there at least one published release?" >&2
|
||||
release_json="$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest")" || {
|
||||
echo "Failed to reach the GitHub API (network error). Check your internet connection and try again." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Received ${#release_json} bytes from the GitHub API."
|
||||
|
||||
if echo "$release_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then
|
||||
echo "" >&2
|
||||
echo "Could not find a published release for ${REPO}." >&2
|
||||
echo "This usually means no release has been tagged yet." >&2
|
||||
echo "" >&2
|
||||
echo "Options:" >&2
|
||||
echo " 1) Ask the maintainer to push a version tag (e.g. 'git tag v0.1.0 && git push origin v0.1.0')," >&2
|
||||
echo " which triggers the release build via GitHub Actions." >&2
|
||||
echo " 2) Build from source instead:" >&2
|
||||
echo " git clone https://github.com/${REPO}.git" >&2
|
||||
echo " cd $(basename "$REPO")" >&2
|
||||
echo " go build -o ${BIN_NAME} ./cmd/${BIN_NAME}" >&2
|
||||
echo " sudo mv ${BIN_NAME} ${INSTALL_DIR}/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if echo "$release_json" | grep -qi 'rate limit exceeded'; then
|
||||
echo "GitHub API rate limit exceeded. Wait a bit and try again, or authenticate with a GitHub token." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Note: '|| true' below prevents 'set -o pipefail' + 'set -e' from aborting the
|
||||
# script silently if grep finds no match; the emptiness check right after
|
||||
# gives a proper diagnostic instead.
|
||||
latest_tag="$(echo "$release_json" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" || true
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "Could not parse the latest release tag from the GitHub API response." >&2
|
||||
echo "Raw response (truncated):" >&2
|
||||
echo "$release_json" | head -c 800 >&2
|
||||
echo "" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Latest release tag: ${latest_tag}"
|
||||
|
||||
asset="kctl-tui-${os}-${arch}"
|
||||
url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}"
|
||||
|
||||
@@ -60,5 +97,4 @@ else
|
||||
fi
|
||||
|
||||
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}"
|
||||
"${INSTALL_DIR}/${BIN_NAME}" --help >/dev/null 2>&1 || true
|
||||
echo "Done. Run '${BIN_NAME}' to get started."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// GetSecretAllFields returns all fields of a Kubernetes secret, already
|
||||
// base64-decoded into plain values.
|
||||
func GetSecretAllFields(namespace, secretName string) (map[string]string, error) {
|
||||
out, err := runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parsed struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(parsed.Data))
|
||||
for k, v := range parsed.Data {
|
||||
decoded, err := DecodeBase64(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode field %q: %w", k, err)
|
||||
}
|
||||
result[k] = decoded
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecodeBase64 decodes a base64-encoded Kubernetes secret value.
|
||||
func DecodeBase64(value string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
@@ -138,6 +162,21 @@ func AnnotateForceSync(namespace, externalSecretName string, unixTimestamp int64
|
||||
externalSecretName, annotation, "--overwrite")
|
||||
}
|
||||
|
||||
// ListAWSSecrets returns all AWS Secrets Manager secret names/IDs visible
|
||||
// in the given region (subject to the caller's IAM permissions).
|
||||
func ListAWSSecrets(region string) ([]string, error) {
|
||||
out, err := runOutput("aws", "secretsmanager", "list-secrets",
|
||||
"--region", region, "--query", "SecretList[].Name", "--output", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(out), &names); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
|
||||
// secret via the aws-cli.
|
||||
func GetAWSSecretString(secretID, region string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user