mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-17 20:10:24 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d05d35bdc1 | ||
|
|
f8679adf95 | ||
|
|
73cc510347 | ||
|
|
d17eb6971c | ||
|
|
fb4c1be12b | ||
|
|
9e3fefb71a | ||
|
|
2d382441a1 | ||
|
|
ad7d8534ab | ||
|
|
a0b937e882 | ||
|
|
d32ccd5952 | ||
|
|
3729a0301d | ||
|
|
a1760b7b01 | ||
|
|
bd23403e6c | ||
|
|
6a0405640d | ||
|
|
c6880e92ce | ||
|
|
209e93646c | ||
|
|
720064454b | ||
|
|
205f8906df |
@@ -119,7 +119,8 @@ 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 setup:
|
and adjust it to your own setup — or use `kctl-tui config edit` to open the
|
||||||
|
file directly in your editor (creates the file and directory if needed):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
contexts:
|
contexts:
|
||||||
@@ -136,6 +137,7 @@ 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"
|
k8s_secret_name_template: "{namespace}-common-secrets"
|
||||||
|
external_secret_name_template: "{namespace}"
|
||||||
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"
|
||||||
@@ -159,6 +161,12 @@ aws_sso_login_command: "aws sso login"
|
|||||||
chosen namespace. Kept separate from `secret_name_template` because the
|
chosen namespace. Kept separate from `secret_name_template` because the
|
||||||
two sides commonly follow different naming conventions. Placeholders:
|
two sides commonly follow different naming conventions. Placeholders:
|
||||||
`{namespace}`.
|
`{namespace}`.
|
||||||
|
- 🎯 `external_secret_name_template`: builds the ExternalSecret CRD object
|
||||||
|
name to annotate when a force-sync is requested. This is often different
|
||||||
|
from the Kubernetes secret name because the ExternalSecret CRD and the
|
||||||
|
resulting Secret are separate objects (e.g. ExternalSecret `"job-apply"`
|
||||||
|
produces Secret `"job-apply-common-secrets"`). Falls back to
|
||||||
|
`k8s_secret_name_template` if omitted. 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-`,
|
||||||
@@ -205,6 +213,7 @@ kctl-tui --version # print version
|
|||||||
kctl-tui --verbose # enable debug logging to stderr
|
kctl-tui --verbose # enable debug logging to stderr
|
||||||
kctl-tui update # update to the latest release
|
kctl-tui update # update to the latest release
|
||||||
kctl-tui doctor # check if all tools, config and connections are OK
|
kctl-tui doctor # check if all tools, config and connections are OK
|
||||||
|
kctl-tui config edit # open ~/.kctl-tui/config.yaml in your editor
|
||||||
kctl-tui config check # validate ~/.kctl-tui/config.yaml
|
kctl-tui config check # validate ~/.kctl-tui/config.yaml
|
||||||
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
|
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
|
||||||
```
|
```
|
||||||
|
|||||||
+68
-19
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -47,7 +48,7 @@ type fullModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newFullModel() *fullModel {
|
func newFullModel() *fullModel {
|
||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, newCompactDelegate(), 0, 0)
|
||||||
l.Title = "kctl-tui"
|
l.Title = "kctl-tui"
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(false)
|
||||||
@@ -197,7 +198,11 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case screenNamespace:
|
case screenNamespace:
|
||||||
m.selectedNamespace = item.value
|
m.selectedNamespace = item.value
|
||||||
if err := kubeexec.CheckTool("tmux"); err != nil {
|
tool := "tmux"
|
||||||
|
if runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt" {
|
||||||
|
tool = "wt"
|
||||||
|
}
|
||||||
|
if err := kubeexec.CheckTool(tool); err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -210,23 +215,21 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
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(m.bootstrapContext())
|
merged := map[string]map[string]string{}
|
||||||
if err != nil {
|
for _, env := range m.cfg.Envs {
|
||||||
return errMsg{err}
|
ctx := m.cfg.ResolveContext(env, m.selectedContext)
|
||||||
|
namespaces, err := kubeexec.GetNamespacesWithLabels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for ns, labels := range namespaces {
|
||||||
|
if _, exists := merged[ns]; !exists {
|
||||||
|
merged[ns] = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return *toTeamsLoadedMsg(namespaces, m.cfg.TeamLabelKey)
|
return *toTeamsLoadedMsg(merged, m.cfg.TeamLabelKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadTeamsFor(namespaces map[string]map[string]string) tea.Cmd {
|
func (m *fullModel) loadTeamsFor(namespaces map[string]map[string]string) tea.Cmd {
|
||||||
@@ -264,12 +267,21 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTmuxSession builds the 3-pane tmux command: the control pane runs
|
// startTmuxSession builds the 3-pane session: the control pane runs
|
||||||
// this binary in "panel" mode (letting the user pick an env and an
|
// this binary in "panel" mode (letting the user pick an env and an
|
||||||
// action), and the two status panes run k9s against the first two
|
// action), and the two status panes run k9s against the first two
|
||||||
// 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. On Windows with multiplexer: "wt", this uses
|
||||||
|
// Windows Terminal's native split-pane instead of tmux/psmux.
|
||||||
func (m *fullModel) startTmuxSession() tea.Cmd {
|
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||||
|
if runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt" {
|
||||||
|
return m.startWtSession()
|
||||||
|
}
|
||||||
|
return m.startTmuxSessionTmux()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTmuxSessionTmux creates the session using tmux/psmux.
|
||||||
|
func (m *fullModel) startTmuxSessionTmux() tea.Cmd {
|
||||||
selfPath, err := os.Executable()
|
selfPath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
selfPath = "kctl-tui" // fallback to PATH lookup
|
selfPath = "kctl-tui" // fallback to PATH lookup
|
||||||
@@ -321,6 +333,43 @@ func (m *fullModel) startTmuxSession() tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startWtSession creates the session using Windows Terminal's native
|
||||||
|
// split-pane feature. This avoids the psmux focus-freeze issue on Windows.
|
||||||
|
// Note: In Windows Terminal, -H (horizontal) stacks panes top/bottom,
|
||||||
|
// while -V (vertical) places them side by side — opposite of tmux.
|
||||||
|
// The -s flag controls the split ratio: first split gives k9sA 75%
|
||||||
|
// (panel keeps 25%), second split divides k9sA equally (37.5% each).
|
||||||
|
func (m *fullModel) startWtSession() tea.Cmd {
|
||||||
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
selfPath = "kctl-tui"
|
||||||
|
}
|
||||||
|
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
|
||||||
|
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
||||||
|
|
||||||
|
envA := m.cfg.Envs[0]
|
||||||
|
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
|
||||||
|
k9sCmdA := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxA, m.selectedNamespace)
|
||||||
|
|
||||||
|
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
|
||||||
|
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
|
||||||
|
|
||||||
|
wtCmd := fmt.Sprintf("wt -M new-tab %s ; split-pane -H -s 0.75 %s", panelCmd, 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 --namespace %s --command pods", ctxB, m.selectedNamespace)
|
||||||
|
kubeexec.VerboseLog("[debug] k9sCmdB=%s\n", k9sCmdB)
|
||||||
|
wtCmd += fmt.Sprintf(" ; split-pane -H -s 0.5 %s", k9sCmdB)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := exec.Command("cmd", "/c", wtCmd)
|
||||||
|
_ = c.Start()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fullModel) View() string {
|
func (m *fullModel) View() string {
|
||||||
view := m.list.View()
|
view := m.list.View()
|
||||||
if m.statusMessage != "" {
|
if m.statusMessage != "" {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/list"
|
||||||
|
|
||||||
// simpleItem is a minimal implementation of list.Item used for all
|
// simpleItem is a minimal implementation of list.Item used for all
|
||||||
// selection screens (contexts, teams, namespaces, menu actions).
|
// selection screens (contexts, teams, namespaces, menu actions).
|
||||||
type simpleItem struct {
|
type simpleItem struct {
|
||||||
@@ -13,3 +15,13 @@ type simpleItem struct {
|
|||||||
func (i simpleItem) Title() string { return i.label }
|
func (i simpleItem) Title() string { return i.label }
|
||||||
func (i simpleItem) Description() string { return "" }
|
func (i simpleItem) Description() string { return "" }
|
||||||
func (i simpleItem) FilterValue() string { return i.label }
|
func (i simpleItem) FilterValue() string { return i.label }
|
||||||
|
|
||||||
|
// newCompactDelegate returns a list delegate that renders each item as a
|
||||||
|
// single line with no extra spacing, maximizing the number of visible
|
||||||
|
// entries in the terminal.
|
||||||
|
func newCompactDelegate() list.DefaultDelegate {
|
||||||
|
d := list.NewDefaultDelegate()
|
||||||
|
d.ShowDescription = false
|
||||||
|
d.SetSpacing(0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|||||||
+54
-3
@@ -6,6 +6,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
@@ -91,7 +94,12 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "WARNING: failed to load config: %v\n", cfgErr)
|
fmt.Fprintf(os.Stderr, "WARNING: failed to load config: %v\n", cfgErr)
|
||||||
}
|
}
|
||||||
if cfgErr == nil && cfg.IsAutoUpdateCheckEnabled() {
|
if cfgErr == nil && cfg.IsAutoUpdateCheckEnabled() {
|
||||||
if checkForUpdateInteractive(verbose) {
|
updated, err := checkForUpdateInteractive(verbose)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +121,7 @@ Usage:
|
|||||||
kctl-tui [flags] start the TUI (full navigation mode)
|
kctl-tui [flags] start the TUI (full navigation mode)
|
||||||
kctl-tui doctor check tools, config and connections
|
kctl-tui doctor check tools, config and connections
|
||||||
kctl-tui update update to the latest release
|
kctl-tui update update to the latest release
|
||||||
|
kctl-tui config edit open ~/.kctl-tui/config.yaml in editor
|
||||||
kctl-tui config check validate ~/.kctl-tui/config.yaml
|
kctl-tui config check validate ~/.kctl-tui/config.yaml
|
||||||
kctl-tui panel [options] control pane (called internally by tmux)
|
kctl-tui panel [options] control pane (called internally by tmux)
|
||||||
|
|
||||||
@@ -126,14 +135,56 @@ Examples:
|
|||||||
kctl-tui doctor # verify everything is installed
|
kctl-tui doctor # verify everything is installed
|
||||||
kctl-tui update # update to the latest version
|
kctl-tui update # update to the latest version
|
||||||
kctl-tui --verbose 2>debug.log # log commands to a file
|
kctl-tui --verbose 2>debug.log # log commands to a file
|
||||||
|
kctl-tui config edit # open config in editor
|
||||||
kctl-tui config check # validate config
|
kctl-tui config check # validate config
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runConfig(args []string) error {
|
func runConfig(args []string) error {
|
||||||
if len(args) == 0 || args[0] != "check" {
|
if len(args) > 0 && args[0] == "check" {
|
||||||
return fmt.Errorf("usage: kctl-tui config check")
|
return runConfigCheck()
|
||||||
}
|
}
|
||||||
|
if len(args) == 0 || args[0] == "edit" {
|
||||||
|
return runConfigEdit()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("usage: kctl-tui config [check|edit]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigEdit() error {
|
||||||
|
cfgPath, err := config.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine config path: %w", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(cfgPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("cannot create config directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("# kctl-tui configuration\n# See https://github.com/skoelle/kctl-tui for examples.\n"), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("cannot create config file %s: %w", cfgPath, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created new config file: %s\n", cfgPath)
|
||||||
|
}
|
||||||
|
editor := os.Getenv("VISUAL")
|
||||||
|
if editor == "" {
|
||||||
|
editor = os.Getenv("EDITOR")
|
||||||
|
}
|
||||||
|
if editor == "" {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
editor = "notepad"
|
||||||
|
default:
|
||||||
|
editor = "vim"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd := exec.Command(editor, cfgPath)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigCheck() error {
|
||||||
cfgPath, err := config.DefaultPath()
|
cfgPath, err := config.DefaultPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot determine config path: %w", err)
|
return fmt.Errorf("cannot determine config path: %w", err)
|
||||||
|
|||||||
+24
-6
@@ -8,6 +8,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -49,8 +50,9 @@ type panelModel struct {
|
|||||||
|
|
||||||
deploymentName string
|
deploymentName string
|
||||||
|
|
||||||
awsSecretName string // resolved via secret_name_template (namespace + env)
|
awsSecretName string // resolved via secret_name_template (namespace + env)
|
||||||
k8sSecretName string // resolved via k8s_secret_name_template (namespace only)
|
k8sSecretName string // resolved via k8s_secret_name_template (namespace only)
|
||||||
|
externalSecretName string // resolved via external_secret_name_template (namespace only)
|
||||||
awsValues map[string]string
|
awsValues map[string]string
|
||||||
k8sValues map[string]string
|
k8sValues map[string]string
|
||||||
diffEntries []kctl.SecretDiffEntry
|
diffEntries []kctl.SecretDiffEntry
|
||||||
@@ -60,6 +62,11 @@ type panelModel struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWtMode returns true when running on Windows with multiplexer: "wt".
|
||||||
|
func (m *panelModel) isWtMode() bool {
|
||||||
|
return runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt"
|
||||||
|
}
|
||||||
|
|
||||||
func runPanel(args []string) error {
|
func runPanel(args []string) error {
|
||||||
fs := flag.NewFlagSet("panel", flag.ContinueOnError)
|
fs := flag.NewFlagSet("panel", flag.ContinueOnError)
|
||||||
context := fs.String("context", "", "context (e.g. internal/external)")
|
context := fs.String("context", "", "context (e.g. internal/external)")
|
||||||
@@ -82,11 +89,15 @@ func newPanelModel(context, ns, team string) *panelModel {
|
|||||||
cfgPath, _ := config.DefaultPath()
|
cfgPath, _ := config.DefaultPath()
|
||||||
cfg, loadErr := config.Load(cfgPath)
|
cfg, loadErr := config.Load(cfgPath)
|
||||||
|
|
||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, newCompactDelegate(), 0, 0)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(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 m.isWtMode() {
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
}
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
m.err = fmt.Errorf("config load failed: %w", loadErr)
|
m.err = fmt.Errorf("config load failed: %w", loadErr)
|
||||||
m.step = stepError
|
m.step = stepError
|
||||||
@@ -98,7 +109,9 @@ func newPanelModel(context, ns, team string) *panelModel {
|
|||||||
|
|
||||||
func (m *panelModel) showEnvMenu() {
|
func (m *panelModel) showEnvMenu() {
|
||||||
items := make([]list.Item, 0, len(m.cfg.Envs)+1)
|
items := make([]list.Item, 0, len(m.cfg.Envs)+1)
|
||||||
items = append(items, simpleItem{label: "Quit (closes this tmux session)", value: "quit"})
|
if !m.isWtMode() {
|
||||||
|
items = append(items, simpleItem{label: "Quit (closes this tmux session)", value: "quit"})
|
||||||
|
}
|
||||||
for _, env := range m.cfg.Envs {
|
for _, env := range m.cfg.Envs {
|
||||||
items = append(items, simpleItem{label: env, value: env})
|
items = append(items, simpleItem{label: env, value: env})
|
||||||
}
|
}
|
||||||
@@ -178,10 +191,14 @@ func (m *panelModel) usesTextInput() bool {
|
|||||||
// handleEsc navigates one level up: action menu -> env menu, most
|
// handleEsc navigates one level up: action menu -> env menu, most
|
||||||
// sub-steps -> action menu. From the top-level env menu it closes the
|
// sub-steps -> action menu. From the top-level env menu it closes the
|
||||||
// whole tmux session (all panes, including the two k9s status panes)
|
// whole tmux session (all panes, including the two k9s status panes)
|
||||||
// before quitting this program, per SPEC.md 3.6.
|
// before quitting this program, per SPEC.md 3.6. In wt mode, Esc on
|
||||||
|
// the env menu does nothing — the user closes the wt window manually.
|
||||||
func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
|
func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
|
||||||
switch m.step {
|
switch m.step {
|
||||||
case stepEnvMenu:
|
case stepEnvMenu:
|
||||||
|
if m.isWtMode() {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
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:
|
case stepActionMenu:
|
||||||
@@ -316,6 +333,7 @@ func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
|
|||||||
func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
|
func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
|
||||||
m.awsSecretName = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
|
m.awsSecretName = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
|
||||||
m.k8sSecretName = m.cfg.ResolveK8sSecretName(m.ns)
|
m.k8sSecretName = m.cfg.ResolveK8sSecretName(m.ns)
|
||||||
|
m.externalSecretName = m.cfg.ResolveExternalSecretName(m.ns)
|
||||||
|
|
||||||
raw, err := kubeexec.GetAWSSecretString(m.awsSecretName, m.cfg.AWSRegion)
|
raw, err := kubeexec.GetAWSSecretString(m.awsSecretName, m.cfg.AWSRegion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -462,7 +480,7 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.step = stepExternalSecretName
|
m.step = stepExternalSecretName
|
||||||
m.input.SetValue(m.k8sSecretName)
|
m.input.SetValue(m.externalSecretName)
|
||||||
m.input.Placeholder = "ExternalSecret object name"
|
m.input.Placeholder = "ExternalSecret object name"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -83,14 +83,15 @@ func runUpdate(verbose bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkForUpdateInteractive checks for a new version and prompts the user to update.
|
// checkForUpdateInteractive checks for a new version and prompts the user to update.
|
||||||
// Returns true if an update was applied.
|
// Returns (true, nil) if an update was applied successfully, (false, nil) if no
|
||||||
func checkForUpdateInteractive(verbose bool) bool {
|
// update was needed or the user declined, and (false, err) if the update failed.
|
||||||
|
func checkForUpdateInteractive(verbose bool) (bool, error) {
|
||||||
if version == "dev" {
|
if version == "dev" {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
updater, err := initUpdater(verbose)
|
updater, err := initUpdater(verbose)
|
||||||
@@ -98,7 +99,7 @@ func checkForUpdateInteractive(verbose bool) bool {
|
|||||||
if verbose {
|
if verbose {
|
||||||
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
|
||||||
}
|
}
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
|
||||||
@@ -110,10 +111,10 @@ func checkForUpdateInteractive(verbose bool) bool {
|
|||||||
if verbose {
|
if verbose {
|
||||||
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
|
||||||
}
|
}
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
current, _ := semver.NewVersion(version)
|
current, _ := semver.NewVersion(version)
|
||||||
@@ -121,7 +122,7 @@ func checkForUpdateInteractive(verbose bool) bool {
|
|||||||
newVer, _ := semver.NewVersion(newVersion)
|
newVer, _ := semver.NewVersion(newVersion)
|
||||||
|
|
||||||
if current != nil && !current.LessThan(newVer) {
|
if current != nil && !current.LessThan(newVer) {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("New version %s available (current: %s). Update now? [y/N] ", newVersion, version)
|
fmt.Printf("New version %s available (current: %s). Update now? [y/N] ", newVersion, version)
|
||||||
@@ -131,17 +132,16 @@ func checkForUpdateInteractive(verbose bool) bool {
|
|||||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||||
|
|
||||||
if answer != "y" && answer != "yes" {
|
if answer != "y" && answer != "yes" {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Updating...")
|
fmt.Println("Updating...")
|
||||||
if err := updater.UpdateTo(ctx, rel, ""); err != nil {
|
if err := updater.UpdateTo(ctx, rel, ""); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Update failed: %v\n", err)
|
return false, fmt.Errorf("update failed: %w", err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Updated to %s. Please restart kctl-tui.\n", newVersion)
|
fmt.Printf("Updated to %s. Please restart kctl-tui.\n", newVersion)
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type verboseLogger struct{}
|
type verboseLogger struct{}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ secret_name_template: "tf-{namespace}-{env}-secrets"
|
|||||||
# Available placeholders: {namespace}.
|
# Available placeholders: {namespace}.
|
||||||
k8s_secret_name_template: "{namespace}-common-secrets"
|
k8s_secret_name_template: "{namespace}-common-secrets"
|
||||||
|
|
||||||
|
# Builds the ExternalSecret CRD object name to annotate when a force-sync
|
||||||
|
# is requested. This is often different from the Kubernetes secret name
|
||||||
|
# because the ExternalSecret CRD and the resulting Secret are separate
|
||||||
|
# objects (e.g. ExternalSecret "job-apply" produces Secret
|
||||||
|
# "job-apply-common-secrets"). Falls back to k8s_secret_name_template
|
||||||
|
# if omitted. Available placeholders: {namespace}.
|
||||||
|
external_secret_name_template: "{namespace}"
|
||||||
|
|
||||||
# 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
|
||||||
@@ -64,3 +72,8 @@ aws_sso_login_command: "aws sso login"
|
|||||||
# Check for updates on startup and prompt to update if a newer version is
|
# Check for updates on startup and prompt to update if a newer version is
|
||||||
# available. Set to false to disable. Defaults to true if omitted.
|
# available. Set to false to disable. Defaults to true if omitted.
|
||||||
# auto_update_check: true
|
# auto_update_check: true
|
||||||
|
|
||||||
|
# Terminal multiplexer backend. "tmux" (default) uses tmux/psmux.
|
||||||
|
# "wt" uses Windows Terminal's native split-pane — avoids the psmux
|
||||||
|
# focus-freeze issue on Windows. Only effective on Windows.
|
||||||
|
# multiplexer: "tmux"
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ type Config struct {
|
|||||||
// naming conventions.
|
// naming conventions.
|
||||||
K8sSecretNameTemplate string `yaml:"k8s_secret_name_template"`
|
K8sSecretNameTemplate string `yaml:"k8s_secret_name_template"`
|
||||||
|
|
||||||
|
// ExternalSecretNameTemplate builds the ExternalSecret CRD object
|
||||||
|
// name that should be annotated when a force-sync is requested. This
|
||||||
|
// is often different from the Kubernetes secret name because the
|
||||||
|
// ExternalSecret CRD and the resulting Secret are separate objects.
|
||||||
|
// Falls back to K8sSecretNameTemplate if empty.
|
||||||
|
ExternalSecretNameTemplate string `yaml:"external_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".
|
||||||
@@ -66,6 +73,11 @@ type Config struct {
|
|||||||
// AutoUpdateCheck controls whether kctl-tui checks for updates on
|
// AutoUpdateCheck controls whether kctl-tui checks for updates on
|
||||||
// startup. Defaults to true when omitted.
|
// startup. Defaults to true when omitted.
|
||||||
AutoUpdateCheck *bool `yaml:"auto_update_check"`
|
AutoUpdateCheck *bool `yaml:"auto_update_check"`
|
||||||
|
|
||||||
|
// Multiplexer selects the terminal multiplexer for the session.
|
||||||
|
// "tmux" (default) uses tmux/psmux. "wt" uses Windows Terminal's
|
||||||
|
// native split-pane (only effective on Windows).
|
||||||
|
Multiplexer string `yaml:"multiplexer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAutoUpdateCheckEnabled returns true unless the user has explicitly set
|
// IsAutoUpdateCheckEnabled returns true unless the user has explicitly set
|
||||||
@@ -77,6 +89,15 @@ func (c Config) IsAutoUpdateCheckEnabled() bool {
|
|||||||
return *c.AutoUpdateCheck
|
return *c.AutoUpdateCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MultiplexerBackend returns the configured multiplexer backend,
|
||||||
|
// falling back to "tmux" if not set.
|
||||||
|
func (c Config) MultiplexerBackend() string {
|
||||||
|
if c.Multiplexer == "" {
|
||||||
|
return "tmux"
|
||||||
|
}
|
||||||
|
return c.Multiplexer
|
||||||
|
}
|
||||||
|
|
||||||
// LoginCommand returns the configured AWS SSO login command, falling back
|
// LoginCommand returns the configured AWS SSO login command, falling back
|
||||||
// to DefaultAWSSSOLoginCommand if none is set.
|
// to DefaultAWSSSOLoginCommand if none is set.
|
||||||
func (c Config) LoginCommand() string {
|
func (c Config) LoginCommand() string {
|
||||||
@@ -134,6 +155,23 @@ func (c Config) ResolveK8sSecretName(namespace string) string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveExternalSecretName builds the ExternalSecret CRD object name for
|
||||||
|
// a given namespace using ExternalSecretNameTemplate. Falls back to
|
||||||
|
// K8sSecretNameTemplate (or SecretNameTemplate if that is also empty) so
|
||||||
|
// that existing configs keep working without changes.
|
||||||
|
func (c Config) ResolveExternalSecretName(namespace string) string {
|
||||||
|
template := c.ExternalSecretNameTemplate
|
||||||
|
if template == "" {
|
||||||
|
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()
|
||||||
|
|||||||
@@ -112,6 +112,33 @@ func TestResolveK8sSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveExternalSecretName_ExplicitTemplate(t *testing.T) {
|
||||||
|
cfg := Config{ExternalSecretNameTemplate: "{namespace}"}
|
||||||
|
got := cfg.ResolveExternalSecretName("job-apply")
|
||||||
|
want := "job-apply"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveExternalSecretName_FallsBackToK8sSecretNameTemplate(t *testing.T) {
|
||||||
|
cfg := Config{K8sSecretNameTemplate: "{namespace}-common-secrets"}
|
||||||
|
got := cfg.ResolveExternalSecretName("job-apply")
|
||||||
|
want := "job-apply-common-secrets"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveExternalSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
|
||||||
|
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
|
||||||
|
got := cfg.ResolveExternalSecretName("job-apply")
|
||||||
|
want := "tf-job-apply-{env}-secrets" // {env} intentionally left unresolved
|
||||||
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user