diff --git a/cli/cmd/deploy.go b/cli/cmd/deploy.go index 34a26006..76620f58 100644 --- a/cli/cmd/deploy.go +++ b/cli/cmd/deploy.go @@ -211,8 +211,19 @@ func runDeploy(cmd *cobra.Command, args []string) error { // Full for a cluster admin, who the wizard also lets pick admin-less. // Anyone else is offered the admin-less stack (deploy_adminless.go) // rather than walked into a refusal halfway through. + // The platform line comes first, before the first question. + account := "admin-less profile (--adminless)" if !deployAdminless { admin, ok := clusterAdminFn(context.Background()) + switch { + case ok && admin: + account = "cluster admin" + case ok: + account = "not cluster admin" + default: + account = "cluster admin not confirmed" + } + printPlatform(target, account) if ok && admin && wizard { if err := profileFormFn(); err != nil { return err @@ -233,6 +244,8 @@ func runDeploy(cmd *cobra.Command, args []string) error { } deployAdminless = true } + } else { + printPlatform(target, account) } if deployAdminless { if err := applyAdminlessProfile(context.Background(), cmd); err != nil { @@ -329,7 +342,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { output.Error(fmt.Sprintf("Introspection failed: %v", err)) return err } - PrintPhaseItem(fmt.Sprintf("Platform: %s (%s)", report.Provider, report.ProviderBasis)) } // ── Versions and scope ─────────────────────────────────────────────── @@ -443,7 +455,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { var p *tea.Program if isTesting { p = tea.NewProgram(dashboard, tea.WithoutRenderer(), tea.WithInput(nil)) - } else if IsInteractive() { + } else if IsInteractive() && !accessibleUI() { p = tea.NewProgram(dashboard, tea.WithAltScreen()) } dl = &DashboardController{p: p} @@ -503,7 +515,13 @@ func runDeploy(cmd *cobra.Command, args []string) error { }() if p != nil { - if _, err := p.Run(); err != nil { + final, err := p.Run() + // The window is gone: what the deploy still says goes to the terminal. + dl.closed.Store(true) + if m, ok := final.(deployDashboardModel); ok && m.interrupted { + output.Warn("Deploy window closed; the deploy goes on and prints below. Ctrl-C again stops kates, which can leave a Helm release half-applied.") + } + if err != nil { // The dashboard is cosmetic; the deploy goroutine is real work. // Returning here used to orphan it mid-helm — the process exited // while installs were in flight, leaving releases half-applied. diff --git a/cli/cmd/deploy_adminless.go b/cli/cmd/deploy_adminless.go index f04091f3..d0589064 100644 --- a/cli/cmd/deploy_adminless.go +++ b/cli/cmd/deploy_adminless.go @@ -63,7 +63,7 @@ func runProfileForm() error { huh.NewOption("Admin-less — Kafka, Connect, MirrorMaker 2, Apicurio, Kafka UI, Kates; no chaos engineering", true), ). Value(&deployAdminless), - )).WithTheme(ThemeKates()).Run() + )).WithTheme(ThemeKates()).WithAccessible(accessibleUI()).Run() } // The flags the profile leaves out: each installs something cluster-scoped. diff --git a/cli/cmd/deploy_dashboard.go b/cli/cmd/deploy_dashboard.go index 84fc311f..e1722fba 100644 --- a/cli/cmd/deploy_dashboard.go +++ b/cli/cmd/deploy_dashboard.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/x/ansi" "sort" "strings" + "sync/atomic" "time" "github.com/bmscomp/kates/cli/output" @@ -24,6 +25,9 @@ var dl *DashboardController type DashboardController struct { p *tea.Program + // closed is set once the window is gone (Ctrl-C closes it mid-deploy): + // what the deploy says from then on goes to the terminal. + closed atomic.Bool } // Every method tolerates a nil receiver. dl is a package-level pointer set @@ -31,8 +35,12 @@ type DashboardController struct { // a step that moved earlier than the dashboard's construction — would // otherwise panic mid-deploy on a log line. Logging is never worth a crash. +func (c *DashboardController) live() bool { + return c != nil && c.p != nil && !c.closed.Load() +} + func (c *DashboardController) Printf(format string, a ...any) { - if c != nil && c.p != nil { + if c.live() { c.p.Send(logMsg{text: fmt.Sprintf(format, a...)}) } else { fmt.Printf(format, a...) @@ -40,7 +48,7 @@ func (c *DashboardController) Printf(format string, a ...any) { } func (c *DashboardController) Println(a ...any) { - if c != nil && c.p != nil { + if c.live() { c.p.Send(logMsg{text: fmt.Sprint(a...)}) } else { fmt.Println(a...) @@ -48,19 +56,19 @@ func (c *DashboardController) Println(a ...any) { } func (c *DashboardController) StartComponent(id string, timeout time.Duration) { - if c != nil && c.p != nil { + if c.live() { c.p.Send(compStatusMsg{id: id, active: true, timeout: timeout}) } } func (c *DashboardController) FinishComponent(id string, success bool) { - if c != nil && c.p != nil { + if c.live() { c.p.Send(compStatusMsg{id: id, active: false, done: true, success: success}) } } func (c *DashboardController) UpdateProgress(current, total int) { - if c != nil && c.p != nil { + if c.live() { c.p.Send(progressMsg{current: current, total: total}) } } @@ -130,6 +138,11 @@ type deployDashboardModel struct { focusedPane int // 0: components, 1: logs helmVersion string + // scrolled is set once a key scrolls the components pane; until then the + // pane follows the component being deployed. + scrolled bool + // interrupted is set when Ctrl-C closed the window before the deploy ended. + interrupted bool // read runs the poller's kubectl queries. It is the seam as it stood when // the dashboard was built: the poller runs on Bubble Tea's goroutines, @@ -145,7 +158,8 @@ func NewDeployDashboard(ctx context.Context, totalSteps int) deployDashboardMode s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(theme.Accent) - prog := progress.New(progress.WithDefaultGradient()) + // The header prints the percentage itself. + prog := progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()) logsVp := viewport.New(100, 10) compVp := viewport.New(100, 15) @@ -265,13 +279,6 @@ func pollAllWorkloads(ctx context.Context, components []*componentStat, read fun stat.ready = *item.Status.ReadyReplicas } } - // Poll pods for this workload to get phase - podOut, _ := read(ctx, "kubectl", "get", "pods", "-n", target.Namespace, "-l", target.Selector, "-o", "jsonpath={range .items[*]}{.status.phase}{','}{end}") - phases := strings.Split(strings.TrimSpace(string(podOut)), ",") - if len(phases) > 0 && phases[0] != "" { - stat.phase = phases[0] // Simplify by just taking the first pod's phase for the workload - } - workloads = append(workloads, stat) } } @@ -360,7 +367,11 @@ func (m deployDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if msg.String() == "ctrl+c" { - m.cancel() // cancel context for background workers + // Closes the window only. Helm may be mid-install, so the deploy + // goes on, printing to the terminal (runDeploy); the terminal is + // no longer raw, so a second Ctrl-C ends kates. + m.cancel() // stops the poller + m.interrupted = true return m, tea.Quit } if msg.String() == "tab" { @@ -371,6 +382,7 @@ func (m deployDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if m.focusedPane == 0 { m.componentsViewport, cmd = m.componentsViewport.Update(msg) + m.scrolled = true } else { m.logsViewport, cmd = m.logsViewport.Update(msg) } @@ -429,33 +441,41 @@ func (m deployDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.Workloads = w } } + // The next poll waits for this one: ticking regardless stacked + // kubectl calls up whenever a poll took longer than the tick. + cmds = append(cmds, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })) case tickMsg: - cmds = append(cmds, - pollAllWorkloads(m.ctx, m.components, m.read), - tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }), - ) + // Only the components that have started have workloads to show. + var started []*componentStat + for _, c := range m.components { + if c.Active || c.Done { + started = append(started, c) + } + } + cmds = append(cmds, pollAllWorkloads(m.ctx, started, m.read)) } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) + // The pane's content lives in the model, not only in View's copy, so + // the keys can scroll it. + content, activeLine := m.componentsContent() + m.componentsViewport.SetContent(content) + if !m.scrolled && activeLine >= 0 { + m.componentsViewport.SetYOffset(activeLine - 1) // with the line above it + } + return m, tea.Batch(cmds...) } -func (m deployDashboardModel) View() string { +// componentsContent is the components pane, and the line the first component +// being deployed starts on (-1 when none is). +func (m deployDashboardModel) componentsContent() (string, int) { var b strings.Builder - - pct := float64(m.currentStep) / float64(m.totalSteps) - if m.totalSteps == 0 { - pct = 0 - } - if pct > 1.0 { - pct = 1.0 - } - - bar := m.progressBar.ViewAs(pct) + activeLine := -1 prevGroup := "" for i, c := range m.components { @@ -473,105 +493,94 @@ func (m deployDashboardModel) View() string { b.WriteString(fmt.Sprintf("%s\n", lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).Render("── "+label))) } - if !c.Active && !c.Done { - b.WriteString(fmt.Sprintf(" %s\n", output.DimStyle.Render("◯ Pending..."))) - } else { - icon := m.spinner.View() - if c.Done { - if c.Success { - icon = output.SuccessStyle.Render("✔") - } else { - icon = output.ErrorStyle.Render("✖") - } - } + if c.Active && activeLine < 0 { + activeLine = strings.Count(b.String(), "\n") + } - statusText := output.DimStyle.Render("Deploying...") - if c.Done { - if c.Success { - statusText = output.AccentStyle.Render("Ready") - } else { - statusText = output.ErrorStyle.Render("Failed") - } - } + // A pending component is named too: the pane is the plan as well as + // the progress. + icon, statusText, name := output.DimStyle.Render("◯"), output.DimStyle.Render("Pending"), output.DimStyle.Render(c.Name) + switch { + case c.Done && c.Success: + icon, statusText = output.SuccessStyle.Render("✔"), output.AccentStyle.Render("Ready") + case c.Done: + icon, statusText = output.ErrorStyle.Render("✖"), output.ErrorStyle.Render("Failed") + case c.Active: + icon, statusText = m.spinner.View(), output.DimStyle.Render("Deploying...") + } + if c.Active || c.Done { + name = lipgloss.NewStyle().Bold(true).Render(c.Name) + } - nsStr := "" - if len(c.Targets) > 0 { - nsStr = " [" + c.Targets[0].Namespace + "]" - } - boldStyle := lipgloss.NewStyle().Bold(true) - leftPart := fmt.Sprintf("%s %s%s", icon, boldStyle.Render(c.Name), output.DimStyle.Render(nsStr)) - rightPart := statusText - // We just use m.compWidth for total width - spaces := m.compWidth - lipgloss.Width(leftPart) - lipgloss.Width(rightPart) - if spaces < 1 { - spaces = 1 - } - b.WriteString(fmt.Sprintf("%s%s%s\n", leftPart, strings.Repeat(" ", spaces), rightPart)) + nsStr := "" + if len(c.Targets) > 0 { + nsStr = " [" + c.Targets[0].Namespace + "]" + } + leftPart := fmt.Sprintf("%s %s%s", icon, name, output.DimStyle.Render(nsStr)) + rightPart := statusText + // We just use m.compWidth for total width + spaces := m.compWidth - lipgloss.Width(leftPart) - lipgloss.Width(rightPart) + if spaces < 1 { + spaces = 1 + } + fmt.Fprintf(&b, "%s%s%s\n", leftPart, strings.Repeat(" ", spaces), rightPart) - // Render timeout bar if active - if c.Active && !c.Done && c.Timeout > 0 { - elapsed := time.Since(c.StartTime) - if elapsed > c.Timeout { - elapsed = c.Timeout - } + // Render timeout bar if active + if c.Active && !c.Done && c.Timeout > 0 { + elapsed := time.Since(c.StartTime) + if elapsed > c.Timeout { + elapsed = c.Timeout + } - tLeft := fmt.Sprintf(" %s", output.DimStyle.Render("Timeout")) + tLeft := fmt.Sprintf(" %s", output.DimStyle.Render("Timeout")) - // Format mm:ss - eM := int(elapsed.Minutes()) - eS := int(elapsed.Seconds()) % 60 - tM := int(c.Timeout.Minutes()) - tS := int(c.Timeout.Seconds()) % 60 + // Format mm:ss + eM := int(elapsed.Minutes()) + eS := int(elapsed.Seconds()) % 60 + tM := int(c.Timeout.Minutes()) + tS := int(c.Timeout.Seconds()) % 60 - tRight := fmt.Sprintf("[%s] %02d:%02d / %02d:%02d", - renderProgressBar(int(elapsed.Seconds()), int(c.Timeout.Seconds()), 15, false, true), - eM, eS, tM, tS) + tRight := fmt.Sprintf("[%s] %02d:%02d / %02d:%02d", + renderProgressBar(int(elapsed.Seconds()), int(c.Timeout.Seconds()), 15, false, true), + eM, eS, tM, tS) - tSpaces := m.compWidth - lipgloss.Width(tLeft) - lipgloss.Width(tRight) - if tSpaces < 1 { - tSpaces = 1 - } - b.WriteString(fmt.Sprintf("%s%s%s\n", tLeft, strings.Repeat(" ", tSpaces), output.DimStyle.Render(tRight))) + tSpaces := m.compWidth - lipgloss.Width(tLeft) - lipgloss.Width(tRight) + if tSpaces < 1 { + tSpaces = 1 } + fmt.Fprintf(&b, "%s%s%s\n", tLeft, strings.Repeat(" ", tSpaces), output.DimStyle.Render(tRight)) + } - for _, w := range c.Workloads { - name := w.name - maxNameLen := (m.compWidth - 4) * 45 / 100 - if maxNameLen < 18 { - maxNameLen = 18 - } - if len(name) > maxNameLen { - name = name[:maxNameLen-3] + "..." - } - barReady := w.ready - barTotal := w.expected - if barTotal == 0 { - barTotal = 1 - barReady = 1 - } + for _, w := range c.Workloads { + name := w.name + maxNameLen := (m.compWidth - 4) * 45 / 100 + if maxNameLen < 18 { + maxNameLen = 18 + } + if len(name) > maxNameLen { + name = name[:maxNameLen-3] + "..." + } + barReady := w.ready + barTotal := w.expected + if barTotal == 0 { + barTotal = 1 + barReady = 1 + } - status := w.phase - if status == "" { - if w.ready >= w.expected { - status = "Ready" - } else { - status = "Pending" - } - } - if status == "Ready" { - status = output.AccentStyle.Render("Ready") - } else { - status = output.WarningStyle.Render(status) - } + // From the ready count: the first pod's phase, read before, put + // a healthy workload in amber as "Running". + status := output.AccentStyle.Render("Ready") + if w.ready < w.expected { + status = output.WarningStyle.Render("Waiting") + } - wLeft := fmt.Sprintf(" %s", output.DimStyle.Render(name)) // removed one space - wRight := fmt.Sprintf("[%s] %2d/%-2d %s", renderProgressBar(barReady, barTotal, 10, false, false), w.ready, w.expected, status) - wSpaces := m.compWidth - lipgloss.Width(wLeft) - lipgloss.Width(wRight) - if wSpaces < 1 { - wSpaces = 1 - } - b.WriteString(fmt.Sprintf("%s%s%s\n", wLeft, strings.Repeat(" ", wSpaces), wRight)) + wLeft := fmt.Sprintf(" %s", output.DimStyle.Render(name)) // removed one space + wRight := fmt.Sprintf("[%s] %2d/%-2d %s", renderProgressBar(barReady, barTotal, 10, false, false), w.ready, w.expected, status) + wSpaces := m.compWidth - lipgloss.Width(wLeft) - lipgloss.Width(wRight) + if wSpaces < 1 { + wSpaces = 1 } + fmt.Fprintf(&b, "%s%s%s\n", wLeft, strings.Repeat(" ", wSpaces), wRight) } if isLastInGroup { @@ -581,24 +590,20 @@ func (m deployDashboardModel) View() string { prevGroup = c.Group } - // Calculate which line the active component starts at to auto-scroll - contentStr := b.String() - m.componentsViewport.SetContent(contentStr) + return b.String(), activeLine +} - // Simple auto-scroll heuristic: if there's an active component, scroll roughly to it - activeIdx := -1 - for i, c := range m.components { - if c.Active { - activeIdx = i - break - } +func (m deployDashboardModel) View() string { + pct := float64(m.currentStep) / float64(m.totalSteps) + if m.totalSteps == 0 { + pct = 0 } - if activeIdx >= 0 && m.focusedPane != 0 { - pct := float64(activeIdx) / float64(len(m.components)) - targetLine := int(pct * float64(m.componentsViewport.TotalLineCount())) - m.componentsViewport.SetYOffset(targetLine) + if pct > 1.0 { + pct = 1.0 } + bar := m.progressBar.ViewAs(pct) + var out strings.Builder headerStyle := lipgloss.NewStyle(). @@ -642,7 +647,6 @@ type workloadStat struct { name string ready int expected int - phase string } type tickMsg time.Time diff --git a/cli/cmd/deploy_dashboard_test.go b/cli/cmd/deploy_dashboard_test.go index 4d8d195e..bf53ac0d 100644 --- a/cli/cmd/deploy_dashboard_test.go +++ b/cli/cmd/deploy_dashboard_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" @@ -143,3 +144,84 @@ func TestMarkEntryStatuses(t *testing.T) { } } } + +// The pane printed "◯ Pending..." for a component that had not started, +// without its name. Its content lived only in View's copy of the viewport, so +// the keys could not scroll it, and it followed the deploy only while the logs +// had focus: below the fold, components were never seen. +func TestDashboardComponentsPane(t *testing.T) { + m := NewDeployDashboard(context.Background(), 8) + for _, id := range []string{"strimzi", "cert-manager", "kyverno", "kafka", "kafka-users", "kates", "kafka-ui", "chaos"} { + m.RegisterComponent(id, "Component "+id, "A", Target{"ns-" + id, ""}) + } + m = dashUpdate(t, m, tea.WindowSizeMsg{Width: 120, Height: 12}) + if content, _ := m.componentsContent(); !strings.Contains(content, "Component kafka-ui") { + t.Errorf("a pending component is not named:\n%s", content) + } + + m = dashUpdate(t, m, compStatusMsg{id: "chaos", active: true, timeout: time.Minute}) + followed := m.componentsViewport.YOffset + if followed == 0 { + t.Fatal("the pane does not follow the component being deployed") + } + + m = dashUpdate(t, m, tea.KeyMsg{Type: tea.KeyUp}) + if m.componentsViewport.YOffset != followed-1 { + t.Fatalf("up scrolled the pane to %d, want %d", m.componentsViewport.YOffset, followed-1) + } + m = dashUpdate(t, m, compStatusMsg{id: "chaos", done: true, success: true}) + if m.componentsViewport.YOffset != followed-1 { + t.Errorf("the pane moved under the user's scroll, to %d", m.componentsViewport.YOffset) + } +} + +// A tick polled every component's workloads, then each workload's pods again, +// and ticked again whatever the poll's progress: on a slow API server the +// kubectl calls stacked up. A healthy workload showed its first pod's phase, +// "Running", in amber. +func TestDashboardPollsOneAtATime(t *testing.T) { + m := NewDeployDashboard(context.Background(), 2) + var reads []string + m.read = func(_ context.Context, _ string, args ...string) ([]byte, error) { + reads = append(reads, strings.Join(args, " ")) + return []byte(`{"items":[{"kind":"Deployment","metadata":{"name":"kates"},"spec":{"replicas":2},"status":{"readyReplicas":2}}]}`), nil + } + m.RegisterComponent("kafka", "Kafka Cluster", "B", Target{"kafka", "strimzi.io/cluster=krafter"}) + m.RegisterComponent("kates", "Kates Backend", "C", Target{"kates", "app.kubernetes.io/instance=kates"}) + m = dashUpdate(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + m = dashUpdate(t, m, compStatusMsg{id: "kates", active: true, timeout: time.Minute}) + + _, cmd := m.Update(tickMsg(time.Now())) + msg, ok := cmd().(allWorkloadsMsg) + if !ok { + t.Fatal("a tick should run the poll and nothing else") + } + if len(reads) != 1 || !strings.Contains(reads[0], "-n kates") { + t.Errorf("want one read, of the started component's workloads; got %q", reads) + } + + next, cmd := m.Update(msg) + if cmd == nil { + t.Error("the poll's answer should schedule the next tick") + } + content, _ := next.(deployDashboardModel).componentsContent() + if !strings.Contains(content, "2/2") || !strings.Contains(content, "Ready") || strings.Contains(content, "Waiting") { + t.Errorf("a ready workload should read Ready:\n%s", content) + } +} + +// Ctrl-C closed the window and nothing else: the deploy went on with nowhere +// to print, and the terminal looked hung until it ended. +func TestDashboardCtrlCClosesWindowOnly(t *testing.T) { + m := NewDeployDashboard(context.Background(), 2) + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if dm := next.(deployDashboardModel); !dm.interrupted || dm.ctx.Err() == nil || cmd == nil { + t.Errorf("Ctrl-C should mark the window interrupted, stop the poller and quit") + } + + c := &DashboardController{p: &tea.Program{}} + c.closed.Store(true) + if c.live() { + t.Error("a closed window should send the deploy's lines to the terminal") + } +} diff --git a/cli/cmd/deploy_installed.go b/cli/cmd/deploy_installed.go index 46e7b8c8..7dca40e6 100644 --- a/cli/cmd/deploy_installed.go +++ b/cli/cmd/deploy_installed.go @@ -123,6 +123,40 @@ func describeInstalled(e DeploySummaryEntry, chart, dir string) string { return fmt.Sprintf("%s %s → %s in %s (%s)", e.Name, have, ours, e.Namespace, verb) } +// installedHints is, per wizard component option, the releases of it already +// deployed in any namespace ("kafka-ui-0.3.2 in kafka"). It is read before +// the namespaces are chosen, so the component list can show them. +func installedHints(ctx context.Context) map[string]string { + ns := resolveNamespaces() + options := []struct{ option, release, namespace string }{ + {"strimzi", "strimzi-operator", "strimzi-operator"}, + {"kafka-connect", "connect-cluster", ns.connect}, + {"kafka-ui", "kafka-ui", ns.kafkaUI}, + {"mirror-maker2", "mm2", ns.mm2}, + {"chaos", "chaos", ns.chaos}, + {"monitoring", "monitoring", ns.jaeger}, + {"cert-manager", "cert-manager", "cert-manager"}, + {"kyverno", "kyverno", "kyverno"}, + } + entries := make([]DeploySummaryEntry, len(options)) + for i, o := range options { + entries[i] = DeploySummaryEntry{Release: o.release, Namespace: o.namespace} + } + hints := map[string]string{} + for _, r := range deployedReleases(ctx, entries) { + i := slices.IndexFunc(options, func(o struct{ option, release, namespace string }) bool { return o.release == r.Name }) + if i < 0 { + continue + } + hint := r.Chart + " in " + r.Namespace + if prev := hints[options[i].option]; prev != "" { + hint = prev + ", " + hint + } + hints[options[i].option] = hint + } + return hints +} + // installedRow is a component of this deploy that is already installed. type installedRow struct { release string @@ -294,7 +328,7 @@ func runInstalledReleasesForm() error { Options(options...). Value(&picked)) } - if err := huh.NewForm(huh.NewGroup(fields...)).WithTheme(ThemeKates()).Run(); err != nil { + if err := huh.NewForm(huh.NewGroup(fields...)).WithTheme(ThemeKates()).WithAccessible(accessibleUI()).Run(); err != nil { return err } for _, o := range options { diff --git a/cli/cmd/deploy_installed_test.go b/cli/cmd/deploy_installed_test.go index d130ddbf..21eb353d 100644 --- a/cli/cmd/deploy_installed_test.go +++ b/cli/cmd/deploy_installed_test.go @@ -3,8 +3,10 @@ package cmd import ( "context" "errors" + "maps" "os" "path/filepath" + "slices" "strings" "testing" @@ -206,3 +208,21 @@ func TestDeployDryRunListsInstalled(t *testing.T) { t.Errorf("the dry run does not list what is installed:\n%s", out) } } + +func TestInstalledHints(t *testing.T) { + orig := runExecOutputFn + t.Cleanup(func() { runExecOutputFn = orig }) + runExecOutputFn = func(_ context.Context, _ string, args ...string) ([]byte, error) { + if slices.Contains(args, "-A") { + return []byte(`[{"name":"kafka-ui","namespace":"kafka","chart":"kafka-ui-0.3.2"}, + {"name":"kafka-ui","namespace":"team","chart":"kafka-ui-0.3.0"}, + {"name":"cert-manager","namespace":"cert-manager","chart":"cert-manager-v1.13.3"}]`), nil + } + return nil, errors.New("unexpected") + } + got := installedHints(context.Background()) + want := map[string]string{"kafka-ui": "kafka-ui-0.3.2 in kafka, kafka-ui-0.3.0 in team", "cert-manager": "cert-manager-v1.13.3 in cert-manager"} + if !maps.Equal(got, want) { + t.Errorf("installedHints = %v, want %v", got, want) + } +} diff --git a/cli/cmd/deploy_picker.go b/cli/cmd/deploy_picker.go index 25812faa..1b493e11 100644 --- a/cli/cmd/deploy_picker.go +++ b/cli/cmd/deploy_picker.go @@ -171,7 +171,7 @@ func runVersionPickerForm() error { kafkaSelect, } } - form := huh.NewForm(huh.NewGroup(fields...)).WithTheme(ThemeKates()) + form := huh.NewForm(huh.NewGroup(fields...)).WithTheme(ThemeKates()).WithAccessible(accessibleUI()) if err := form.Run(); err != nil { return err @@ -315,7 +315,7 @@ func runDeployReviewForm() error { Negative("Cancel"). Value(&confirm), ), - ).WithTheme(ThemeKates()) + ).WithTheme(ThemeKates()).WithAccessible(accessibleUI()) if err := form.Run(); err != nil { return err } diff --git a/cli/cmd/deploy_plan.go b/cli/cmd/deploy_plan.go index b7e3a448..b7abee8c 100644 --- a/cli/cmd/deploy_plan.go +++ b/cli/cmd/deploy_plan.go @@ -14,17 +14,28 @@ import ( func runInteractiveForms() error { var components []string + // What is already installed, beside each component it belongs to. + hintCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + hints := installedHints(hintCtx) + cancel() + option := func(label, value string, on bool) huh.Option[string] { + if hint := hints[value]; hint != "" { + label += " · " + hint + } + return huh.NewOption(label, value).Selected(on) + } + var leftOut []leftOutComponent isolated := "Isolated Namespaces (kafka, kates, monitoring, litmus)" componentOptions := []huh.Option[string]{ - huh.NewOption("🦊 Strimzi Operator", "strimzi").Selected(deployWithStrimzi), - huh.NewOption("🔗 Kafka Connect + PostgreSQL (CDC)", "kafka-connect").Selected(deployWithKafkaConnect), - huh.NewOption("💻 Kafka UI (Dashboard)", "kafka-ui").Selected(deployWithKafkaUI), - huh.NewOption("🔁 MirrorMaker 2 (loopback mirror of the primary)", "mirror-maker2").Selected(deployWithMirrorMaker2), - huh.NewOption("🧪 Litmus Chaos Engine", "chaos").Selected(deployWithChaos), - huh.NewOption("📊 Monitoring (Grafana + Prometheus)", "monitoring").Selected(deployWithMonitoring), - huh.NewOption("🔐 Cert-Manager (TLS)", "cert-manager").Selected(deployWithCertManager), - huh.NewOption("🚦 Kyverno (Policies)", "kyverno").Selected(deployWithKyverno), + option("🦊 Strimzi Operator", "strimzi", deployWithStrimzi), + option("🔗 Kafka Connect + PostgreSQL (CDC)", "kafka-connect", deployWithKafkaConnect), + option("💻 Kafka UI (Dashboard)", "kafka-ui", deployWithKafkaUI), + option("🔁 MirrorMaker 2 (loopback mirror of the primary)", "mirror-maker2", deployWithMirrorMaker2), + option("🧪 Litmus Chaos Engine", "chaos", deployWithChaos), + option("📊 Monitoring (Grafana + Prometheus)", "monitoring", deployWithMonitoring), + option("🔐 Cert-Manager (TLS)", "cert-manager", deployWithCertManager), + option("🚦 Kyverno (Policies)", "kyverno", deployWithKyverno), } if deployAdminless { // Only what installs into a namespace: no operators, no chaos @@ -42,7 +53,7 @@ func runInteractiveForms() error { componentFields := []huh.Field{ huh.NewMultiSelect[string](). Title("Select Components"). - Description("Space toggles, enter confirms. Kafka and the Kates backend are always deployed."). + Description("Space toggles, enter confirms. Kafka and the Kates backend are always deployed. After · is what is installed now."). Options(componentOptions...). Value(&components), } @@ -82,7 +93,7 @@ func runInteractiveForms() error { // multi-select squeezed under three other fields could hide its // last entries until the cursor reached them. huh.NewGroup(componentFields...), - ).WithTheme(ThemeKates()) + ).WithTheme(ThemeKates()).WithAccessible(accessibleUI()) if err := form1.Run(); err != nil { return err @@ -99,16 +110,16 @@ func runInteractiveForms() error { Title("Namespace"). Description("An existing namespace this account may write to; everything goes there"). Value(&deployNamespace), - )).WithTheme(ThemeKates()) + )).WithTheme(ThemeKates()).WithAccessible(accessibleUI()) if err := form2.Run(); err != nil { return err } } if deployTopology != "single" { - var nsGroups []*huh.Group - - // Core namespaces (always shown for isolated topology) - nsGroups = append(nsGroups, huh.NewGroup( + // Two pages at most: the namespaces every deploy needs, then the + // chosen components'. A huh group scrolls to the focused field, so + // the second page fits any terminal however many components are on. + nsGroups := []*huh.Group{huh.NewGroup( huh.NewInput(). Title("Kafka Namespace"). Description("Namespace for Strimzi operator and Kafka cluster"). @@ -117,29 +128,23 @@ func runInteractiveForms() error { Title("Kates App Namespace"). Description("Namespace for the Kates backend service"). Value(&deployAppNS), - )) + )} - // Conditional namespace groups based on selected components + var optional []huh.Field if sliceContains(components, "monitoring") && !deployAdminless { - nsGroups = append(nsGroups, huh.NewGroup( - huh.NewInput(). - Title("Monitoring Namespace"). - Description("Namespace for Jaeger and monitoring components"). - Value(&deployMonitoringNS), - )) + optional = append(optional, huh.NewInput(). + Title("Monitoring Namespace"). + Description("Namespace for the Prometheus and Grafana stack"). + Value(&deployMonitoringNS)) } - if sliceContains(components, "chaos") { - nsGroups = append(nsGroups, huh.NewGroup( - huh.NewInput(). - Title("Chaos Namespace"). - Description("Namespace for Litmus Chaos engine"). - Value(&deployChaosNS), - )) + optional = append(optional, huh.NewInput(). + Title("Chaos Namespace"). + Description("Namespace for Litmus Chaos engine"). + Value(&deployChaosNS)) } - if sliceContains(components, "kafka-connect") { - nsGroups = append(nsGroups, huh.NewGroup( + optional = append(optional, huh.NewInput(). Title("Kafka Connect Namespace"). Description("Namespace for Kafka Connect cluster (separate from Kafka)"). @@ -147,29 +152,25 @@ func runInteractiveForms() error { huh.NewInput(). Title("Database Namespace"). Description("Namespace for PostgreSQL CDC database"). - Value(&deployDbNS), - )) + Value(&deployDbNS)) } - if sliceContains(components, "mirror-maker2") { - nsGroups = append(nsGroups, huh.NewGroup( - huh.NewInput(). - Title("MirrorMaker 2 Namespace"). - Description("Namespace for the loopback mirror (the Kafka namespace is the simplest: the kates-mm2 credential lives there)"). - Value(&deployMM2NS), - )) + optional = append(optional, huh.NewInput(). + Title("MirrorMaker 2 Namespace"). + Description("Namespace for the loopback mirror (the Kafka namespace is the simplest: the kates-mm2 credential lives there)"). + Value(&deployMM2NS)) } - if sliceContains(components, "kafka-ui") { - nsGroups = append(nsGroups, huh.NewGroup( - huh.NewInput(). - Title("Kafka UI Namespace"). - Description("Namespace for the Kafka UI dashboard"). - Value(&deployKafkaUINS), - )) + optional = append(optional, huh.NewInput(). + Title("Kafka UI Namespace"). + Description("Namespace for the Kafka UI dashboard"). + Value(&deployKafkaUINS)) + } + if len(optional) > 0 { + nsGroups = append(nsGroups, huh.NewGroup(optional...)) } - form2 := huh.NewForm(nsGroups...).WithTheme(ThemeKates()) + form2 := huh.NewForm(nsGroups...).WithTheme(ThemeKates()).WithAccessible(accessibleUI()) if err := form2.Run(); err != nil { return err } @@ -271,7 +272,7 @@ func printComponentSelection() { PrintPhaseSuccess("Litmus Chaos Engine") } if deployWithMonitoring { - PrintPhaseSuccess("Jaeger (Tracing)") + PrintPhaseSuccess("Monitoring (Prometheus + Grafana)") } if deployWithCertManager { PrintPhaseSuccess("Cert-Manager (TLS)") diff --git a/cli/cmd/deploy_view.go b/cli/cmd/deploy_view.go index 004ac919..488f3773 100644 --- a/cli/cmd/deploy_view.go +++ b/cli/cmd/deploy_view.go @@ -3,10 +3,12 @@ package cmd import ( "context" "fmt" + "os" "strings" "time" "github.com/bmscomp/kates/cli/output" + "github.com/bmscomp/kates/cli/pkg/detect" "github.com/bmscomp/kates/cli/pkg/theme" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -355,6 +357,16 @@ func PrintDeployBanner(target string) { Render(strings.Repeat(g.HeavyRule, uiWidth()))) } +// printPlatform says what deploy is about to install onto: the platform and +// how it was told, the Kubernetes version, and the account. +func printPlatform(target, account string) { + provider, basis, version := detect.Platform(defaultExecutor, target) + if version == "" { + version = "version unknown" + } + PrintPhaseItem(fmt.Sprintf("%s (%s) · Kubernetes %s · %s", provider, basis, version, account)) +} + // bannerSubtitle is the one-line context: CLI version and target cluster, // each omitted when unknown rather than printed as "unknown". func bannerSubtitle(target string) string { @@ -368,6 +380,13 @@ func bannerSubtitle(target string) string { return strings.Join(parts, " · ") } +// accessibleUI is huh's line-by-line prompting and a printed, not live, +// deploy dashboard: for screen readers and dumb terminals (TERM=dumb, or +// KATES_ACCESSIBLE=1). +func accessibleUI() bool { + return os.Getenv("TERM") == "dumb" || os.Getenv("KATES_ACCESSIBLE") == "1" +} + // ThemeKates returns a custom huh theme using the Kates blue palette, // optimized for light terminal backgrounds. func ThemeKates() *huh.Theme { diff --git a/cli/pkg/detect/collector.go b/cli/pkg/detect/collector.go index 6a6c0c68..2f2dcd76 100644 --- a/cli/pkg/detect/collector.go +++ b/cli/pkg/detect/collector.go @@ -254,6 +254,18 @@ func platformOf(gitVersion string, apiVersions []string, nodes []NodeInfo, kubeC return "generic", "no platform signal" } +// Platform reads only what platformOf needs: the server version, the API +// groups and the nodes' providerIDs, which an admin-less account may not +// read. version is the server's gitVersion. +func Platform(exec CommandExecutor, kubeContext string) (provider, basis, version string) { + c := &Collector{exec: exec} + _, _, version = c.getK8sVersion() + out, _ := exec.Exec("kubectl", "api-versions") + nodes, _ := c.parseNodes() + provider, basis = platformOf(version, strings.Fields(out), nodes, kubeContext) + return provider, basis, version +} + func providerFromContext(ctx string) string { lowerCtx := strings.ToLower(ctx) if strings.Contains(lowerCtx, "kind") { diff --git a/cli/pkg/detect/collector_test.go b/cli/pkg/detect/collector_test.go index 7cdec57d..5bb9133e 100644 --- a/cli/pkg/detect/collector_test.go +++ b/cli/pkg/detect/collector_test.go @@ -757,3 +757,19 @@ func TestPlatformOf(t *testing.T) { }) } } + +func TestPlatform_ReadsOnlyWhatItNeeds(t *testing.T) { + m := NewMockExecutor() + m.Set(`{"serverVersion":{"major":"1","minor":"30","gitVersion":"v1.30.4-eks-a737599"}}`, "kubectl", "version", "-o", "json") + m.Set("v1\napps/v1", "kubectl", "api-versions") + m.Set(`{"items":[{"metadata":{"name":"ip-10-0-1-1"},"spec":{"providerID":"aws:///eu-west-1a/i-1"}}]}`, "kubectl", "get", "nodes", "-o", "json") + if p, basis, v := Platform(m, "prod"); p != "eks" || basis != "server version v1.30.4-eks-a737599 on aws:// nodes" || v != "v1.30.4-eks-a737599" { + t.Errorf("Platform = %q, %q, %q", p, basis, v) + } + + // An admin-less account may not list nodes: the version and the context still count. + m.Errors["kubectl get nodes"] = errors.New("forbidden") + if p, basis, _ := Platform(m, "kind-panda"); p != "kind" || basis != "context name, guessed" { + t.Errorf("without nodes: Platform = %q, %q", p, basis) + } +}