Files
cctuip/cctuip.go
T
root c4e31f9880 fix: merge per-service statuses, bound SSH concurrency, fix filter and page size
Stacks usually run more than one service, so the last service read used
to wipe the status of the stack. Merge the labels into the existing map
instead.

Servers are now queried through an injectable fetcher with a concurrency
limit, so a large fleet no longer opens one SSH connection per server at
once, and the fetch is testable.

Also:
- only read the recipe catalogue when a deployed version exists, and
  render statuses anyway if it cannot be fetched
- truncate the error banner instead of joining every server error
- clamp the page size to at least one row, as bubble-table otherwise
  renders nothing at all
- stop treating keypresses as commands while the filter has focus, so q
  types a q rather than quitting
- parse flags on a dedicated flag set with long forms, so a bad flag
  reports against cctuip help instead of exiting
- drop golang.org/x/exp in favour of the built-in maps
2026-09-30 15:20:55 +00:00

692 lines
17 KiB
Go

// cctuip is a Co-op Cloud TUI.
package main
import (
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"sort"
"strings"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
"golang.org/x/term"
)
// version is the cctuip version. This is overridden at build time with
// -ldflags "-X 'main.version=$(VERSION)'", see the Makefile.
var version = "v0.1.0"
// help is the cctuip CLI help output.
const help = `cctuip [options]
cctuip is a Co-op Cloud TUI.
Options:
-h, --help output help
-v, --version output version
`
// Table row, column and per-stack status keys. The same keys are used for all
// three so that they cannot drift apart.
const (
keyDomain = "domain"
keyServer = "server"
keyRecipe = "recipe"
keyStatus = "status"
keyVersion = "version"
keyUpdates = "updates"
keyChaos = "chaos"
keyChaosVersion = "chaos-version"
keyAutoUpdate = "auto-update"
keyApp = "app"
)
// statusKeys are the columns rendered from the per-stack status maps, in the
// order they appear in the table.
var statusKeys = []string{
keyStatus,
keyVersion,
keyUpdates,
keyChaos,
keyChaosVersion,
keyAutoUpdate,
}
// unknownStatus is rendered for any value which could not be determined.
const unknownStatus = "🤷"
// maxConcurrentServers caps how many servers are queried at the same time so
// that a large fleet does not open one SSH connection per server at once.
const maxConcurrentServers = 5
// maxReportedProblems caps how many problems the error banner shows before it
// collapses the rest into a count.
const maxReportedProblems = 3
// chromeRows is the number of terminal rows reserved for the counts line, the
// error banner and the footer around the table.
const chromeRows = 10
// newFlagSet registers the cctuip flags, including their long forms, on a
// dedicated flag set so that a bad flag returns an error rather than exiting
// from inside the flag package.
func newFlagSet(helpFlag, versionFlag *bool) *flag.FlagSet {
fs := flag.NewFlagSet("cctuip", flag.ContinueOnError)
fs.BoolVar(helpFlag, "h", false, "output help")
fs.BoolVar(helpFlag, "help", false, "output help")
fs.BoolVar(versionFlag, "v", false, "output version")
fs.BoolVar(versionFlag, "version", false, "output version")
return fs
}
// getApps retrieves app metadata from the abra API.
func getApps() ([]config.App, error) {
appFiles, err := config.LoadAppFiles("")
if err != nil {
return []config.App{}, fmt.Errorf("getApps: %s", err)
}
apps, err := config.GetApps(appFiles, "")
if err != nil {
return []config.App{}, fmt.Errorf("getApps: %s", err)
}
sort.Sort(config.ByName(apps))
return apps, nil
}
// getNumServersAndRecipes totals servers and recipes.
func getNumServersAndRecipes(apps []config.App) (int, int) {
servers := make(map[string]struct{})
recipes := make(map[string]struct{})
for _, app := range apps {
servers[app.Server] = struct{}{}
recipes[app.Recipe] = struct{}{}
}
return len(servers), len(recipes)
}
// errorMsg delivers errors to the UI.
type errorMsg struct{ err error }
// appsDeployStatusMsg delivers the deployment status of all apps and any
// per-server errors to the UI.
type appsDeployStatusMsg struct {
statuses map[string]map[string]string
serverErrors map[string]error
catalogueErr error
}
// labelKey builds the Co-op Cloud label key for a stack label suffix.
func labelKey(stackName, suffix string) string {
return fmt.Sprintf("coop-cloud.%s.%s", stackName, suffix)
}
// parseServiceStatuses folds the labels of every deployed swarm service into a
// status map per stack.
//
// Services are merged into the existing map rather than replacing it because
// most stacks run more than one service (e.g. an app alongside its database)
// and the last service read would otherwise drop the keys it does not set.
func parseServiceStatuses(all []stack.StackStatus) map[string]map[string]string {
statuses := make(map[string]map[string]string)
for _, result := range all {
if result.Err != nil {
continue
}
for _, service := range result.Services {
name := service.Spec.Labels[convert.LabelNamespace]
if name == "" {
continue
}
status, ok := statuses[name]
if !ok {
status = map[string]string{keyStatus: "deployed"}
statuses[name] = status
}
if chaos, ok := service.Spec.Labels[labelKey(name, "chaos")]; ok {
status[keyChaos] = chaos
}
if chaosVersion, ok := service.Spec.Labels[labelKey(name, "chaos-version")]; ok {
status[keyChaosVersion] = chaosVersion
}
if autoUpdate, ok := service.Spec.Labels[labelKey(name, "autoupdate")]; ok {
status[keyAutoUpdate] = autoUpdate
} else {
status[keyAutoUpdate] = "false"
}
if version, ok := service.Spec.Labels[labelKey(name, "version")]; ok {
status[keyVersion] = version
}
}
}
return statuses
}
// resolveUpdates compares each deployed version against the recipe catalogue
// and records how many newer versions are available, in place.
func resolveUpdates(
apps []config.App,
statuses map[string]map[string]string,
catl recipe.RecipeCatalogue,
) {
catalogueVersions := make(map[string][]string)
for _, app := range apps {
status, ok := statuses[app.StackName()]
if !ok {
continue
}
version, ok := status[keyVersion]
if !ok {
continue
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
status[keyUpdates] = unknownStatus
continue
}
updates, ok := catalogueVersions[app.Recipe]
if !ok {
updates, err = recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
status[keyUpdates] = unknownStatus
continue
}
catalogueVersions[app.Recipe] = updates
}
var (
newUpdates []string
parseFailed bool
)
for _, update := range updates {
parsedUpdate, err := tagcmp.Parse(update)
if err != nil {
parseFailed = true
break
}
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
newUpdates = append(newUpdates, update)
}
}
switch {
case parseFailed:
status[keyUpdates] = unknownStatus
case len(newUpdates) == 0:
status[keyUpdates] = "✅"
default:
status[keyUpdates] = fmt.Sprintf("%v", len(newUpdates))
}
}
}
// hasDeployedVersion reports whether any of the apps is deployed with a known
// version, i.e. whether it is worth fetching the recipe catalogue at all.
func hasDeployedVersion(apps []config.App, statuses map[string]map[string]string) bool {
for _, app := range apps {
if _, ok := statuses[app.StackName()][keyVersion]; ok {
return true
}
}
return false
}
// serverResult pairs a server with the swarm services retrieved from it.
type serverResult struct {
server string
status stack.StackStatus
}
// serverFetcher retrieves the deployed swarm services for a single server.
type serverFetcher func(server string) stack.StackStatus
// newServerFetcher returns a fetcher which reaches each server's docker socket
// over SSH.
func newServerFetcher() serverFetcher {
return func(server string) stack.StackStatus {
cl, err := client.New(server)
if err != nil {
return stack.StackStatus{Err: err}
}
return stack.GetAllDeployedServices(cl, server)
}
}
// fetchAll queries each server concurrently, keeping at most limit requests in
// flight. Servers which cannot be reached (e.g. SSH login issues) are reported
// via serverErrors so that statuses for the remaining servers are still
// returned.
func fetchAll(
fetcher serverFetcher,
servers []string,
limit int,
) ([]stack.StackStatus, map[string]error) {
if limit < 1 {
limit = 1
}
results := make(chan serverResult, len(servers))
slots := make(chan struct{}, limit)
for _, server := range servers {
go func(s string) {
slots <- struct{}{}
defer func() { <-slots }()
results <- serverResult{server: s, status: fetcher(s)}
}(server)
}
all := make([]stack.StackStatus, 0, len(servers))
serverErrors := make(map[string]error)
for range servers {
res := <-results
if res.status.Err != nil {
serverErrors[res.server] = res.status.Err
continue
}
all = append(all, res.status)
}
return all, serverErrors
}
// getAppsDeployStatus retrieves apps deployment status from the servers they
// are deployed on.
func getAppsDeployStatus(m model) tea.Msg {
apps := visibleApps(&m)
var (
servers []string
seen = make(map[string]struct{})
)
for _, app := range apps {
if _, ok := seen[app.Server]; ok {
continue
}
seen[app.Server] = struct{}{}
servers = append(servers, app.Server)
}
all, serverErrors := fetchAll(newServerFetcher(), servers, maxConcurrentServers)
statuses := parseServiceStatuses(all)
// The catalogue lives behind a git fetch, so only reach for it when there
// is a deployed version to compare it against.
var catalogueErr error
if hasDeployedVersion(apps, statuses) {
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
catalogueErr = err
} else {
resolveUpdates(apps, statuses, catl)
}
}
return appsDeployStatusMsg{
statuses: statuses,
serverErrors: serverErrors,
catalogueErr: catalogueErr,
}
}
// statusOrUnknown returns a status value, falling back to 🤷.
func statusOrUnknown(status map[string]string, key string) string {
if value := status[key]; value != "" {
return value
}
return unknownStatus
}
// renderAppsDeployStatus renders the deployment statuses into the table.
func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model {
for _, row := range m.table.GetVisibleRows() {
app, ok := row.Data[keyApp].(config.App)
if !ok {
continue
}
status := appStatuses.statuses[app.StackName()]
for _, key := range statusKeys {
row.Data[key] = statusOrUnknown(status, key)
}
}
return m.table
}
// summariseProblems folds per-server and catalogue problems into a single
// error short enough for the one-line banner.
func summariseProblems(appStatuses appsDeployStatusMsg) error {
var problems []string
for server, err := range appStatuses.serverErrors {
problems = append(problems, fmt.Sprintf("%s: %s", server, err))
}
if appStatuses.catalogueErr != nil {
problems = append(problems, fmt.Sprintf("catalogue: %s", appStatuses.catalogueErr))
}
if len(problems) == 0 {
return nil
}
sort.Strings(problems)
if len(problems) > maxReportedProblems {
return fmt.Errorf("%s, …and %v more",
strings.Join(problems[:maxReportedProblems], ", "),
len(problems)-maxReportedProblems,
)
}
return errors.New(strings.Join(problems, ", "))
}
type initTableMsg struct{ table table.Model }
// newRows builds a table row per app, seeded with unknown values.
func newRows(apps []config.App) []table.Row {
rows := make([]table.Row, 0, len(apps))
for _, app := range apps {
rows = append(rows, table.NewRow(table.RowData{
keyDomain: app.Domain,
keyServer: app.Server,
keyRecipe: app.Recipe,
keyStatus: unknownStatus,
keyVersion: unknownStatus,
keyUpdates: unknownStatus,
keyChaos: unknownStatus,
keyChaosVersion: unknownStatus,
keyAutoUpdate: unknownStatus,
keyApp: app,
}))
}
return rows
}
// pageSize returns how many table rows fit in the given terminal height.
// bubble-table mis-paginates on a non-positive page size and silently renders
// no rows at all, so the result is always at least one row.
func pageSize(height int) int {
size := height - chromeRows
if size < 1 {
return 1
}
return size
}
// newTable builds the apps table sized for the given terminal dimensions.
func newTable(rows []table.Row, width, height int) table.Model {
colStyle := lipgloss.NewStyle().Align(lipgloss.Left)
columns := []table.Column{
table.NewFlexColumn(keyDomain, "Domain", 2).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn(keyServer, "Server", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn(keyRecipe, "Recipe", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn(keyStatus, "Status", 1).WithStyle(colStyle),
table.NewFlexColumn(keyVersion, "Version", 2).WithStyle(colStyle),
table.NewFlexColumn(keyUpdates, "Updates", 2).WithStyle(colStyle),
table.NewFlexColumn(keyChaos, "Chaos", 1).WithStyle(colStyle),
table.NewFlexColumn(keyChaosVersion, "Chaos version", 2).WithStyle(colStyle),
table.NewFlexColumn(keyAutoUpdate, "Auto-update", 1).WithStyle(colStyle),
}
keymap := table.DefaultKeyMap()
keymap.Filter = key.NewBinding(key.WithKeys("/", "f"))
keymap.PageDown = key.NewBinding(key.WithKeys("right", "l", "pgdown", "ctrl+d"))
keymap.PageUp = key.NewBinding(key.WithKeys("left", "h", "pgup", "ctrl+u"))
return table.
New(columns).
Filtered(true).
Focused(true).
WithPageSize(pageSize(height)).
WithRows(rows).
WithTargetWidth(width).
WithKeyMap(keymap)
}
// initTable loads the table layout from local-first data sources (~/.abra).
func initTable(m model) tea.Msg {
width, height, err := term.GetSize(0)
if err != nil {
return errorMsg{err}
}
return initTableMsg{table: newTable(newRows(m.apps), width, height)}
}
// model is the TUI application state.
type model struct {
apps []config.App
numApps int
numServers int
numRecipes int
numFilteredApps int
numFilteredServers int
numFilteredRecipes int
table table.Model
spinner spinner.Model
pollingStatus bool
err error
}
// visibleApps retrieves all apps currently visible in the table.
func visibleApps(m *model) []config.App {
var apps []config.App
for _, row := range m.table.GetVisibleRows() {
app, ok := row.Data[keyApp].(config.App)
if !ok {
continue
}
apps = append(apps, app)
}
return apps
}
// updateCount updates the apps/servers/recipes count.
func (m *model) updateCount() {
if m.table.GetIsFilterActive() {
apps := visibleApps(m)
m.numFilteredApps = len(apps)
m.numFilteredServers, m.numFilteredRecipes = getNumServersAndRecipes(apps)
} else {
m.numFilteredApps = m.numApps
m.numFilteredServers = m.numServers
m.numFilteredRecipes = m.numRecipes
}
}
// Init initialises a new model. All I/O happens here and the results are fed
// into the UI for further updates and rendering.
func (m model) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg { return initTable(m) },
m.spinner.Tick,
)
}
// Update handles updates to the TUI via I/O and the user.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
cmds = append(cmds, cmd)
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
switch msg := msg.(type) {
case tea.KeyMsg:
if m.err != nil {
m.err = nil
}
m.updateCount()
// While the fuzzy filter has focus every keypress belongs to it.
if m.table.GetIsFilterInputFocused() {
break
}
switch msg.String() {
case "q":
return m, tea.Quit
case "s":
if !m.pollingStatus {
m.pollingStatus = true
cmds = append(cmds, func() tea.Msg { return getAppsDeployStatus(m) })
}
}
case initTableMsg:
m.table = msg.table
case appsDeployStatusMsg:
m.pollingStatus = false
m.table = renderAppsDeployStatus(&m, msg)
if err := summariseProblems(msg); err != nil {
m.err = err
}
case tea.WindowSizeMsg:
m.table = m.table.WithTargetWidth(msg.Width)
m.table = m.table.WithPageSize(pageSize(msg.Height))
case errorMsg:
m.pollingStatus = false
m.err = msg.err
}
return m, tea.Batch(cmds...)
}
// View renders the UI.
func (m model) View() string {
body := strings.Builder{}
body.WriteString(fmt.Sprintf(
"Servers: %v, Apps: %v, Recipes: %v\n",
m.numFilteredServers, m.numFilteredApps, m.numFilteredRecipes,
))
body.WriteString(m.table.View() + "\n")
if m.err != nil {
body.WriteString(fmt.Sprintf("⚠ %v\n", m.err))
}
body.WriteString(fmt.Sprintf("cctuip %s", version))
if m.pollingStatus {
body.WriteString(fmt.Sprintf(" %s querying app status...", m.spinner.View()))
}
return body.String()
}
// main is the command-line entrypoint.
func main() {
var helpFlag bool
var versionFlag bool
fs := newFlagSet(&helpFlag, &versionFlag)
// The flag package prints its own error and usage text, but we want to
// report problems against cctuip's help instead.
fs.SetOutput(io.Discard)
if err := fs.Parse(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "cctuip: %s\n\n%s", err, help)
os.Exit(2)
}
if helpFlag {
fmt.Print(help)
os.Exit(0)
}
if versionFlag {
fmt.Println(version)
os.Exit(0)
}
apps, err := getApps()
if err != nil {
log.Fatal(err)
}
numServers, numRecipes := getNumServersAndRecipes(apps)
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
numApps := len(apps)
m := model{
apps: apps,
numApps: numApps,
numServers: numServers,
numRecipes: numRecipes,
numFilteredApps: numApps,
numFilteredServers: numServers,
numFilteredRecipes: numRecipes,
spinner: s,
}
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("oops, cctuip exploded: %s", err)
}
}