mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-18 04:20:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f889de86f | ||
|
|
ff898f727a | ||
|
|
f7606011bb | ||
|
|
c02e145e4a | ||
|
|
929b073382 | ||
|
|
3af468de98 | ||
|
|
0a607bab28 | ||
|
|
1f87cdbf08 | ||
|
|
79c861682b | ||
|
|
c229f4d5d8 | ||
|
|
9daba2a3f0 |
@@ -1,27 +1,35 @@
|
|||||||
# kctl-tui
|
# kctl-tui
|
||||||
|
|
||||||
A small terminal entry point for everyday Kubernetes work: pick a context,
|
A small terminal entry point for everyday Kubernetes work: pick a context
|
||||||
a team, and a namespace once, then drive status (via k9s), rollout
|
and a namespace once, then drive rollout restarts and an AWS Secrets
|
||||||
restarts, and an AWS Secrets Manager <-> Kubernetes Secret diff/force-sync
|
Manager <-> Kubernetes Secret diff/force-sync per environment from one
|
||||||
workflow from one place instead of retyping long `kubectl` commands.
|
place instead of retyping long `kubectl` commands.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Working with several clusters, many namespaces per team, and paired
|
Working with several clusters, many namespaces per team, and paired
|
||||||
environments (e.g. staging/production) quickly turns into a lot of repeated
|
environments (e.g. beta/prod) quickly turns into a lot of repeated typing
|
||||||
typing with plain `kubectl`/`k9s`. kctl-tui adds:
|
with plain `kubectl`/`k9s`. kctl-tui adds:
|
||||||
|
|
||||||
- A guided **context -> team -> namespace** selection with sensible
|
- A guided **context -> team -> namespace** selection that starts
|
||||||
defaults (the currently active context/namespace is pre-selected).
|
directly at team selection (using a configured default context), with
|
||||||
|
the context screen just one `Esc` away.
|
||||||
- Namespace grouping by an arbitrary, configurable **label** instead of
|
- Namespace grouping by an arbitrary, configurable **label** instead of
|
||||||
scrolling through every namespace in the cluster.
|
scrolling through every namespace in the cluster.
|
||||||
- A **3-pane view** (via `tmux`): one control pane for actions, two status
|
- A **3-pane view** (via `tmux`): one control pane for actions, two status
|
||||||
panes running `k9s` for the current namespace across two related
|
panes running `k9s` for the current namespace across your two
|
||||||
contexts.
|
configured environments (e.g. beta/prod), shown side by side.
|
||||||
- A guided **rollout restart** that lists deployments instead of requiring
|
- A control-pane menu organized **by environment**: pick beta or prod,
|
||||||
you to know/type the exact deployment name.
|
then Secrets sync or Redeploy for that environment specifically.
|
||||||
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison,
|
- AWS Secrets Manager secret IDs and Kubernetes context names/ARNs are
|
||||||
including an optional ExternalSecret force-sync annotation.
|
**computed from configurable templates** (namespace + environment),
|
||||||
|
instead of listing secrets or discovering contexts live from
|
||||||
|
`kubectl`/`aws-cli`.
|
||||||
|
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison of
|
||||||
|
every field at once, with a force-sync request for the whole secret if
|
||||||
|
anything differs.
|
||||||
|
- An **AWS auth check** before the secrets workflow, offering to run your
|
||||||
|
configured SSO login command interactively if the session has expired.
|
||||||
|
|
||||||
See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
|
See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
|
||||||
[PLAN.md](PLAN.md) for the implementation roadmap and current status.
|
[PLAN.md](PLAN.md) for the implementation roadmap and current status.
|
||||||
@@ -31,38 +39,41 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
|
|||||||
```
|
```
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| Control pane: kctl-tui panel |
|
| Control pane: kctl-tui panel |
|
||||||
| -> Redeploy, Secrets diff/force-sync |
|
| -> 1) Quit 2) beta 3) prod |
|
||||||
|
| each with: a) Secrets sync b) Redeploy |
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| k9s --context <context-a> -n <namespace> |
|
| k9s --context <resolved beta context> -n <ns> |
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
| k9s --context <context-b> -n <namespace> |
|
| k9s --context <resolved prod context> -n <ns> |
|
||||||
+--------------------------------------------------+
|
+--------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Run `kctl-tui`. It walks you through context, team, and namespace
|
1. Run `kctl-tui`. It loads your config, applies the default context, and
|
||||||
selection.
|
jumps straight to team selection; press `Esc` there to pick a
|
||||||
2. Once a namespace is confirmed, it opens a `tmux` session with the layout
|
different context first.
|
||||||
above and attaches to it.
|
2. Pick a team (namespace label filter), then a namespace.
|
||||||
3. Inside the control pane you can trigger a rollout restart or compare/
|
3. It opens a `tmux` session with the layout above: the control pane runs
|
||||||
force-sync a secret. The two status panes keep showing live pod state
|
this binary in "panel" mode, the two status panes run `k9s` against
|
||||||
via `k9s`, so there is no separate "status" menu entry.
|
your first two configured environments (e.g. beta and prod), resolved
|
||||||
4. Pressing `Esc` in the control pane closes the whole `tmux` session
|
from `context_template`.
|
||||||
(including both `k9s` panes) and returns you to the namespace
|
4. In the control pane, pick an environment, then Secrets sync or
|
||||||
selection.
|
Redeploy for that environment. `Esc` goes back one level (action menu
|
||||||
5. Pressing `Tab` in the control pane switches both status panes to the
|
-> environment menu -> closes the whole tmux session, including both
|
||||||
paired context configured in `context_pairs` (see Configuration),
|
`k9s` panes, and returns you to namespace selection).
|
||||||
keeping the same namespace.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- `kubectl`, configured with access to your cluster(s).
|
- `kubectl`, configured with access to your cluster(s) (the actual
|
||||||
|
context names/ARNs are resolved from your `context_template`, see
|
||||||
|
Configuration below - they must already exist in your kubeconfig, e.g.
|
||||||
|
added via `aws eks update-kubeconfig`).
|
||||||
- `k9s` (used for the two status panes).
|
- `k9s` (used for the two status panes).
|
||||||
- `tmux` (used for the 3-pane layout). On Windows, this means running
|
- `tmux` (used for the 3-pane layout). On Windows, this means running
|
||||||
kctl-tui inside **WSL** — `tmux` has no native Windows port. Native
|
kctl-tui inside **WSL** — `tmux` has no native Windows port. Native
|
||||||
Windows Terminal has its own split-pane feature, but it cannot be
|
Windows Terminal has its own split-pane feature, but it cannot be
|
||||||
scripted from inside a pane the way `tmux` can, so the automated 3-pane
|
scripted from inside a pane the way `tmux` can, so the automated 3-pane
|
||||||
layout and the `Tab`/`Esc` session handling described above are only
|
layout and the `Esc` session handling described above are only fully
|
||||||
fully supported under Linux/WSL. See SPEC.md section 3.6 for details.
|
supported under Linux/WSL. See SPEC.md section 3.6 for details.
|
||||||
- `aws` CLI, configured with credentials, only needed for the secrets
|
- `aws` CLI, configured with credentials, only needed for the secrets
|
||||||
workflow.
|
workflow.
|
||||||
|
|
||||||
@@ -98,29 +109,58 @@ matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/rele
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml`
|
Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml`
|
||||||
and adjust it to your own cluster setup:
|
and adjust it to your own setup:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
context_pairs:
|
|
||||||
- name: "example-environment-pair"
|
|
||||||
contexts:
|
contexts:
|
||||||
- "example-context-a"
|
- "internal"
|
||||||
- "example-context-b"
|
- "external"
|
||||||
|
default_context: "internal"
|
||||||
|
|
||||||
|
envs:
|
||||||
|
- "beta"
|
||||||
|
- "prod"
|
||||||
|
|
||||||
|
aws_region: "eu-central-1"
|
||||||
|
aws_account_id: "123456789012"
|
||||||
|
|
||||||
|
secret_name_template: "tf-{namespace}-{env}-secrets"
|
||||||
|
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"
|
||||||
|
aws_sso_login_command: "aws sso login"
|
||||||
```
|
```
|
||||||
|
|
||||||
- `context_pairs`: groups of related `kubectl` contexts. `Tab` in the
|
- `contexts` / `default_context`: the top-level grouping the tool starts
|
||||||
control pane cycles through the contexts of whichever group the current
|
from (e.g. a network boundary such as internal/external-facing
|
||||||
context belongs to.
|
clusters). This is the outermost navigation level, one `Esc` above team
|
||||||
|
selection.
|
||||||
|
- `envs`: the environments switchable from the control panel (e.g.
|
||||||
|
"beta"/"prod"). The **first two** entries are also used for the two k9s
|
||||||
|
status panes shown side by side.
|
||||||
|
- `aws_region` / `aws_account_id`: used for AWS Secrets Manager calls and
|
||||||
|
to fill the `{account_id}` placeholder in `context_template`.
|
||||||
|
`123456789012` is a placeholder, not a real account.
|
||||||
|
- `secret_name_template`: builds the AWS Secrets Manager secret ID from
|
||||||
|
the chosen namespace and environment. Placeholders: `{namespace}`,
|
||||||
|
`{env}`.
|
||||||
|
- `context_template`: builds the actual kubectl context name/ARN from
|
||||||
|
region, account ID, environment, and context. Placeholders: `{region}`,
|
||||||
|
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
|
||||||
|
`-1`, cluster naming, ARN shape) to match how your own clusters/contexts
|
||||||
|
are actually named — the resolved value must match an existing context
|
||||||
|
in your kubeconfig.
|
||||||
- `team_label_key`: the Kubernetes namespace label used to group
|
- `team_label_key`: the Kubernetes namespace label used to group
|
||||||
namespaces by team/ownership in the team-selection screen. This is
|
namespaces by team/ownership in the team-selection screen. This is
|
||||||
entirely up to your organization's labeling convention; kctl-tui ships
|
entirely up to your organization's labeling convention; kctl-tui ships
|
||||||
with no default team label of its own.
|
with no default team label of its own.
|
||||||
|
- `aws_sso_login_command`: run interactively if `aws sts
|
||||||
|
get-caller-identity` fails before the secrets workflow (e.g. an expired
|
||||||
|
SSO session). Defaults to `aws sso login`.
|
||||||
|
|
||||||
`~/.kctl-tui/config.yaml` is not part of this repository and should stay
|
`~/.kctl-tui/config.yaml` is not part of this repository and should stay
|
||||||
that way — it typically contains your organization's internal context and
|
that way — it typically contains your organization's internal account ID,
|
||||||
label names.
|
context naming, and label names.
|
||||||
|
|
||||||
## WSL setup notes
|
## WSL setup notes
|
||||||
|
|
||||||
@@ -141,11 +181,11 @@ go vet ./...
|
|||||||
go build ./cmd/kctl-tui
|
go build ./cmd/kctl-tui
|
||||||
```
|
```
|
||||||
|
|
||||||
Pure logic (context-pair matching, label filtering, config parsing) lives
|
Pure logic (template resolution, label filtering, config parsing, secret
|
||||||
in `internal/kctl` and `internal/config` and is covered by unit tests. Code
|
diffing) lives in `internal/kctl` and `internal/config` and is covered by
|
||||||
that shells out to `kubectl`/`aws`/`tmux` lives in `internal/kubeexec` and
|
unit tests. Code that shells out to `kubectl`/`aws`/`tmux` lives in
|
||||||
in `cmd/kctl-tui` and is intentionally kept thin and untested, since it has
|
`internal/kubeexec` and in `cmd/kctl-tui` and is intentionally kept thin
|
||||||
no meaningful behavior without a live cluster.
|
and untested, since it has no meaningful behavior without a live cluster.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+68
-48
@@ -20,9 +20,12 @@ const (
|
|||||||
screenNamespace
|
screenNamespace
|
||||||
)
|
)
|
||||||
|
|
||||||
// fullModel drives the interactive context -> team -> namespace navigation
|
// fullModel drives the interactive navigation. It starts directly at the
|
||||||
// and, once a namespace is chosen, launches the 3-pane tmux session
|
// team-selection screen using the configured default context, and only
|
||||||
// (control pane + two k9s panes) via tea.ExecProcess.
|
// shows the context screen when the user explicitly goes back via Esc.
|
||||||
|
// Once a namespace is chosen, it launches the 3-pane tmux session
|
||||||
|
// (control pane + two k9s panes, one per configured env) via
|
||||||
|
// tea.ExecProcess.
|
||||||
type fullModel struct {
|
type fullModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
state screenState
|
state screenState
|
||||||
@@ -42,48 +45,43 @@ func newFullModel() *fullModel {
|
|||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||||
l.Title = "kctl-tui"
|
l.Title = "kctl-tui"
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
return &fullModel{list: l, state: screenContext}
|
return &fullModel{list: l}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) Init() tea.Cmd {
|
func (m *fullModel) Init() tea.Cmd {
|
||||||
return m.loadContexts
|
return m.bootstrap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadContexts() tea.Msg {
|
// bootstrap loads the config and applies the default context so the tool
|
||||||
contexts, err := kubeexec.GetContexts()
|
// can jump straight to the team-selection screen.
|
||||||
|
func (m *fullModel) bootstrap() tea.Msg {
|
||||||
|
cfgPath, _ := config.DefaultPath()
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
current := kubeexec.GetCurrentContext()
|
if len(cfg.Contexts) == 0 {
|
||||||
|
return errMsg{fmt.Errorf("no 'contexts' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
||||||
cfgPath, _ := config.DefaultPath()
|
|
||||||
cfg, _ := config.Load(cfgPath)
|
|
||||||
|
|
||||||
items := make([]list.Item, 0, len(contexts))
|
|
||||||
if current != "" {
|
|
||||||
items = append(items, simpleItem{label: "(current) " + current, value: current})
|
|
||||||
}
|
}
|
||||||
for _, c := range contexts {
|
if len(cfg.Envs) == 0 {
|
||||||
if c != current {
|
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
||||||
items = append(items, simpleItem{label: c, value: c})
|
|
||||||
}
|
}
|
||||||
}
|
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
|
||||||
return contextsLoadedMsg{items: items, cfg: cfg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type contextsLoadedMsg struct {
|
type bootstrapMsg struct {
|
||||||
items []list.Item
|
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contextsLoadedMsg struct{ items []list.Item }
|
||||||
|
|
||||||
type teamsLoadedMsg struct {
|
type teamsLoadedMsg struct {
|
||||||
items []list.Item
|
items []list.Item
|
||||||
namespaces map[string]map[string]string
|
namespaces map[string]map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type namespacesLoadedMsg struct {
|
type namespacesLoadedMsg struct{ items []list.Item }
|
||||||
items []list.Item
|
|
||||||
}
|
|
||||||
|
|
||||||
type tmuxDoneMsg struct{ err error }
|
type tmuxDoneMsg struct{ err error }
|
||||||
|
|
||||||
@@ -100,8 +98,12 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case contextsLoadedMsg:
|
case bootstrapMsg:
|
||||||
m.cfg = msg.cfg
|
m.cfg = msg.cfg
|
||||||
|
m.selectedContext = msg.context
|
||||||
|
return m, m.loadTeams
|
||||||
|
|
||||||
|
case contextsLoadedMsg:
|
||||||
m.state = screenContext
|
m.state = screenContext
|
||||||
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
|
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
|
||||||
m.list.SetItems(msg.items)
|
m.list.SetItems(msg.items)
|
||||||
@@ -110,7 +112,7 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case teamsLoadedMsg:
|
case teamsLoadedMsg:
|
||||||
m.namespaces = msg.namespaces
|
m.namespaces = msg.namespaces
|
||||||
m.state = screenTeam
|
m.state = screenTeam
|
||||||
m.list.Title = "Select team (esc = back to context)"
|
m.list.Title = fmt.Sprintf("Select team [context=%s] (esc = back to context)", m.selectedContext)
|
||||||
m.list.SetItems(msg.items)
|
m.list.SetItems(msg.items)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -157,6 +159,18 @@ func (m *fullModel) handleBack() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *fullModel) loadContexts() tea.Msg {
|
||||||
|
items := make([]list.Item, 0, len(m.cfg.Contexts))
|
||||||
|
for _, c := range m.cfg.Contexts {
|
||||||
|
label := c
|
||||||
|
if c == m.selectedContext {
|
||||||
|
label = "(current) " + c
|
||||||
|
}
|
||||||
|
items = append(items, simpleItem{label: label, value: c})
|
||||||
|
}
|
||||||
|
return contextsLoadedMsg{items: items}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
||||||
item, ok := m.list.SelectedItem().(simpleItem)
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -166,9 +180,6 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
switch m.state {
|
switch m.state {
|
||||||
case screenContext:
|
case screenContext:
|
||||||
m.selectedContext = item.value
|
m.selectedContext = item.value
|
||||||
if err := kubeexec.UseContext(item.value); err != nil {
|
|
||||||
return m, func() tea.Msg { return errMsg{err} }
|
|
||||||
}
|
|
||||||
return m, m.loadTeams
|
return m, m.loadTeams
|
||||||
|
|
||||||
case screenTeam:
|
case screenTeam:
|
||||||
@@ -177,16 +188,24 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case screenNamespace:
|
case screenNamespace:
|
||||||
m.selectedNamespace = item.value
|
m.selectedNamespace = item.value
|
||||||
if err := kubeexec.SetNamespace(item.value); err != nil {
|
|
||||||
return m, func() tea.Msg { return errMsg{err} }
|
|
||||||
}
|
|
||||||
return m, m.startTmuxSession()
|
return m, m.startTmuxSession()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bootstrapContext resolves a kubectl context purely to discover
|
||||||
|
// namespaces/labels for the team/namespace screens. The first configured
|
||||||
|
// env is used as a stable default for this discovery step, since
|
||||||
|
// namespace names are assumed to be identical across envs.
|
||||||
|
func (m *fullModel) bootstrapContext() string {
|
||||||
|
if len(m.cfg.Envs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.cfg.ResolveContext(m.cfg.Envs[0], m.selectedContext)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadTeams() tea.Msg {
|
func (m *fullModel) loadTeams() tea.Msg {
|
||||||
namespaces, err := kubeexec.GetNamespacesWithLabels()
|
namespaces, err := kubeexec.GetNamespacesWithLabels(m.bootstrapContext())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
@@ -227,25 +246,26 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTmuxSession builds the 3-pane tmux command (control pane running
|
// startTmuxSession builds the 3-pane tmux command: the control pane runs
|
||||||
// this binary in "panel" mode, plus two k9s status panes) and runs it via
|
// this binary in "panel" mode (letting the user pick an env and an
|
||||||
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal.
|
// action), and the two status panes run k9s against the first two
|
||||||
//
|
// configured envs, resolved via the context template, so both are
|
||||||
// Layout: even-vertical stacks all three panes evenly from top to bottom
|
// visible side by side.
|
||||||
// (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 {
|
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||||
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 --ctx=%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)
|
||||||
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
|
|
||||||
|
|
||||||
secondCtx := m.selectedContext
|
envA := m.cfg.Envs[0]
|
||||||
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok {
|
envB := m.cfg.Envs[0]
|
||||||
secondCtx = next
|
if len(m.cfg.Envs) > 1 {
|
||||||
|
envB = m.cfg.Envs[1]
|
||||||
}
|
}
|
||||||
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace)
|
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)
|
||||||
|
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
|
||||||
|
|
||||||
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
||||||
panelCmd, ";",
|
panelCmd, ";",
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
|
|||||||
return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
|
return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
|
|
||||||
return kctl.FindNextContext(current, pairs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
|
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
|
||||||
return kctl.DiffSecretValues(left, right)
|
return kctl.DiffSecretValues(left, right)
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-121
@@ -13,34 +13,40 @@ import (
|
|||||||
"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/config"
|
||||||
"github.com/skoelle/kctl-tui/internal/kctl"
|
"github.com/skoelle/kctl-tui/internal/kctl"
|
||||||
"github.com/skoelle/kctl-tui/internal/kubeexec"
|
"github.com/skoelle/kctl-tui/internal/kubeexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// panelStep identifies which part of the redeploy/secrets wizard is shown.
|
// panelStep identifies which part of the env/action wizard is shown.
|
||||||
type panelStep int
|
type panelStep int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stepMenu panelStep = iota
|
stepEnvMenu panelStep = iota
|
||||||
|
stepActionMenu
|
||||||
stepRedeployList
|
stepRedeployList
|
||||||
stepRedeployConfirm
|
stepRedeployConfirm
|
||||||
stepSecretRegion
|
stepAWSAuthPrompt
|
||||||
stepSecretList
|
|
||||||
stepK8sSecretName
|
stepK8sSecretName
|
||||||
stepDiffResult
|
stepDiffResult
|
||||||
stepForceSyncConfirm
|
stepForceSyncConfirm
|
||||||
stepExternalSecretName
|
stepExternalSecretName
|
||||||
stepDone
|
stepDone
|
||||||
|
stepError
|
||||||
)
|
)
|
||||||
|
|
||||||
type panelModel struct {
|
type panelModel struct {
|
||||||
ctx, ns, team string
|
context, ns, team string
|
||||||
|
cfg config.Config
|
||||||
|
|
||||||
|
currentEnv string
|
||||||
|
|
||||||
step panelStep
|
step panelStep
|
||||||
list list.Model
|
list list.Model
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
|
|
||||||
awsRegion string
|
deploymentName string
|
||||||
|
|
||||||
awsSecretID string
|
awsSecretID string
|
||||||
awsValues map[string]string
|
awsValues map[string]string
|
||||||
k8sSecretName string
|
k8sSecretName string
|
||||||
@@ -53,46 +59,79 @@ type panelModel struct {
|
|||||||
|
|
||||||
func runPanel(args []string) error {
|
func runPanel(args []string) error {
|
||||||
fs := flag.NewFlagSet("panel", flag.ContinueOnError)
|
fs := flag.NewFlagSet("panel", flag.ContinueOnError)
|
||||||
ctx := fs.String("ctx", "", "kubectl context")
|
context := fs.String("context", "", "context (e.g. internal/external)")
|
||||||
ns := fs.String("ns", "", "namespace")
|
ns := fs.String("ns", "", "namespace")
|
||||||
team := fs.String("team", "", "team label value")
|
team := fs.String("team", "", "team label value")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m := newPanelModel(*ctx, *ns, *team)
|
m := newPanelModel(*context, *ns, *team)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
_, err := p.Run()
|
_, err := p.Run()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPanelModel(ctx, ns, team string) *panelModel {
|
func newPanelModel(context, ns, team string) *panelModel {
|
||||||
l := list.New(menuItems(), list.NewDefaultDelegate(), 0, 0)
|
|
||||||
l.Title = fmt.Sprintf("kctl-tui panel [ctx=%s ns=%s team=%s]", ctx, ns, team)
|
|
||||||
l.SetShowStatusBar(false)
|
|
||||||
|
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
|
|
||||||
return &panelModel{ctx: ctx, ns: ns, team: team, step: stepMenu, list: l, input: ti}
|
cfgPath, _ := config.DefaultPath()
|
||||||
|
cfg, _ := config.Load(cfgPath)
|
||||||
|
|
||||||
|
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
|
||||||
|
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
|
||||||
|
m.showEnvMenu()
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuItems() []list.Item {
|
func (m *panelModel) showEnvMenu() {
|
||||||
return []list.Item{
|
items := make([]list.Item, 0, len(m.cfg.Envs)+1)
|
||||||
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"},
|
items = append(items, simpleItem{label: "Quit (closes this tmux session)", value: "quit"})
|
||||||
simpleItem{label: "Secrets: AWS <-> Kubernetes diff", value: "secrets"},
|
for _, env := range m.cfg.Envs {
|
||||||
simpleItem{label: "Quit (closes this tmux session)", value: "quit"},
|
items = append(items, simpleItem{label: env, value: env})
|
||||||
}
|
}
|
||||||
|
m.list.SetItems(items)
|
||||||
|
m.list.Title = fmt.Sprintf("kctl-tui panel [context=%s ns=%s team=%s]", m.context, m.ns, m.team)
|
||||||
|
m.step = stepEnvMenu
|
||||||
|
m.currentEnv = ""
|
||||||
|
m.message = ""
|
||||||
|
m.err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *panelModel) showActionMenu() {
|
||||||
|
m.list.SetItems([]list.Item{
|
||||||
|
simpleItem{label: "Secrets sync (AWS <-> Kubernetes)", value: "secrets"},
|
||||||
|
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"},
|
||||||
|
})
|
||||||
|
m.list.Title = fmt.Sprintf("kctl-tui panel [context=%s ns=%s team=%s env=%s] (esc = back)",
|
||||||
|
m.context, m.ns, m.team, m.currentEnv)
|
||||||
|
m.step = stepActionMenu
|
||||||
|
m.message = ""
|
||||||
|
m.err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedContext returns the actual kubectl context/ARN for the
|
||||||
|
// currently selected env, built from the configured context_template.
|
||||||
|
func (m *panelModel) resolvedContext() string {
|
||||||
|
return m.cfg.ResolveContext(m.currentEnv, m.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) Init() tea.Cmd { return nil }
|
func (m *panelModel) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
type awsLoginDoneMsg struct{ err error }
|
||||||
|
|
||||||
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.list.SetSize(msg.Width, msg.Height-2)
|
m.list.SetSize(msg.Width, msg.Height-2)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case awsLoginDoneMsg:
|
||||||
|
return m.afterAWSLogin(msg.err)
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
@@ -117,32 +156,42 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *panelModel) usesTextInput() bool {
|
func (m *panelModel) usesTextInput() bool {
|
||||||
switch m.step {
|
switch m.step {
|
||||||
case stepSecretRegion, stepK8sSecretName, stepExternalSecretName:
|
case stepK8sSecretName, stepExternalSecretName:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEsc closes the whole tmux session (all panes, including the two
|
// handleEsc navigates one level up: action menu -> env menu, most
|
||||||
// k9s status panes) before quitting this program, per SPEC.md 3.6.
|
// sub-steps -> action menu. From the top-level env menu it closes the
|
||||||
|
// whole tmux session (all panes, including the two k9s status panes)
|
||||||
|
// before quitting this program, per SPEC.md 3.6.
|
||||||
func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
|
func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
|
||||||
|
switch m.step {
|
||||||
|
case stepEnvMenu:
|
||||||
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
|
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
case stepActionMenu:
|
||||||
|
m.showEnvMenu()
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
m.showActionMenu()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
|
func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
|
||||||
switch m.step {
|
switch m.step {
|
||||||
case stepMenu:
|
case stepEnvMenu:
|
||||||
return m.fromMenu()
|
return m.fromEnvMenu()
|
||||||
|
case stepActionMenu:
|
||||||
|
return m.fromActionMenu()
|
||||||
case stepRedeployList:
|
case stepRedeployList:
|
||||||
return m.fromRedeployList()
|
return m.fromRedeployList()
|
||||||
case stepRedeployConfirm:
|
case stepRedeployConfirm:
|
||||||
return m.fromRedeployConfirm()
|
return m.fromRedeployConfirm()
|
||||||
case stepSecretRegion:
|
case stepAWSAuthPrompt:
|
||||||
m.awsRegion = m.input.Value()
|
return m.fromAWSAuthPrompt()
|
||||||
return m.fetchSecretList()
|
|
||||||
case stepSecretList:
|
|
||||||
return m.fromSecretList()
|
|
||||||
case stepK8sSecretName:
|
case stepK8sSecretName:
|
||||||
m.k8sSecretName = m.input.Value()
|
m.k8sSecretName = m.input.Value()
|
||||||
return m.compareAllFields()
|
return m.compareAllFields()
|
||||||
@@ -150,118 +199,111 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
|
|||||||
return m.fromForceSyncConfirm()
|
return m.fromForceSyncConfirm()
|
||||||
case stepExternalSecretName:
|
case stepExternalSecretName:
|
||||||
return m.doForceSync()
|
return m.doForceSync()
|
||||||
case stepDiffResult, stepDone:
|
case stepDiffResult, stepDone, stepError:
|
||||||
m.resetToMenu()
|
m.showActionMenu()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) resetToMenu() {
|
// showError switches to a dedicated error screen so failures from
|
||||||
m.step = stepMenu
|
// kubectl/aws calls stay visible until the user explicitly acknowledges
|
||||||
m.list.SetItems(menuItems())
|
// them with Enter, instead of being silently discarded.
|
||||||
m.list.Title = "kctl-tui panel"
|
func (m *panelModel) showError(err error) (tea.Model, tea.Cmd) {
|
||||||
m.message = ""
|
m.err = err
|
||||||
m.err = nil
|
m.step = stepError
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
|
func (m *panelModel) fromEnvMenu() (tea.Model, tea.Cmd) {
|
||||||
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if item.value == "quit" {
|
||||||
|
return m.handleEsc()
|
||||||
|
}
|
||||||
|
m.currentEnv = item.value
|
||||||
|
m.showActionMenu()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
|
||||||
item, ok := m.list.SelectedItem().(simpleItem)
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
switch item.value {
|
switch item.value {
|
||||||
case "redeploy":
|
case "redeploy":
|
||||||
deployments, err := kubeexec.GetDeployments(m.ns)
|
deployments, err := kubeexec.GetDeployments(m.resolvedContext(), m.ns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.err = err
|
return m.showError(err)
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
items := make([]list.Item, 0, len(deployments))
|
items := make([]list.Item, 0, len(deployments))
|
||||||
for _, d := range deployments {
|
for _, d := range deployments {
|
||||||
items = append(items, simpleItem{label: d, value: d})
|
items = append(items, simpleItem{label: d, value: d})
|
||||||
}
|
}
|
||||||
m.list.SetItems(items)
|
m.list.SetItems(items)
|
||||||
m.list.Title = "Select deployment to restart (esc = back)"
|
m.list.Title = fmt.Sprintf("Select deployment to restart [env=%s] (esc = back)", m.currentEnv)
|
||||||
m.step = stepRedeployList
|
m.step = stepRedeployList
|
||||||
case "secrets":
|
case "secrets":
|
||||||
m.step = stepSecretRegion
|
return m.checkAWSAuthAndProceed()
|
||||||
m.input.SetValue("eu-central-1")
|
|
||||||
m.input.Placeholder = "AWS region"
|
|
||||||
case "quit":
|
|
||||||
return m.handleEsc()
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
|
// checkAWSAuthAndProceed verifies the current AWS credentials/SSO session
|
||||||
item, ok := m.list.SelectedItem().(simpleItem)
|
// before entering the secrets workflow. If the check fails (e.g. an
|
||||||
if !ok {
|
// expired SSO session), it offers to run the configured login command
|
||||||
return m, nil
|
// interactively instead of letting the user hit a confusing failure
|
||||||
}
|
// several steps later.
|
||||||
m.k8sSecretName = item.value // reused as "deployment name" here
|
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
|
||||||
|
if err := kubeexec.CheckAWSAuth(); err != nil {
|
||||||
|
m.err = err
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
|
simpleItem{label: "Run AWS login now (" + m.cfg.LoginCommand() + ")", value: "login"},
|
||||||
simpleItem{label: "Cancel", value: "no"},
|
simpleItem{label: "Cancel", value: "cancel"},
|
||||||
})
|
})
|
||||||
m.list.Title = "Confirm rollout restart"
|
m.list.Title = "AWS session invalid or expired"
|
||||||
m.step = stepRedeployConfirm
|
m.step = stepAWSAuthPrompt
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
return m.startSecretsFlow()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
|
func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) {
|
||||||
item, ok := m.list.SelectedItem().(simpleItem)
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
if !ok || item.value != "yes" {
|
if !ok || item.value != "login" {
|
||||||
m.resetToMenu()
|
m.showActionMenu()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
deployment := m.k8sSecretName // set in fromRedeployList
|
cmd := kubeexec.RunAWSLogin(m.cfg.LoginCommand())
|
||||||
_, err := kubeexec.RolloutRestart(m.ns, deployment)
|
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
|
||||||
if err != nil {
|
return awsLoginDoneMsg{err: err}
|
||||||
m.err = err
|
})
|
||||||
}
|
|
||||||
status, _ := kubeexec.RolloutStatus(m.ns, deployment)
|
|
||||||
m.message = "Rollout status: " + status
|
|
||||||
m.step = stepDone
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchSecretList lists all AWS Secrets Manager secrets in the chosen
|
// afterAWSLogin re-checks AWS auth once the interactive login command has
|
||||||
// region so the user can pick one instead of typing the exact secret ID.
|
// finished (successfully or not) and either proceeds into the secrets
|
||||||
func (m *panelModel) fetchSecretList() (tea.Model, tea.Cmd) {
|
// workflow or shows the remaining error.
|
||||||
names, err := kubeexec.ListAWSSecrets(m.awsRegion)
|
func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
|
||||||
if err != nil {
|
if execErr != nil {
|
||||||
m.err = err
|
return m.showError(fmt.Errorf("login command failed to run: %w", execErr))
|
||||||
m.resetToMenu()
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
if len(names) == 0 {
|
if err := kubeexec.CheckAWSAuth(); err != nil {
|
||||||
m.err = fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion)
|
return m.showError(fmt.Errorf("still not authenticated with AWS after running '%s': %w", m.cfg.LoginCommand(), err))
|
||||||
m.resetToMenu()
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
items := make([]list.Item, 0, len(names))
|
return m.startSecretsFlow()
|
||||||
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) {
|
// startSecretsFlow computes the AWS secret ID from the configured
|
||||||
item, ok := m.list.SelectedItem().(simpleItem)
|
// template (namespace + env) and fetches it directly - no more listing
|
||||||
if !ok {
|
// secrets or asking for a region, both now driven by config.
|
||||||
return m, nil
|
func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
|
||||||
}
|
m.awsSecretID = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
|
||||||
m.awsSecretID = item.value
|
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.cfg.AWSRegion)
|
||||||
|
|
||||||
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.err = err
|
return m.showError(fmt.Errorf("failed to fetch AWS secret %q: %w", m.awsSecretID, err))
|
||||||
m.resetToMenu()
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -280,19 +322,51 @@ func (m *panelModel) fromSecretList() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// compareAllFields fetches every field of the Kubernetes secret and diffs
|
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
|
||||||
// it against every key of the AWS secret in one go, instead of requiring
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
// the user to pick a single field.
|
if !ok {
|
||||||
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
m.deploymentName = item.value
|
||||||
|
m.list.SetItems([]list.Item{
|
||||||
|
simpleItem{label: "Yes, restart " + item.value, value: "yes"},
|
||||||
|
simpleItem{label: "Cancel", value: "no"},
|
||||||
|
})
|
||||||
|
m.list.Title = fmt.Sprintf("Confirm rollout restart [env=%s]", m.currentEnv)
|
||||||
|
m.step = stepRedeployConfirm
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
|
||||||
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
|
if !ok || item.value != "yes" {
|
||||||
|
m.showActionMenu()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
ctx := m.resolvedContext()
|
||||||
|
_, err := kubeexec.RolloutRestart(ctx, m.ns, m.deploymentName)
|
||||||
|
if err != nil {
|
||||||
|
return m.showError(err)
|
||||||
|
}
|
||||||
|
status, err := kubeexec.RolloutStatus(ctx, m.ns, m.deploymentName)
|
||||||
|
if err != nil {
|
||||||
|
return m.showError(err)
|
||||||
|
}
|
||||||
|
m.message = fmt.Sprintf("[env=%s] Rollout status: %s", m.currentEnv, status)
|
||||||
|
m.step = stepDone
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareAllFields fetches every field of the Kubernetes secret and diffs
|
||||||
|
// it against every key of the templated AWS secret in one go.
|
||||||
|
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
||||||
|
k8sValues, err := kubeexec.GetSecretAllFields(m.resolvedContext(), m.ns, m.k8sSecretName)
|
||||||
|
if err != nil {
|
||||||
|
return m.showError(err)
|
||||||
|
}
|
||||||
m.k8sValues = k8sValues
|
m.k8sValues = k8sValues
|
||||||
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
||||||
m.message = renderDiffTable(m.awsSecretID, m.k8sSecretName, m.diffEntries)
|
m.message = renderDiffTable(m.currentEnv, m.awsSecretID, m.k8sSecretName, m.diffEntries)
|
||||||
|
|
||||||
if anyMismatch(m.diffEntries) {
|
if anyMismatch(m.diffEntries) {
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
@@ -307,9 +381,9 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDiffTable(awsSecretID, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
|
func renderDiffTable(env, awsSecretID, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "AWS secret: %s Kubernetes secret: %s\n\n", awsSecretID, k8sSecretName)
|
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretID, k8sSecretName)
|
||||||
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
|
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
status := "OK"
|
status := "OK"
|
||||||
@@ -346,9 +420,9 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
|
|||||||
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
||||||
name := m.input.Value()
|
name := m.input.Value()
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts)
|
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.err = err
|
return m.showError(err)
|
||||||
}
|
}
|
||||||
m.message += fmt.Sprintf("\nForce-sync requested for %s (timestamp %s).", name, 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
|
||||||
@@ -357,32 +431,38 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *panelModel) View() string {
|
func (m *panelModel) View() string {
|
||||||
switch m.step {
|
switch m.step {
|
||||||
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList:
|
case stepEnvMenu, stepActionMenu, stepRedeployList, stepRedeployConfirm:
|
||||||
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 stepAWSAuthPrompt:
|
||||||
|
errText := ""
|
||||||
|
if m.err != nil {
|
||||||
|
errText = m.err.Error() + "\n\n"
|
||||||
|
}
|
||||||
|
return errText + m.list.View()
|
||||||
case stepForceSyncConfirm:
|
case stepForceSyncConfirm:
|
||||||
return m.message + "\n\n" + m.list.View()
|
return m.message + "\n\n" + m.list.View()
|
||||||
case stepDiffResult, stepDone:
|
case stepDiffResult, stepDone:
|
||||||
v := m.message
|
return m.message + "\n\n(press enter to return to the action menu, esc to go back)"
|
||||||
|
case stepError:
|
||||||
|
errText := "unknown error"
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
v += "\nerror: " + m.err.Error()
|
errText = m.err.Error()
|
||||||
}
|
}
|
||||||
return v + "\n\n(press enter to return to menu, esc to close session)"
|
return "ERROR:\n\n" + errText + "\n\n(press enter to return to the action menu, esc to go back)"
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)",
|
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back)",
|
||||||
m.stepPrompt(), m.input.View())
|
m.stepPrompt(), m.input.View())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) stepPrompt() string {
|
func (m *panelModel) stepPrompt() string {
|
||||||
switch m.step {
|
switch m.step {
|
||||||
case stepSecretRegion:
|
|
||||||
return "AWS region to list secrets from"
|
|
||||||
case stepK8sSecretName:
|
case stepK8sSecretName:
|
||||||
return "Kubernetes secret name (in namespace " + m.ns + ")"
|
return fmt.Sprintf("Kubernetes secret name (env=%s, namespace=%s)", m.currentEnv, m.ns)
|
||||||
case stepExternalSecretName:
|
case stepExternalSecretName:
|
||||||
return "ExternalSecret object name to annotate"
|
return "ExternalSecret object name to annotate"
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-13
@@ -1,19 +1,54 @@
|
|||||||
# Example configuration for kctl-tui.
|
# Example configuration for kctl-tui.
|
||||||
# Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your
|
# Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your
|
||||||
# own cluster setup. Do NOT commit your real config.yaml with company- or
|
# own AWS/Kubernetes setup. Do NOT commit your real config.yaml with
|
||||||
# project-specific context/namespace/label names to a public repository.
|
# company- or project-specific account IDs, contexts, or label names to a
|
||||||
|
# public repository.
|
||||||
|
|
||||||
# Groups of kubectl contexts that belong together (e.g. the same
|
# Top-level grouping the tool starts from (e.g. network boundary such as
|
||||||
# environment pair, such as staging/production of the same cluster).
|
# internal/external-facing clusters). This is the outermost navigation
|
||||||
# Pressing TAB in the control pane cycles through the contexts listed here
|
# level; press Esc from the team-selection screen to get here.
|
||||||
# while keeping the current namespace.
|
|
||||||
context_pairs:
|
|
||||||
- name: "example-environment-pair"
|
|
||||||
contexts:
|
contexts:
|
||||||
- "example-context-a"
|
- "internal"
|
||||||
- "example-context-b"
|
- "external"
|
||||||
|
|
||||||
# The namespace label key used to group namespaces by team/ownership in the
|
# Pre-selected on startup so the tool can jump straight to team selection
|
||||||
# team-selection screen. Adjust this to whatever label your organization
|
# instead of asking for the context every time. Falls back to the first
|
||||||
# actually uses (can contain a domain prefix, e.g. "example.org/team").
|
# entry of 'contexts' if omitted.
|
||||||
|
default_context: "internal"
|
||||||
|
|
||||||
|
# Environments switchable from the control panel (e.g. "1) beta" /
|
||||||
|
# "2) prod"). The first two entries are also used for the two k9s status
|
||||||
|
# panes shown side by side.
|
||||||
|
envs:
|
||||||
|
- "beta"
|
||||||
|
- "prod"
|
||||||
|
|
||||||
|
# AWS region used for all AWS Secrets Manager calls.
|
||||||
|
aws_region: "eu-central-1"
|
||||||
|
|
||||||
|
# AWS account ID, used to fill the {account_id} placeholder below.
|
||||||
|
# 123456789012 is a placeholder, not a real account.
|
||||||
|
aws_account_id: "123456789012"
|
||||||
|
|
||||||
|
# Builds the AWS Secrets Manager secret ID from the chosen namespace and
|
||||||
|
# environment. Available placeholders: {namespace}, {env}.
|
||||||
|
secret_name_template: "tf-{namespace}-{env}-secrets"
|
||||||
|
|
||||||
|
# Builds the actual kubectl context name/ARN from region, account ID, env,
|
||||||
|
# and context. Available placeholders: {region}, {account_id}, {env},
|
||||||
|
# {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN
|
||||||
|
# shape) to match how your own EKS clusters/contexts are actually named.
|
||||||
|
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
|
||||||
|
|
||||||
|
# The namespace label key used to group namespaces by team/ownership in
|
||||||
|
# the team-selection screen. Adjust this to whatever label your
|
||||||
|
# organization actually uses (can contain a domain prefix, e.g.
|
||||||
|
# "example.org/team").
|
||||||
team_label_key: "example.org/team"
|
team_label_key: "example.org/team"
|
||||||
|
|
||||||
|
# Command used to (re-)authenticate with AWS before the Secrets workflow,
|
||||||
|
# if 'aws sts get-caller-identity' fails (e.g. an expired AWS SSO session).
|
||||||
|
# Defaults to "aws sso login" if omitted. Override this if your organization
|
||||||
|
# wraps SSO login in a custom script or needs a specific --profile, e.g.:
|
||||||
|
# aws_sso_login_command: "aws sso login --profile my-profile"
|
||||||
|
aws_sso_login_command: "aws sso login"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package config loads the user-specific, non-versioned kctl-tui
|
// Package config loads the user-specific, non-versioned kctl-tui
|
||||||
// configuration (context pairs, team label key) from a YAML file.
|
// configuration (contexts, envs, templates) from a YAML file.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,10 +11,89 @@ import (
|
|||||||
"github.com/skoelle/kctl-tui/internal/kctl"
|
"github.com/skoelle/kctl-tui/internal/kctl"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultAWSSSOLoginCommand is used when the user has not configured a
|
||||||
|
// custom login command in their config.yaml.
|
||||||
|
const DefaultAWSSSOLoginCommand = "aws sso login"
|
||||||
|
|
||||||
// Config is the root structure of ~/.kctl-tui/config.yaml
|
// Config is the root structure of ~/.kctl-tui/config.yaml
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ContextPairs []kctl.ContextPair `yaml:"context_pairs"`
|
// Contexts are the top-level groupings the tool starts from, e.g.
|
||||||
|
// "internal"/"external". This is the outermost navigation level.
|
||||||
|
Contexts []string `yaml:"contexts"`
|
||||||
|
|
||||||
|
// DefaultContext is pre-selected on startup so the tool can jump
|
||||||
|
// straight to team selection; falls back to the first entry of
|
||||||
|
// Contexts if empty.
|
||||||
|
DefaultContext string `yaml:"default_context"`
|
||||||
|
|
||||||
|
// Envs are the environments switchable from the control panel, e.g.
|
||||||
|
// "beta"/"prod". The first two entries are used for the two k9s
|
||||||
|
// status panes.
|
||||||
|
Envs []string `yaml:"envs"`
|
||||||
|
|
||||||
|
// AWSRegion is used for all AWS Secrets Manager calls.
|
||||||
|
AWSRegion string `yaml:"aws_region"`
|
||||||
|
|
||||||
|
// AWSAccountID fills the {account_id} placeholder in ContextTemplate.
|
||||||
|
AWSAccountID string `yaml:"aws_account_id"`
|
||||||
|
|
||||||
|
// SecretNameTemplate builds the AWS Secrets Manager secret ID from a
|
||||||
|
// namespace and env, e.g. "tf-{namespace}-{env}-secrets".
|
||||||
|
SecretNameTemplate string `yaml:"secret_name_template"`
|
||||||
|
|
||||||
|
// ContextTemplate builds the actual kubectl context name/ARN from
|
||||||
|
// region, account_id, env, and context, e.g.
|
||||||
|
// "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1".
|
||||||
|
ContextTemplate string `yaml:"context_template"`
|
||||||
|
|
||||||
|
// TeamLabelKey is the namespace label used to group namespaces by
|
||||||
|
// team/ownership in the team-selection screen.
|
||||||
TeamLabelKey string `yaml:"team_label_key"`
|
TeamLabelKey string `yaml:"team_label_key"`
|
||||||
|
|
||||||
|
// AWSSSOLoginCommand is run interactively if an AWS auth check fails
|
||||||
|
// before the secrets workflow (e.g. an expired SSO session).
|
||||||
|
AWSSSOLoginCommand string `yaml:"aws_sso_login_command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginCommand returns the configured AWS SSO login command, falling back
|
||||||
|
// to DefaultAWSSSOLoginCommand if none is set.
|
||||||
|
func (c Config) LoginCommand() string {
|
||||||
|
if c.AWSSSOLoginCommand == "" {
|
||||||
|
return DefaultAWSSSOLoginCommand
|
||||||
|
}
|
||||||
|
return c.AWSSSOLoginCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveDefaultContext returns DefaultContext if set, otherwise the
|
||||||
|
// first entry of Contexts, otherwise an empty string.
|
||||||
|
func (c Config) EffectiveDefaultContext() string {
|
||||||
|
if c.DefaultContext != "" {
|
||||||
|
return c.DefaultContext
|
||||||
|
}
|
||||||
|
if len(c.Contexts) > 0 {
|
||||||
|
return c.Contexts[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveContext builds the actual kubectl context name/ARN for a given
|
||||||
|
// env + context (e.g. "beta" + "internal") using ContextTemplate.
|
||||||
|
func (c Config) ResolveContext(env, context string) string {
|
||||||
|
return kctl.ResolveTemplate(c.ContextTemplate, map[string]string{
|
||||||
|
"region": c.AWSRegion,
|
||||||
|
"account_id": c.AWSAccountID,
|
||||||
|
"env": env,
|
||||||
|
"context": context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSecretName builds the AWS Secrets Manager secret ID for a given
|
||||||
|
// namespace + env using SecretNameTemplate.
|
||||||
|
func (c Config) ResolveSecretName(namespace, env string) string {
|
||||||
|
return kctl.ResolveTemplate(c.SecretNameTemplate, map[string]string{
|
||||||
|
"namespace": namespace,
|
||||||
|
"env": env,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
|
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
|
||||||
@@ -27,9 +106,10 @@ func DefaultPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the config file at path. If the file does not
|
// Load reads and parses the config file at path. If the file does not
|
||||||
// exist, it returns a zero-value Config (no pairs, empty label key) and no
|
// exist, it returns a zero-value Config and no error, so the tool can
|
||||||
// error, so the tool can run with sane defaults before the user has set up
|
// still start (with an explanatory error surfaced later where a required
|
||||||
// a config file.
|
// field turns out to be missing) before the user has set up a config
|
||||||
|
// file.
|
||||||
func Load(path string) (Config, error) {
|
func Load(path string) (Config, error) {
|
||||||
cfg := Config{}
|
cfg := Config{}
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,24 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if len(cfg.ContextPairs) != 0 {
|
if len(cfg.Contexts) != 0 || len(cfg.Envs) != 0 {
|
||||||
t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs)
|
t.Fatalf("expected no contexts/envs, got %+v", cfg)
|
||||||
}
|
|
||||||
if cfg.TeamLabelKey != "" {
|
|
||||||
t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_ValidFile(t *testing.T) {
|
func TestLoad_ValidFile(t *testing.T) {
|
||||||
content := []byte(`
|
content := []byte(`
|
||||||
context_pairs:
|
contexts:
|
||||||
- name: "env-pair-1"
|
- "internal"
|
||||||
contexts: ["ctx-a", "ctx-b"]
|
- "external"
|
||||||
|
default_context: "internal"
|
||||||
|
envs:
|
||||||
|
- "beta"
|
||||||
|
- "prod"
|
||||||
|
aws_region: "eu-central-1"
|
||||||
|
aws_account_id: "123456789012"
|
||||||
|
secret_name_template: "tf-{namespace}-{env}-secrets"
|
||||||
|
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"
|
||||||
`)
|
`)
|
||||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
@@ -38,7 +43,58 @@ team_label_key: "example.org/team"
|
|||||||
if cfg.TeamLabelKey != "example.org/team" {
|
if cfg.TeamLabelKey != "example.org/team" {
|
||||||
t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey)
|
t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey)
|
||||||
}
|
}
|
||||||
if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" {
|
if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 {
|
||||||
t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs)
|
t.Fatalf("unexpected contexts/envs: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveDefaultContext(t *testing.T) {
|
||||||
|
cfg := Config{Contexts: []string{"internal", "external"}}
|
||||||
|
if got := cfg.EffectiveDefaultContext(); got != "internal" {
|
||||||
|
t.Fatalf("expected first context as fallback default, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DefaultContext = "external"
|
||||||
|
if got := cfg.EffectiveDefaultContext(); got != "external" {
|
||||||
|
t.Fatalf("expected explicit default_context to win, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := Config{}
|
||||||
|
if got := empty.EffectiveDefaultContext(); got != "" {
|
||||||
|
t.Fatalf("expected empty string when no contexts configured, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveContext(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
AWSRegion: "eu-central-1",
|
||||||
|
AWSAccountID: "123456789012",
|
||||||
|
ContextTemplate: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1",
|
||||||
|
}
|
||||||
|
got := cfg.ResolveContext("beta", "internal")
|
||||||
|
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSecretName(t *testing.T) {
|
||||||
|
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
|
||||||
|
got := cfg.ResolveSecretName("example-ns", "beta")
|
||||||
|
want := "tf-example-ns-beta-secrets"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginCommand_DefaultsWhenUnset(t *testing.T) {
|
||||||
|
cfg := Config{}
|
||||||
|
if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand {
|
||||||
|
t.Fatalf("got %q, want default %q", got, DefaultAWSSSOLoginCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AWSSSOLoginCommand = "aws sso login --profile custom"
|
||||||
|
if got := cfg.LoginCommand(); got != "aws sso login --profile custom" {
|
||||||
|
t.Fatalf("expected custom login command to be used, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
// Package kctl contains the core, non-interactive logic of kctl-tui.
|
|
||||||
// Functions here are pure (no kubectl/tmux side effects) so they can be
|
|
||||||
// unit tested without a live cluster.
|
|
||||||
package kctl
|
|
||||||
|
|
||||||
// ContextPair groups a set of related kubectl contexts that should be
|
|
||||||
// switchable via TAB while keeping the same namespace (e.g. staging/prod).
|
|
||||||
type ContextPair struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Contexts []string `yaml:"contexts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindNextContext returns the next context in the same pair/group as
|
|
||||||
// current, cycling through the group. Returns "", false if current is not
|
|
||||||
// part of any configured pair.
|
|
||||||
func FindNextContext(current string, pairs []ContextPair) (string, bool) {
|
|
||||||
for _, pair := range pairs {
|
|
||||||
idx := indexOf(pair.Contexts, current)
|
|
||||||
if idx == -1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
next := pair.Contexts[(idx+1)%len(pair.Contexts)]
|
|
||||||
return next, true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexOf(items []string, target string) int {
|
|
||||||
for i, v := range items {
|
|
||||||
if v == target {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package kctl
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestFindNextContext_TwoWayToggle(t *testing.T) {
|
|
||||||
pairs := []ContextPair{
|
|
||||||
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
next, ok := FindNextContext("ctx-a", pairs)
|
|
||||||
if !ok || next != "ctx-b" {
|
|
||||||
t.Fatalf("expected ctx-b, got %q (ok=%v)", next, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
next, ok = FindNextContext("ctx-b", pairs)
|
|
||||||
if !ok || next != "ctx-a" {
|
|
||||||
t.Fatalf("expected ctx-a, got %q (ok=%v)", next, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindNextContext_Rotation(t *testing.T) {
|
|
||||||
pairs := []ContextPair{
|
|
||||||
{Name: "rotation", Contexts: []string{"a", "b", "c"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
next, ok := FindNextContext("c", pairs)
|
|
||||||
if !ok || next != "a" {
|
|
||||||
t.Fatalf("expected wraparound to a, got %q (ok=%v)", next, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindNextContext_NotConfigured(t *testing.T) {
|
|
||||||
pairs := []ContextPair{
|
|
||||||
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, ok := FindNextContext("unrelated-context", pairs)
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("expected ok=false for a context with no configured pair")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindNextContext_NoPairsConfigured(t *testing.T) {
|
|
||||||
_, ok := FindNextContext("ctx-a", nil)
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("expected ok=false when no pairs are configured")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kctl
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ResolveTemplate replaces "{key}" placeholders in template with the
|
||||||
|
// corresponding value from values. Placeholders with no matching key are
|
||||||
|
// left untouched, so a misconfigured template is visible (e.g. a literal
|
||||||
|
// "{typo}" in the result) instead of silently collapsing to an empty
|
||||||
|
// string.
|
||||||
|
func ResolveTemplate(template string, values map[string]string) string {
|
||||||
|
result := template
|
||||||
|
for k, v := range values {
|
||||||
|
result = strings.ReplaceAll(result, "{"+k+"}", v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kctl
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveTemplate_SinglePlaceholder(t *testing.T) {
|
||||||
|
got := ResolveTemplate("secret-{namespace}", map[string]string{"namespace": "example-ns"})
|
||||||
|
want := "secret-example-ns"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplate_MultiplePlaceholders(t *testing.T) {
|
||||||
|
template := "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
|
||||||
|
values := map[string]string{
|
||||||
|
"region": "eu-central-1",
|
||||||
|
"account_id": "123456789012",
|
||||||
|
"env": "beta",
|
||||||
|
"context": "internal",
|
||||||
|
}
|
||||||
|
got := ResolveTemplate(template, values)
|
||||||
|
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplate_UnknownPlaceholderLeftAsIs(t *testing.T) {
|
||||||
|
got := ResolveTemplate("secret-{unknown}", map[string]string{"namespace": "example-ns"})
|
||||||
|
want := "secret-{unknown}"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplate_EmptyTemplate(t *testing.T) {
|
||||||
|
got := ResolveTemplate("", map[string]string{"namespace": "example-ns"})
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("expected empty result, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
// All functions here have side effects (they run external processes) and
|
// All functions here have side effects (they run external processes) and
|
||||||
// are therefore not covered by unit tests; the pure logic they depend on
|
// are therefore not covered by unit tests; the pure logic they depend on
|
||||||
// lives in the kctl package instead.
|
// lives in the kctl package instead.
|
||||||
|
//
|
||||||
|
// Every kubectl-related function takes an explicit context argument
|
||||||
|
// (passed as --context) instead of relying on/mutating the globally
|
||||||
|
// active kubectl context. This lets the panel act on multiple resolved
|
||||||
|
// contexts (e.g. beta and prod) without switching global state back and
|
||||||
|
// forth.
|
||||||
package kubeexec
|
package kubeexec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,45 +27,12 @@ func runOutput(name string, args ...string) (string, error) {
|
|||||||
return strings.TrimSpace(string(out)), nil
|
return strings.TrimSpace(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContexts returns all configured kubectl context names.
|
// kubectlArgs prepends a --context flag when context is non-empty.
|
||||||
func GetContexts() ([]string, error) {
|
func kubectlArgs(context string, args ...string) []string {
|
||||||
out, err := runOutput("kubectl", "config", "get-contexts", "-o", "name")
|
if context == "" {
|
||||||
if err != nil {
|
return args
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if out == "" {
|
return append([]string{"--context", context}, args...)
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
return strings.Split(out, "\n"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentContext returns the currently active kubectl context, or an
|
|
||||||
// empty string if none is set.
|
|
||||||
func GetCurrentContext() string {
|
|
||||||
out, _ := runOutput("kubectl", "config", "current-context")
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentNamespace returns the namespace bound to the current context,
|
|
||||||
// defaulting to "default" if unset.
|
|
||||||
func GetCurrentNamespace() string {
|
|
||||||
out, _ := runOutput("kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}")
|
|
||||||
if out == "" {
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// UseContext switches the active kubectl context.
|
|
||||||
func UseContext(ctx string) error {
|
|
||||||
_, err := runOutput("kubectl", "config", "use-context", ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetNamespace binds a namespace to the current kubectl context.
|
|
||||||
func SetNamespace(ns string) error {
|
|
||||||
_, err := runOutput("kubectl", "config", "set-context", "--current", "--namespace="+ns)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type nsItem struct {
|
type nsItem struct {
|
||||||
@@ -74,9 +47,10 @@ type nsList struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetNamespacesWithLabels returns a map of namespace name -> labels for all
|
// GetNamespacesWithLabels returns a map of namespace name -> labels for all
|
||||||
// namespaces visible in the current context.
|
// namespaces visible in the given context.
|
||||||
func GetNamespacesWithLabels() (map[string]map[string]string, error) {
|
func GetNamespacesWithLabels(context string) (map[string]map[string]string, error) {
|
||||||
out, err := runOutput("kubectl", "get", "ns", "-o", "json")
|
args := kubectlArgs(context, "get", "ns", "-o", "json")
|
||||||
|
out, err := runOutput("kubectl", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -91,10 +65,11 @@ func GetNamespacesWithLabels() (map[string]map[string]string, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeployments lists deployment names in the given namespace.
|
// GetDeployments lists deployment names in the given context/namespace.
|
||||||
func GetDeployments(namespace string) ([]string, error) {
|
func GetDeployments(context, namespace string) ([]string, error) {
|
||||||
out, err := runOutput("kubectl", "-n", namespace, "get", "deploy",
|
args := kubectlArgs(context, "-n", namespace, "get", "deploy",
|
||||||
"-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`)
|
"-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`)
|
||||||
|
out, err := runOutput("kubectl", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -105,26 +80,22 @@ func GetDeployments(namespace string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RolloutRestart triggers a rolling restart of a deployment.
|
// RolloutRestart triggers a rolling restart of a deployment.
|
||||||
func RolloutRestart(namespace, deployment string) (string, error) {
|
func RolloutRestart(context, namespace, deployment string) (string, error) {
|
||||||
return runOutput("kubectl", "-n", namespace, "rollout", "restart", "deploy/"+deployment)
|
args := kubectlArgs(context, "-n", namespace, "rollout", "restart", "deploy/"+deployment)
|
||||||
|
return runOutput("kubectl", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RolloutStatus waits for and returns the rollout status of a deployment.
|
// RolloutStatus waits for and returns the rollout status of a deployment.
|
||||||
func RolloutStatus(namespace, deployment string) (string, error) {
|
func RolloutStatus(context, namespace, deployment string) (string, error) {
|
||||||
return runOutput("kubectl", "-n", namespace, "rollout", "status", "deploy/"+deployment)
|
args := kubectlArgs(context, "-n", namespace, "rollout", "status", "deploy/"+deployment)
|
||||||
}
|
return runOutput("kubectl", args...)
|
||||||
|
|
||||||
// GetSecretValueBase64 returns the raw (still base64-encoded) value of a
|
|
||||||
// single field in a Kubernetes secret.
|
|
||||||
func GetSecretValueBase64(namespace, secretName, field string) (string, error) {
|
|
||||||
path := fmt.Sprintf("jsonpath={.data.%s}", field)
|
|
||||||
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretAllFields returns all fields of a Kubernetes secret, already
|
// GetSecretAllFields returns all fields of a Kubernetes secret, already
|
||||||
// base64-decoded into plain values.
|
// base64-decoded into plain values.
|
||||||
func GetSecretAllFields(namespace, secretName string) (map[string]string, error) {
|
func GetSecretAllFields(context, namespace, secretName string) (map[string]string, error) {
|
||||||
out, err := runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", "json")
|
args := kubectlArgs(context, "-n", namespace, "get", "secret", secretName, "-o", "json")
|
||||||
|
out, err := runOutput("kubectl", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -156,31 +127,38 @@ func DecodeBase64(value string) (string, error) {
|
|||||||
|
|
||||||
// AnnotateForceSync sets the force-sync annotation on an ExternalSecret
|
// AnnotateForceSync sets the force-sync annotation on an ExternalSecret
|
||||||
// object to trigger an immediate re-sync from the upstream secret store.
|
// object to trigger an immediate re-sync from the upstream secret store.
|
||||||
func AnnotateForceSync(namespace, externalSecretName string, unixTimestamp int64) (string, error) {
|
func AnnotateForceSync(context, namespace, externalSecretName string, unixTimestamp int64) (string, error) {
|
||||||
annotation := fmt.Sprintf("force-sync=%d", unixTimestamp)
|
annotation := fmt.Sprintf("force-sync=%d", unixTimestamp)
|
||||||
return runOutput("kubectl", "-n", namespace, "annotate", "externalsecret",
|
args := kubectlArgs(context, "-n", namespace, "annotate", "externalsecret",
|
||||||
externalSecretName, annotation, "--overwrite")
|
externalSecretName, annotation, "--overwrite")
|
||||||
}
|
return runOutput("kubectl", args...)
|
||||||
|
|
||||||
// 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. The secret ID is computed from config templates
|
||||||
|
// (see internal/config), not looked up interactively.
|
||||||
func GetAWSSecretString(secretID, region string) (string, error) {
|
func GetAWSSecretString(secretID, region string) (string, error) {
|
||||||
return runOutput("aws", "secretsmanager", "get-secret-value",
|
return runOutput("aws", "secretsmanager", "get-secret-value",
|
||||||
"--secret-id", secretID, "--region", region,
|
"--secret-id", secretID, "--region", region,
|
||||||
"--query", "SecretString", "--output", "text")
|
"--query", "SecretString", "--output", "text")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckAWSAuth performs a cheap, fast call to verify the current AWS
|
||||||
|
// credentials/SSO session are valid. Returns nil if authenticated, or the
|
||||||
|
// underlying error (e.g. an expired SSO session) otherwise.
|
||||||
|
func CheckAWSAuth() error {
|
||||||
|
_, err := runOutput("aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAWSLogin returns an *exec.Cmd for the given login command (e.g.
|
||||||
|
// "aws sso login"), split on whitespace. The caller is responsible for
|
||||||
|
// running it interactively (e.g. via tea.ExecProcess) since SSO login
|
||||||
|
// typically requires opening a browser and confirming a device code.
|
||||||
|
func RunAWSLogin(loginCommand string) *exec.Cmd {
|
||||||
|
parts := strings.Fields(loginCommand)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
parts = []string{"aws", "sso", "login"}
|
||||||
|
}
|
||||||
|
return exec.Command(parts[0], parts[1:]...)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user