Compare commits

...
8 Commits
17 changed files with 591 additions and 145 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
ext="" ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
out="dist/kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}${ext}" out="dist/kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}${ext}"
go build -o "$out" -ldflags "-s -w" ./cmd/kctl-tui go build -o "$out" -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" ./cmd/kctl-tui
echo "Built $out" echo "Built $out"
- name: Upload artifact - name: Upload artifact
+2 -2
View File
@@ -1,8 +1,8 @@
# Binaries # Binaries
/bin/ /bin/
/dist/ /dist/
kctl-tui /kctl-tui
kctl-tui.exe /kctl-tui.exe
# Go # Go
*.test *.test
+20 -22
View File
@@ -17,15 +17,17 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
## 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`), namespace/label filtering template resolution (`ResolveTemplate`), namespace/label filtering
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing (`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
(`DiffSecretValues`, `AnyMismatch`). (`DiffSecretValues`, `AnyMismatch`).
- [x] `internal/config`: YAML config loading (`context_pairs`, - [x] `internal/config`: YAML config loading with template-based context
`team_label_key`), with safe defaults when no config file exists yet. and secret name resolution (`ContextTemplate`, `SecretNameTemplate`,
`K8sSecretNameTemplate`), 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, listing (namespaces, deployments, rollout restart/status, fetching AWS
AWS secrets, reading all fields of a Kubernetes secret, ExternalSecret secrets by template-resolved ID, reading all fields of a Kubernetes
annotation). secret, ExternalSecret annotation, AWS auth check).
- [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
@@ -38,13 +40,13 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
- [x] `cmd/kctl-tui` "panel" mode: - [x] `cmd/kctl-tui` "panel" mode:
- Redeploy: pick a deployment from a list, confirm, then - Redeploy: pick a deployment from a list, confirm, then
`rollout restart` + `rollout status`. `rollout restart` + `rollout status`.
- Secrets: pick an AWS region, then pick the actual secret from a - Secrets: AWS auth check with interactive SSO login fallback,
**list of all AWS Secrets Manager secrets** in that region (no more then automatically resolve the AWS secret ID (from
manual secret-ID typing), enter the matching Kubernetes secret `secret_name_template`) and Kubernetes secret name (from
name, and automatically diff **every field** of both secrets in one `k8s_secret_name_template`), fetch both, diff **every field**
table (key / AWS value / Kubernetes value / match status). If any in one table (key / AWS value / Kubernetes value / match status).
field differs, offer a single force-sync request for the **whole If any field differs, offer a single force-sync request for the
secret** (one ExternalSecret annotation), not per individual field. **whole secret** (one ExternalSecret annotation).
- `Esc` closes the whole tmux session (`tmux kill-session`). - `Esc` closes the whole tmux session (`tmux kill-session`).
## Phase 2 — Hardening (open) ## Phase 2 — Hardening (open)
@@ -68,23 +70,19 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
- [ ] Detect OS at runtime; on native Windows (no WSL), fall back to - [ ] Detect OS at runtime; on native Windows (no WSL), fall back to
`wt.exe split-pane` instead of `tmux` for the status panes. `wt.exe split-pane` instead of `tmux` for the status panes.
- [ ] Document/implement that `Tab`-based context switching and - [ ] Document/implement that `Esc`-triggered session close is **not**
`Esc`-triggered session close are **not** available in the native available in the native Windows fallback — the panes must be closed
Windows fallback, per SPEC.md 3.6 — the panes must be closed
manually there. manually there.
## Phase 4 — Nice-to-haves (open, not committed) ## Phase 4 — Nice-to-haves (open, not committed)
- [x] `--version` flag — prints version, set via `-ldflags` at build time.
- [x] Config validation command (`kctl-tui config check`) — validates
required fields and shows a resolved context example.
- [ ] Optional direct use of `client-go` instead of shelling out to - [ ] Optional direct use of `client-go` instead of shelling out to
`kubectl`, for faster context/namespace/label queries. `kubectl`, for faster context/namespace/label queries.
- [ ] Config validation command (`kctl-tui config check`) that reports
unknown label keys or context names not present in the current
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
+15
View File
@@ -125,6 +125,7 @@ aws_region: "eu-central-1"
aws_account_id: "123456789012" aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets" 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" 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"
@@ -144,6 +145,10 @@ aws_sso_login_command: "aws sso login"
- `secret_name_template`: builds the AWS Secrets Manager secret ID from - `secret_name_template`: builds the AWS Secrets Manager secret ID from
the chosen namespace and environment. Placeholders: `{namespace}`, the chosen namespace and environment. Placeholders: `{namespace}`,
`{env}`. `{env}`.
- `k8s_secret_name_template`: builds the Kubernetes secret name from the
chosen namespace. Kept separate from `secret_name_template` because the
two sides commonly follow different naming conventions. Placeholders:
`{namespace}`.
- `context_template`: builds the actual kubectl context name/ARN from - `context_template`: builds the actual kubectl context name/ARN from
region, account ID, environment, and context. Placeholders: `{region}`, region, account ID, environment, and context. Placeholders: `{region}`,
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`, `{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
@@ -173,6 +178,16 @@ mkdir -p ~/.kube
ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config
``` ```
## Usage
```bash
kctl-tui # start the TUI (full navigation mode)
kctl-tui --version # print version
kctl-tui --verbose # enable debug logging to stderr
kctl-tui config check # validate ~/.kctl-tui/config.yaml
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
```
## Development ## Development
```bash ```bash
+56 -45
View File
@@ -5,7 +5,7 @@
A single terminal tool as the central entry point for everyday Kubernetes A single terminal tool as the central entry point for everyday Kubernetes
work, bundling the most common workflows currently done via long work, bundling the most common workflows currently done via long
`kubectl`/`k9s`/`aws-cli` commands, operable through a text UI (arrow keys, `kubectl`/`k9s`/`aws-cli` commands, operable through a text UI (arrow keys,
Esc, Tab) instead of long typed commands. Esc) instead of long typed commands.
Target platform: **Linux / WSL** (primary usage scenario, since split Target platform: **Linux / WSL** (primary usage scenario, since split
panes require a real terminal multiplexer). Native Windows (without WSL) panes require a real terminal multiplexer). Native Windows (without WSL)
@@ -49,9 +49,6 @@ Navigation:
(`tmux kill-session`, closing both k9s panes as well) and then moves the (`tmux kill-session`, closing both k9s panes as well) and then moves the
Go tool's screen stack one level up: namespace selection -> team Go tool's screen stack one level up: namespace selection -> team
selection -> context selection. selection -> context selection.
- **Tab** in the control pane switches the context pair according to the
context-pair pattern (see 3.6) for both status panes simultaneously; the
namespace stays the same.
### 3.2 Namespace grouping via labels ### 3.2 Namespace grouping via labels
@@ -64,10 +61,10 @@ Navigation:
kubectl get ns -o jsonpath='{range .items[*]}{.metadata.labels["<team-label-key>"]}{"\n"}{end}' | sort -u kubectl get ns -o jsonpath='{range .items[*]}{.metadata.labels["<team-label-key>"]}{"\n"}{end}' | sort -u
``` ```
- The actual label key is project-specific and set via the configuration - The actual label key is project-specific and set via the configuration
file (see 3.6), not hardcoded. file (see `team_label_key` in config), not hardcoded.
- Namespace labeling is a prerequisite (one-time setup outside the tool). - Namespace labeling is a prerequisite (one-time setup outside the tool).
### 3.3 Layout: 3-panel view (core design change vs. earlier drafts) ### 3.3 Layout: 3-panel view
Once start navigation is complete, the tool opens a tmux session with Once start navigation is complete, the tool opens a tmux session with
**three panes**, started with a single command: **three panes**, started with a single command:
@@ -93,10 +90,12 @@ Example startup command (generic placeholders):
``` ```
tmux new-session -d -s kctl \ tmux new-session -d -s kctl \
"kctl-tui panel --ctx=$CTX_A --ns=$NS --team=$TEAM" \; \ "kctl-tui panel --context=$CTX_A --ns=$NS --team=$TEAM" \; \
split-window -v "k9s --context $CTX_A -n $NS" \; \ set-option -t kctl remain-on-exit on \; \
split-window -v "k9s --context $CTX_B -n $NS" \; \ split-window -v -t kctl:0.0 "k9s --context $CTX_A -n $NS" \; \
select-layout main-horizontal \; \ split-window -v -t kctl:0.1 "k9s --context $CTX_B -n $NS" \; \
select-layout -t kctl even-vertical \; \
select-pane -t kctl:0.0 \; \
attach -t kctl attach -t kctl
``` ```
@@ -115,54 +114,66 @@ Switching between panes: `Ctrl-b` + arrow key, or `Ctrl-b` `o`.
### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane ### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane
1. Load the secret from AWS Secrets Manager: 1. Before entering the secrets workflow, verify the AWS session is valid
(`aws sts get-caller-identity`). If expired, offer to run the
configured SSO login command interactively.
2. Resolve the AWS Secrets Manager secret ID from `secret_name_template`
(using namespace + env) and the Kubernetes secret name from
`k8s_secret_name_template` (using namespace). No manual input required
for either name.
3. Fetch the AWS secret:
`aws secretsmanager get-secret-value --secret-id <secret-id> --region <region> --query SecretString --output text`. `aws secretsmanager get-secret-value --secret-id <secret-id> --region <region> --query SecretString --output text`.
2. Show the contained keys for selection. 4. Fetch all fields of the Kubernetes secret and base64-decode them:
3. Load the matching Kubernetes secret field: `kubectl -n <ns> get secret <secret-name> -o json`.
`kubectl -n <ns> get secret <secret-name> -o jsonpath='{.data.<field>}'`, 5. Compare every field at once in a table (key / AWS value / Kubernetes
base64-decode it. value / match status).
4. Compare the values (identical / different). 6. On mismatch, optionally request a force-sync for the whole secret:
5. On mismatch, optionally request a force-sync:
`kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`. `kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`.
All names (secret ID, secret name, field name, ExternalSecret name) are The ExternalSecret object name for the force-sync annotation is the only
asked for interactively at runtime, never hardcoded in the tool. value asked for interactively at runtime.
### 3.6 Context-pair pattern (configurable) — drives both status panes at once ### 3.6 Context resolution via templates
Requirement: the Tab switch in the control pane must switch **both** The actual kubectl context name/ARN for each environment is computed from
status panes below it, not just an internal state. a configurable template at startup. The two k9s status panes and all
kubectl calls in the control pane use the resolved context.
Configuration format (e.g. `~/.kctl-tui/config.yaml`), purely illustrative Configuration format (e.g. `~/.kctl-tui/config.yaml`), purely illustrative
with generic placeholders: with generic placeholders:
```yaml ```yaml
context_pairs: contexts:
- name: "environment-pair-1" - "internal"
contexts: ["<context-a1>", "<context-a2>"] - "external"
- name: "environment-pair-2" default_context: "internal"
contexts: ["<context-b1>", "<context-b2>"]
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
team_label_key: "<organization>/<label-name>" team_label_key: "<organization>/<label-name>"
``` ```
Behavior on Tab in the control pane: The `context_template` replaces `{region}`, `{account_id}`, `{env}`, and
`{context}` placeholders with the configured values and the currently
selected environment/context. The resolved value must match an existing
context in your kubeconfig (e.g. added via `aws eks update-kubeconfig`).
1. Determine the current context pair from the configuration. **Platform limitation on Windows without WSL:** `kill-session` is
2. Restart both k9s panes via tmux-specific. Windows Terminal (`wt.exe`) offers no equivalent scripting
`tmux respawn-pane -k -t kctl:0.1 "k9s --context <newA> -n <ns>"` and to end the session from inside a pane. On plain Windows (without WSL),
`... kctl:0.2 ...` (namespace stays the same). only a simplified flow is possible: k9s panes are closed manually (`q`,
3. If the current context is in no configured list: show a hint in the then `Ctrl+Shift+W`); automatic session termination is unavailable there.
control pane instead of an error. This limitation is the main reason the primary target system is set to
4. No action outside this configuration — no error, only a hint. Linux/WSL.
**Platform limitation on Windows without WSL:** `respawn-pane`/
`kill-session` are tmux-specific. Windows Terminal (`wt.exe`) offers no
equivalent scripting to replace panes or end the session from inside a
pane. On plain Windows (without WSL), only a simplified flow is possible:
k9s panes are closed manually (`q`, then `Ctrl+Shift+W`); Tab switching and
automatic session termination are unavailable there. This limitation is
the main reason the primary target system is set to Linux/WSL.
## 4. Non-functional requirements ## 4. Non-functional requirements
@@ -240,8 +251,8 @@ Rejected options (see discussion history):
- Split view v1 fixed at 2 status panes + 1 control pane (3 panes total). - Split view v1 fixed at 2 status panes + 1 control pane (3 panes total).
- No RBAC/permission checks before executing sensitive actions — the tool - No RBAC/permission checks before executing sensitive actions — the tool
assumes existing kubectl permissions. assumes existing kubectl permissions.
- Configuration file format (`config.yaml`) is a proposal, not finally - Configuration file format (`config.yaml`) is defined and implemented;
agreed; concrete label keys, context names, and namespace names are concrete label keys, context names, and namespace names are
project-specific and belong exclusively in the user's local, unversioned project-specific and belong exclusively in the user's local, unversioned
configuration, not in this document or the source code. configuration, not in this document or the source code.
- Native Windows (without WSL) remains a secondary platform with manual - Native Windows (without WSL) remains a secondary platform with manual
+27 -8
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os/exec" "os/exec"
"sort"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@@ -66,6 +67,9 @@ func (m *fullModel) bootstrap() tea.Msg {
if len(cfg.Envs) == 0 { if len(cfg.Envs) == 0 {
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")} return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
} }
if err := kubeexec.CheckTool("kubectl"); err != nil {
return errMsg{err}
}
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()} return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
} }
@@ -235,6 +239,7 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
for ns := range m.namespaces { for ns := range m.namespaces {
names = append(names, ns) names = append(names, ns)
} }
sort.Strings(names)
} else { } else {
names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue) names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue)
} }
@@ -252,35 +257,49 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
// configured envs, resolved via the context template, so both are // configured envs, resolved via the context template, so both are
// visible side by side. // visible side by side.
func (m *fullModel) startTmuxSession() tea.Cmd { func (m *fullModel) startTmuxSession() tea.Cmd {
return func() tea.Msg {
if err := kubeexec.CheckTool("tmux"); err != nil {
return errMsg{err}
}
if err := kubeexec.CheckTool("k9s"); err != nil {
return errMsg{err}
}
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 --context=%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)
envA := m.cfg.Envs[0] envA := m.cfg.Envs[0]
envB := m.cfg.Envs[0]
if len(m.cfg.Envs) > 1 {
envB = m.cfg.Envs[1]
}
ctxA := m.cfg.ResolveContext(envA, m.selectedContext) 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) 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", args := []string{
"new-session", "-d", "-s", "kctl",
panelCmd, ";", panelCmd, ";",
"set-option", "-t", "kctl", "remain-on-exit", "on", ";", "set-option", "-t", "kctl", "remain-on-exit", "on", ";",
"split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";", "split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";",
}
if len(m.cfg.Envs) > 1 {
envB := m.cfg.Envs[1]
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
args = append(args,
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";", "split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";",
)
}
args = append(args,
"select-layout", "-t", "kctl", "even-vertical", ";", "select-layout", "-t", "kctl", "even-vertical", ";",
"select-pane", "-t", "kctl:0.0", ";", "select-pane", "-t", "kctl:0.0", ";",
"attach", "-t", "kctl", "attach", "-t", "kctl",
) )
c := exec.Command("tmux", args...)
return tea.ExecProcess(c, func(err error) tea.Msg { return tea.ExecProcess(c, func(err error) tea.Msg {
return tmuxDoneMsg{err: err} return tmuxDoneMsg{err: err}
}) })
} }
}
func (m *fullModel) View() string { func (m *fullModel) View() string {
view := m.list.View() view := m.list.View()
+89 -2
View File
@@ -5,15 +5,50 @@ import (
"os" "os"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/skoelle/kctl-tui/internal/config"
"github.com/skoelle/kctl-tui/internal/kubeexec"
) )
// version is set via -ldflags at build time.
var version = "dev"
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "panel" { args := os.Args[1:]
if err := runPanel(os.Args[2:]); err != nil {
// Extract global flags before delegating to sub-commands.
verbose := false
filtered := make([]string, 0, len(args))
for _, a := range args {
switch a {
case "--verbose":
verbose = true
case "--version", "-v":
fmt.Printf("kctl-tui %s\n", version)
return
default:
filtered = append(filtered, a)
}
}
if verbose {
kubeexec.SetVerbose(true, os.Stderr)
}
if len(filtered) > 0 {
switch filtered[0] {
case "panel":
if err := runPanel(filtered[1:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err) fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err)
os.Exit(1) os.Exit(1)
} }
return return
case "config":
if err := runConfig(filtered[1:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui config error:", err)
os.Exit(1)
}
return
}
} }
m := newFullModel() m := newFullModel()
@@ -23,3 +58,55 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
func runConfig(args []string) error {
if len(args) == 0 || args[0] != "check" {
return fmt.Errorf("usage: kctl-tui config check")
}
cfgPath, err := config.DefaultPath()
if err != nil {
return fmt.Errorf("cannot determine config path: %w", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
return fmt.Errorf("failed to load %s: %w", cfgPath, err)
}
ok := true
if len(cfg.Contexts) == 0 {
fmt.Fprintln(os.Stderr, "ERROR: no 'contexts' configured")
ok = false
}
if len(cfg.Envs) == 0 {
fmt.Fprintln(os.Stderr, "ERROR: no 'envs' configured")
ok = false
}
if cfg.ContextTemplate == "" {
fmt.Fprintln(os.Stderr, "ERROR: 'context_template' is empty")
ok = false
}
if cfg.SecretNameTemplate == "" {
fmt.Fprintln(os.Stderr, "ERROR: 'secret_name_template' is empty")
ok = false
}
if cfg.TeamLabelKey == "" {
fmt.Fprintln(os.Stderr, "WARNING: 'team_label_key' is empty — team selection will have no groups")
}
if cfg.AWSRegion == "" {
fmt.Fprintln(os.Stderr, "WARNING: 'aws_region' is empty — secrets workflow will fail")
}
// Try resolving one context to verify the template works.
if len(cfg.Contexts) > 0 && len(cfg.Envs) > 0 && cfg.ContextTemplate != "" {
ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0])
fmt.Printf("Resolved context example: %s\n", ctx)
}
if ok {
fmt.Println("Config OK")
} else {
fmt.Fprintln(os.Stderr, "Config has errors — see above")
os.Exit(1)
}
return nil
}
+85 -34
View File
@@ -27,7 +27,6 @@ const (
stepRedeployList stepRedeployList
stepRedeployConfirm stepRedeployConfirm
stepAWSAuthPrompt stepAWSAuthPrompt
stepK8sSecretName
stepDiffResult stepDiffResult
stepForceSyncConfirm stepForceSyncConfirm
stepExternalSecretName stepExternalSecretName
@@ -47,11 +46,12 @@ type panelModel struct {
deploymentName 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
diffOffset int // scroll position for diff table
message string message string
err error err error
@@ -77,13 +77,18 @@ func newPanelModel(context, ns, team string) *panelModel {
ti.Focus() ti.Focus()
cfgPath, _ := config.DefaultPath() cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath) cfg, loadErr := config.Load(cfgPath)
l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti} m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
if loadErr != nil {
m.err = fmt.Errorf("config load failed: %w", loadErr)
m.step = stepError
} else {
m.showEnvMenu() m.showEnvMenu()
}
return m return m
} }
@@ -140,6 +145,14 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleEsc() return m.handleEsc()
case "enter": case "enter":
return m.handleEnter() return m.handleEnter()
case "up", "k":
if m.step == stepDiffResult {
return m.scrollDiff(-1)
}
case "down", "j":
if m.step == stepDiffResult {
return m.scrollDiff(1)
}
} }
} }
@@ -155,11 +168,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 { return m.step == stepExternalSecretName
case stepK8sSecretName, stepExternalSecretName:
return true
}
return false
} }
// handleEsc navigates one level up: action menu -> env menu, most // handleEsc navigates one level up: action menu -> env menu, most
@@ -192,9 +201,6 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
return m.fromRedeployConfirm() return m.fromRedeployConfirm()
case stepAWSAuthPrompt: case stepAWSAuthPrompt:
return m.fromAWSAuthPrompt() return m.fromAWSAuthPrompt()
case stepK8sSecretName:
m.k8sSecretName = m.input.Value()
return m.compareAllFields()
case stepForceSyncConfirm: case stepForceSyncConfirm:
return m.fromForceSyncConfirm() return m.fromForceSyncConfirm()
case stepExternalSecretName: case stepExternalSecretName:
@@ -258,6 +264,9 @@ func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
// interactively instead of letting the user hit a confusing failure // interactively instead of letting the user hit a confusing failure
// several steps later. // several steps later.
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) { func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
if err := kubeexec.CheckTool("aws"); err != nil {
return m.showError(err)
}
if err := kubeexec.CheckAWSAuth(); err != nil { if err := kubeexec.CheckAWSAuth(); err != nil {
m.err = err m.err = err
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
@@ -296,14 +305,17 @@ func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
return m.startSecretsFlow() return m.startSecretsFlow()
} }
// startSecretsFlow computes the AWS secret ID from the configured // startSecretsFlow computes the AWS secret ID (namespace + env) and the
// template (namespace + env) and fetches it directly - no more listing // Kubernetes secret name (namespace only) from their respective
// secrets or asking for a region, both now driven by config. // templates and fetches the AWS side directly - no manual input required
// for either name.
func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) { func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
m.awsSecretID = m.cfg.ResolveSecretName(m.ns, m.currentEnv) m.awsSecretName = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.cfg.AWSRegion) m.k8sSecretName = m.cfg.ResolveK8sSecretName(m.ns)
raw, err := kubeexec.GetAWSSecretString(m.awsSecretName, m.cfg.AWSRegion)
if err != nil { if err != nil {
return m.showError(fmt.Errorf("failed to fetch AWS secret %q: %w", m.awsSecretID, err)) return m.showError(fmt.Errorf("failed to fetch AWS secret %q: %w", m.awsSecretName, 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 {
@@ -316,10 +328,7 @@ func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
} }
} }
m.step = stepK8sSecretName return m.compareAllFields()
m.input.SetValue("")
m.input.Placeholder = "Kubernetes secret name (in namespace " + m.ns + ")"
return m, nil
} }
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) { func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
@@ -358,15 +367,16 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
} }
// 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 templated AWS secret in one go. // it against every key of the AWS secret in one go.
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) { func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
k8sValues, err := kubeexec.GetSecretAllFields(m.resolvedContext(), 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.currentEnv, m.awsSecretID, m.k8sSecretName, m.diffEntries) m.diffOffset = 0
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
if anyMismatch(m.diffEntries) { if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
@@ -381,16 +391,52 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func renderDiffTable(env, awsSecretID, k8sSecretName string, entries []kctl.SecretDiffEntry) string { func (m *panelModel) scrollDiff(delta int) (tea.Model, tea.Cmd) {
newOff := m.diffOffset + delta
if newOff < 0 {
newOff = 0
}
maxOff := len(m.diffEntries) - 1
if maxOff < 0 {
maxOff = 0
}
if newOff > maxOff {
newOff = maxOff
}
m.diffOffset = newOff
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
return m, nil
}
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry, offset, visibleHeight int) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, 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 { start := offset
if start > len(entries) {
start = len(entries)
}
end := len(entries)
if visibleHeight > 0 && start+visibleHeight < end {
end = start + visibleHeight
}
for _, e := range entries[start:end] {
status := "OK" status := "OK"
if !e.Match { if !e.Match {
status = "MISMATCH" status = "MISMATCH"
} }
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(e.Left, 20), truncate(e.Right, 20), status) left := e.Left
if e.LeftBin {
left = fmt.Sprintf("<binary %d bytes>", len(e.Left))
}
right := e.Right
if e.RightBin {
right = fmt.Sprintf("<binary %d bytes>", len(e.Right))
}
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(left, 20), truncate(right, 20), status)
}
if len(entries) > 0 {
fmt.Fprintf(&b, "\n Showing %d-%d of %d fields (j/k or arrow keys to scroll)", start+1, end, len(entries))
} }
return b.String() return b.String()
} }
@@ -418,7 +464,15 @@ 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 := strings.TrimSpace(m.input.Value())
if name == "" {
return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
}
for _, r := range name {
if r < 0x20 || r > 0x7e || r == '/' || r == ' ' {
return m.showError(fmt.Errorf("ExternalSecret name contains invalid character: %q", r))
}
}
ts := time.Now().Unix() ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts) _, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
if err != nil { if err != nil {
@@ -460,10 +514,7 @@ func (m *panelModel) View() string {
} }
func (m *panelModel) stepPrompt() string { func (m *panelModel) stepPrompt() string {
switch m.step { if m.step == stepExternalSecretName {
case stepK8sSecretName:
return fmt.Sprintf("Kubernetes secret name (env=%s, namespace=%s)", m.currentEnv, m.ns)
case stepExternalSecretName:
return "ExternalSecret object name to annotate" return "ExternalSecret object name to annotate"
} }
return "" return ""
+8
View File
@@ -34,6 +34,14 @@ aws_account_id: "123456789012"
# environment. Available placeholders: {namespace}, {env}. # environment. Available placeholders: {namespace}, {env}.
secret_name_template: "tf-{namespace}-{env}-secrets" 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, # Builds the actual kubectl context name/ARN from region, account ID, env,
# and context. Available placeholders: {region}, {account_id}, {env}, # and context. Available placeholders: {region}, {account_id}, {env},
# {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN # {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN
+21 -1
View File
@@ -5,6 +5,26 @@ go 1.22
require ( require (
github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/lipgloss v1.0.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.4.2 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+49
View File
@@ -0,0 +1,49 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+22
View File
@@ -41,6 +41,12 @@ type Config struct {
// namespace and env, e.g. "tf-{namespace}-{env}-secrets". // namespace and env, e.g. "tf-{namespace}-{env}-secrets".
SecretNameTemplate string `yaml:"secret_name_template"` 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 // ContextTemplate builds the actual kubectl context name/ARN from
// region, account_id, env, and context, e.g. // region, account_id, env, and context, e.g.
// "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1". // "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1".
@@ -96,6 +102,22 @@ func (c Config) ResolveSecretName(namespace, env string) string {
}) })
} }
// 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()
+22
View File
@@ -28,6 +28,7 @@ envs:
aws_region: "eu-central-1" aws_region: "eu-central-1"
aws_account_id: "123456789012" aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets" 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" 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"
`) `)
@@ -46,6 +47,9 @@ team_label_key: "example.org/team"
if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 { if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 {
t.Fatalf("unexpected contexts/envs: %+v", cfg) 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) { func TestEffectiveDefaultContext(t *testing.T) {
@@ -87,6 +91,24 @@ func TestResolveSecretName(t *testing.T) {
} }
} }
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) { func TestLoginCommand_DefaultsWhenUnset(t *testing.T) {
cfg := Config{} cfg := Config{}
if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand { if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand {
+24 -1
View File
@@ -9,6 +9,23 @@ type SecretDiffEntry struct {
Left string // e.g. the AWS Secrets Manager value Left string // e.g. the AWS Secrets Manager value
Right string // e.g. the decoded Kubernetes secret value Right string // e.g. the decoded Kubernetes secret value
Match bool Match bool
LeftBin bool // true if Left contains non-printable (binary) data
RightBin bool // true if Right contains non-printable (binary) data
}
// IsBinary reports whether s contains non-printable bytes (i.e. is likely
// binary data rather than human-readable text). Control characters below
// space (0x20) are excluded, except for common whitespace (\t, \n, \r).
func IsBinary(s string) bool {
for _, r := range s {
if r > 0x7f {
return true
}
if r < 0x20 && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
} }
// DiffSecretValues compares two key/value maps and returns a sorted list of // DiffSecretValues compares two key/value maps and returns a sorted list of
@@ -35,7 +52,13 @@ func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
for _, k := range keys { for _, k := range keys {
l := left[k] l := left[k]
r := right[k] r := right[k]
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: l == r}) lb := IsBinary(l)
rb := IsBinary(r)
match := l == r
if lb || rb {
match = l == r
}
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: match, LeftBin: lb, RightBin: rb})
} }
return result return result
} }
+40
View File
@@ -57,3 +57,43 @@ func TestDiffSecretValues_EmptyMaps(t *testing.T) {
t.Fatalf("expected no mismatch for empty maps") t.Fatalf("expected no mismatch for empty maps")
} }
} }
func TestIsBinary_Plaintext(t *testing.T) {
if IsBinary("hello world") {
t.Fatal("expected plaintext to not be binary")
}
if IsBinary("line1\nline2\ttab") {
t.Fatal("expected newline/tab to not be binary")
}
}
func TestIsBinary_BinaryData(t *testing.T) {
if !IsBinary("hello\x00world") {
t.Fatal("expected null byte to be binary")
}
if !IsBinary("key=\xff\xfe") {
t.Fatal("expected non-ASCII bytes to be binary")
}
}
func TestDiffSecretValues_BinaryDetection(t *testing.T) {
left := map[string]string{"ok": "text", "bin": "data\x00here"}
right := map[string]string{"ok": "text", "bin": "data\x00here"}
entries := DiffSecretValues(left, right)
for _, e := range entries {
if e.Key == "bin" {
if !e.LeftBin || !e.RightBin {
t.Fatalf("expected binary flags set for key 'bin', got LeftBin=%v RightBin=%v", e.LeftBin, e.RightBin)
}
if !e.Match {
t.Fatalf("expected binary values to match")
}
}
if e.Key == "ok" {
if e.LeftBin || e.RightBin {
t.Fatalf("expected binary flags unset for key 'ok'")
}
}
}
}
+28 -3
View File
@@ -19,12 +19,27 @@ import (
) )
func runOutput(name string, args ...string) (string, error) { func runOutput(name string, args ...string) (string, error) {
logCmd(name, args...)
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput() var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil { if err != nil {
return "", fmt.Errorf("%s %s failed: %w\n%s", name, strings.Join(args, " "), err, string(out)) logErr(name, err)
prefix := fmt.Sprintf("%s %s failed: %v", name, strings.Join(args, " "), err)
msg := prefix
if s := strings.TrimSpace(stderr.String()); s != "" {
msg += "\n" + s
} }
return strings.TrimSpace(string(out)), nil if s := strings.TrimSpace(stdout.String()); s != "" {
msg += "\n" + s
}
return "", fmt.Errorf("%s", msg)
}
out := strings.TrimSpace(stdout.String())
logOutput(name, out)
return out, nil
} }
// kubectlArgs prepends a --context flag when context is non-empty. // kubectlArgs prepends a --context flag when context is non-empty.
@@ -162,3 +177,13 @@ func RunAWSLogin(loginCommand string) *exec.Cmd {
} }
return exec.Command(parts[0], parts[1:]...) return exec.Command(parts[0], parts[1:]...)
} }
// CheckTool verifies that a named executable is available in PATH.
// Returns nil if found, or a descriptive error if not.
func CheckTool(name string) error {
_, err := exec.LookPath(name)
if err != nil {
return fmt.Errorf("%q not found in PATH — please install it first", name)
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package kubeexec
import (
"fmt"
"io"
"strings"
"sync"
)
var (
verbose bool
logOut io.Writer = io.Discard
mu sync.Mutex
)
// SetVerbose enables or disables debug logging of executed commands.
// When enabled, commands and their outputs are written to the provided
// writer (typically os.Stderr). When disabled (the default), all logging
// is discarded.
func SetVerbose(enabled bool, w io.Writer) {
mu.Lock()
defer mu.Unlock()
verbose = enabled
if w != nil {
logOut = w
}
}
func logCmd(name string, args ...string) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, "[cmd] %s %s\n", name, strings.Join(args, " "))
}
func logOutput(name string, output string) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
if output != "" {
fmt.Fprintf(logOut, "[out] %s: %s\n", name, output)
}
}
func logErr(name string, err error) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, "[err] %s: %v\n", name, err)
}