Compare commits

..
3 Commits
4 changed files with 142 additions and 30 deletions
+97 -28
View File
@@ -13,6 +13,7 @@ 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"
) )
@@ -24,6 +25,7 @@ const (
stepMenu panelStep = iota stepMenu panelStep = iota
stepRedeployList stepRedeployList
stepRedeployConfirm stepRedeployConfirm
stepAWSAuthPrompt
stepSecretRegion stepSecretRegion
stepSecretList stepSecretList
stepK8sSecretName stepK8sSecretName
@@ -31,10 +33,12 @@ const (
stepForceSyncConfirm stepForceSyncConfirm
stepExternalSecretName stepExternalSecretName
stepDone stepDone
stepError
) )
type panelModel struct { type panelModel struct {
ctx, ns, team string ctx, ns, team string
cfg config.Config
step panelStep step panelStep
list list.Model list list.Model
@@ -74,7 +78,10 @@ func newPanelModel(ctx, ns, team string) *panelModel {
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)
return &panelModel{ctx: ctx, ns: ns, team: team, cfg: cfg, step: stepMenu, list: l, input: ti}
} }
func menuItems() []list.Item { func menuItems() []list.Item {
@@ -87,12 +94,17 @@ func menuItems() []list.Item {
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":
@@ -138,6 +150,8 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
return m.fromRedeployList() return m.fromRedeployList()
case stepRedeployConfirm: case stepRedeployConfirm:
return m.fromRedeployConfirm() return m.fromRedeployConfirm()
case stepAWSAuthPrompt:
return m.fromAWSAuthPrompt()
case stepSecretRegion: case stepSecretRegion:
m.awsRegion = m.input.Value() m.awsRegion = m.input.Value()
return m.fetchSecretList() return m.fetchSecretList()
@@ -150,7 +164,7 @@ 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.resetToMenu()
return m, nil return m, nil
} }
@@ -165,6 +179,15 @@ func (m *panelModel) resetToMenu() {
m.err = nil m.err = nil
} }
// showError switches to a dedicated error screen so failures from
// kubectl/aws calls stay visible until the user explicitly acknowledges
// them with Enter, instead of being silently discarded.
func (m *panelModel) showError(err error) (tea.Model, tea.Cmd) {
m.err = err
m.step = stepError
return m, nil
}
func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) { func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
@@ -174,8 +197,7 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
case "redeploy": case "redeploy":
deployments, err := kubeexec.GetDeployments(m.ns) deployments, err := kubeexec.GetDeployments(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 {
@@ -185,15 +207,63 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
m.list.Title = "Select deployment to restart (esc = back)" m.list.Title = "Select deployment to restart (esc = back)"
m.step = stepRedeployList m.step = stepRedeployList
case "secrets": case "secrets":
m.step = stepSecretRegion return m.checkAWSAuthAndProceed()
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
case "quit": case "quit":
return m.handleEsc() return m.handleEsc()
} }
return m, nil return m, nil
} }
// checkAWSAuthAndProceed verifies the current AWS credentials/SSO session
// before entering the secrets workflow. If the check fails (e.g. an
// expired SSO session), it offers to run the configured login command
// interactively instead of letting the user hit a confusing failure
// several steps later.
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
if err := kubeexec.CheckAWSAuth(); err != nil {
m.err = err
m.list.SetItems([]list.Item{
simpleItem{label: "Run AWS login now (" + m.cfg.LoginCommand() + ")", value: "login"},
simpleItem{label: "Cancel", value: "cancel"},
})
m.list.Title = "AWS session invalid or expired"
m.step = stepAWSAuthPrompt
return m, nil
}
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
return m, nil
}
func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "login" {
m.resetToMenu()
return m, nil
}
cmd := kubeexec.RunAWSLogin(m.cfg.LoginCommand())
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
return awsLoginDoneMsg{err: err}
})
}
// afterAWSLogin re-checks AWS auth once the interactive login command has
// finished (successfully or not) and either proceeds into the secrets
// workflow or shows the remaining error.
func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
if execErr != nil {
return m.showError(fmt.Errorf("login command failed to run: %w", execErr))
}
if err := kubeexec.CheckAWSAuth(); err != nil {
return m.showError(fmt.Errorf("still not authenticated with AWS after running '%s': %w", m.cfg.LoginCommand(), err))
}
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
m.input.Placeholder = "AWS region"
return m, nil
}
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) { func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
@@ -218,9 +288,12 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
deployment := m.k8sSecretName // set in fromRedeployList deployment := m.k8sSecretName // set in fromRedeployList
_, err := kubeexec.RolloutRestart(m.ns, deployment) _, err := kubeexec.RolloutRestart(m.ns, deployment)
if err != nil { if err != nil {
m.err = err return m.showError(err)
}
status, err := kubeexec.RolloutStatus(m.ns, deployment)
if err != nil {
return m.showError(err)
} }
status, _ := kubeexec.RolloutStatus(m.ns, deployment)
m.message = "Rollout status: " + status m.message = "Rollout status: " + status
m.step = stepDone m.step = stepDone
return m, nil return m, nil
@@ -231,14 +304,10 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
func (m *panelModel) fetchSecretList() (tea.Model, tea.Cmd) { func (m *panelModel) fetchSecretList() (tea.Model, tea.Cmd) {
names, err := kubeexec.ListAWSSecrets(m.awsRegion) names, err := kubeexec.ListAWSSecrets(m.awsRegion)
if err != nil { if err != nil {
m.err = err return m.showError(err)
m.resetToMenu()
return m, nil
} }
if len(names) == 0 { if len(names) == 0 {
m.err = fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion) return m.showError(fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion))
m.resetToMenu()
return m, nil
} }
items := make([]list.Item, 0, len(names)) items := make([]list.Item, 0, len(names))
for _, n := range names { for _, n := range names {
@@ -259,9 +328,7 @@ func (m *panelModel) fromSecretList() (tea.Model, tea.Cmd) {
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion) raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion)
if err != nil { if err != nil {
m.err = err return m.showError(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 {
@@ -286,9 +353,7 @@ func (m *panelModel) fromSecretList() (tea.Model, tea.Cmd) {
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) { func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
k8sValues, err := kubeexec.GetSecretAllFields(m.ns, m.k8sSecretName) k8sValues, err := kubeexec.GetSecretAllFields(m.ns, m.k8sSecretName)
if err != nil { if err != nil {
m.err = err return m.showError(err)
m.resetToMenu()
return m, nil
} }
m.k8sValues = k8sValues m.k8sValues = k8sValues
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues) m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
@@ -348,7 +413,7 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
ts := time.Now().Unix() ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts) _, err := kubeexec.AnnotateForceSync(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
@@ -358,19 +423,23 @@ 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 stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList:
v := m.list.View() return m.list.View()
case stepAWSAuthPrompt:
errText := ""
if m.err != nil { if m.err != nil {
v += "\nerror: " + m.err.Error() errText = m.err.Error() + "\n\n"
} }
return v 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 menu, esc to close session)"
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 menu, esc to close session)"
default: default:
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)", return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)",
m.stepPrompt(), m.input.View()) m.stepPrompt(), m.input.View())
+7
View File
@@ -17,3 +17,10 @@ context_pairs:
# team-selection screen. Adjust this to whatever label your organization # team-selection screen. Adjust this to whatever label your organization
# actually uses (can contain a domain prefix, e.g. "example.org/team"). # 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"
+18 -2
View File
@@ -11,10 +11,26 @@ 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"` ContextPairs []kctl.ContextPair `yaml:"context_pairs"`
TeamLabelKey string `yaml:"team_label_key"` TeamLabelKey string `yaml:"team_label_key"`
AWSSSOLoginCommand string `yaml:"aws_sso_login_command"`
}
// LoginCommand returns the configured AWS SSO login command, falling back
// to DefaultAWSSSOLoginCommand if none is set. Organizations that wrap
// SSO login in a custom script (e.g. to select a specific profile) can
// override this via aws_sso_login_command in config.yaml.
func (c Config) LoginCommand() string {
if c.AWSSSOLoginCommand == "" {
return DefaultAWSSSOLoginCommand
}
return c.AWSSSOLoginCommand
} }
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml // DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
+20
View File
@@ -184,3 +184,23 @@ func GetAWSSecretString(secretID, region string) (string, error) {
"--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:]...)
}