|
|
|
@@ -13,6 +13,7 @@ import (
|
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
|
|
|
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/kubeexec"
|
|
|
|
|
)
|
|
|
|
@@ -24,6 +25,7 @@ const (
|
|
|
|
|
stepMenu panelStep = iota
|
|
|
|
|
stepRedeployList
|
|
|
|
|
stepRedeployConfirm
|
|
|
|
|
stepAWSAuthPrompt
|
|
|
|
|
stepSecretRegion
|
|
|
|
|
stepSecretList
|
|
|
|
|
stepK8sSecretName
|
|
|
|
@@ -31,10 +33,12 @@ const (
|
|
|
|
|
stepForceSyncConfirm
|
|
|
|
|
stepExternalSecretName
|
|
|
|
|
stepDone
|
|
|
|
|
stepError
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type panelModel struct {
|
|
|
|
|
ctx, ns, team string
|
|
|
|
|
cfg config.Config
|
|
|
|
|
|
|
|
|
|
step panelStep
|
|
|
|
|
list list.Model
|
|
|
|
@@ -74,7 +78,10 @@ func newPanelModel(ctx, ns, team string) *panelModel {
|
|
|
|
|
ti := textinput.New()
|
|
|
|
|
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 {
|
|
|
|
@@ -87,12 +94,17 @@ func menuItems() []list.Item {
|
|
|
|
|
|
|
|
|
|
func (m *panelModel) Init() tea.Cmd { return nil }
|
|
|
|
|
|
|
|
|
|
type awsLoginDoneMsg struct{ err error }
|
|
|
|
|
|
|
|
|
|
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
case tea.WindowSizeMsg:
|
|
|
|
|
m.list.SetSize(msg.Width, msg.Height-2)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case awsLoginDoneMsg:
|
|
|
|
|
return m.afterAWSLogin(msg.err)
|
|
|
|
|
|
|
|
|
|
case tea.KeyMsg:
|
|
|
|
|
switch msg.String() {
|
|
|
|
|
case "ctrl+c":
|
|
|
|
@@ -138,6 +150,8 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
|
|
|
|
|
return m.fromRedeployList()
|
|
|
|
|
case stepRedeployConfirm:
|
|
|
|
|
return m.fromRedeployConfirm()
|
|
|
|
|
case stepAWSAuthPrompt:
|
|
|
|
|
return m.fromAWSAuthPrompt()
|
|
|
|
|
case stepSecretRegion:
|
|
|
|
|
m.awsRegion = m.input.Value()
|
|
|
|
|
return m.fetchSecretList()
|
|
|
|
@@ -150,7 +164,7 @@ func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
|
|
|
|
|
return m.fromForceSyncConfirm()
|
|
|
|
|
case stepExternalSecretName:
|
|
|
|
|
return m.doForceSync()
|
|
|
|
|
case stepDiffResult, stepDone:
|
|
|
|
|
case stepDiffResult, stepDone, stepError:
|
|
|
|
|
m.resetToMenu()
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
@@ -165,6 +179,15 @@ func (m *panelModel) resetToMenu() {
|
|
|
|
|
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) {
|
|
|
|
|
item, ok := m.list.SelectedItem().(simpleItem)
|
|
|
|
|
if !ok {
|
|
|
|
@@ -174,8 +197,7 @@ func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) {
|
|
|
|
|
case "redeploy":
|
|
|
|
|
deployments, err := kubeexec.GetDeployments(m.ns)
|
|
|
|
|
if err != nil {
|
|
|
|
|
m.err = err
|
|
|
|
|
return m, nil
|
|
|
|
|
return m.showError(err)
|
|
|
|
|
}
|
|
|
|
|
items := make([]list.Item, 0, len(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.step = stepRedeployList
|
|
|
|
|
case "secrets":
|
|
|
|
|
m.step = stepSecretRegion
|
|
|
|
|
m.input.SetValue("eu-central-1")
|
|
|
|
|
m.input.Placeholder = "AWS region"
|
|
|
|
|
return m.checkAWSAuthAndProceed()
|
|
|
|
|
case "quit":
|
|
|
|
|
return m.handleEsc()
|
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
|
item, ok := m.list.SelectedItem().(simpleItem)
|
|
|
|
|
if !ok {
|
|
|
|
@@ -218,9 +288,12 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
|
|
|
|
|
deployment := m.k8sSecretName // set in fromRedeployList
|
|
|
|
|
_, err := kubeexec.RolloutRestart(m.ns, deployment)
|
|
|
|
|
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.step = stepDone
|
|
|
|
|
return m, nil
|
|
|
|
@@ -231,14 +304,10 @@ func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
|
|
|
|
|
func (m *panelModel) fetchSecretList() (tea.Model, tea.Cmd) {
|
|
|
|
|
names, err := kubeexec.ListAWSSecrets(m.awsRegion)
|
|
|
|
|
if err != nil {
|
|
|
|
|
m.err = err
|
|
|
|
|
m.resetToMenu()
|
|
|
|
|
return m, nil
|
|
|
|
|
return m.showError(err)
|
|
|
|
|
}
|
|
|
|
|
if len(names) == 0 {
|
|
|
|
|
m.err = fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion)
|
|
|
|
|
m.resetToMenu()
|
|
|
|
|
return m, nil
|
|
|
|
|
return m.showError(fmt.Errorf("no AWS secrets found in region %s (or missing IAM permissions)", m.awsRegion))
|
|
|
|
|
}
|
|
|
|
|
items := make([]list.Item, 0, len(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)
|
|
|
|
|
if err != nil {
|
|
|
|
|
m.err = err
|
|
|
|
|
m.resetToMenu()
|
|
|
|
|
return m, nil
|
|
|
|
|
return m.showError(err)
|
|
|
|
|
}
|
|
|
|
|
var parsed map[string]interface{}
|
|
|
|
|
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) {
|
|
|
|
|
k8sValues, err := kubeexec.GetSecretAllFields(m.ns, m.k8sSecretName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
m.err = err
|
|
|
|
|
m.resetToMenu()
|
|
|
|
|
return m, nil
|
|
|
|
|
return m.showError(err)
|
|
|
|
|
}
|
|
|
|
|
m.k8sValues = k8sValues
|
|
|
|
|
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
|
|
|
@@ -348,7 +413,7 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
|
|
|
|
ts := time.Now().Unix()
|
|
|
|
|
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts)
|
|
|
|
|
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.step = stepDone
|
|
|
|
@@ -358,19 +423,23 @@ func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
|
|
|
|
func (m *panelModel) View() string {
|
|
|
|
|
switch m.step {
|
|
|
|
|
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretList:
|
|
|
|
|
v := m.list.View()
|
|
|
|
|
return m.list.View()
|
|
|
|
|
case stepAWSAuthPrompt:
|
|
|
|
|
errText := ""
|
|
|
|
|
if m.err != nil {
|
|
|
|
|
v += "\nerror: " + m.err.Error()
|
|
|
|
|
errText = m.err.Error() + "\n\n"
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
return errText + m.list.View()
|
|
|
|
|
case stepForceSyncConfirm:
|
|
|
|
|
return m.message + "\n\n" + m.list.View()
|
|
|
|
|
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 {
|
|
|
|
|
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:
|
|
|
|
|
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)",
|
|
|
|
|
m.stepPrompt(), m.input.View())
|
|
|
|
|