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
This commit is contained in:
root
2026-09-30 15:20:55 +00:00
parent 371c189226
commit c4e31f9880
4 changed files with 1234 additions and 211 deletions
+398 -208
View File
@@ -2,8 +2,10 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"sort"
@@ -20,12 +22,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
"golang.org/x/exp/slices"
"golang.org/x/term"
)
// version is the cctuip version.
const version = "v0.1.0"
// 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]
@@ -33,18 +35,62 @@ const help = `cctuip [options]
cctuip is a Co-op Cloud TUI.
Options:
-h output help
-v output version
-h, --help output help
-v, --version output version
`
var helpFlag bool
var versionFlag bool
// 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"
)
// handleCliFlags parses CLI flags.
func handleCliFlags() {
flag.BoolVar(&helpFlag, "h", false, "output help")
flag.BoolVar(&versionFlag, "v", false, "output version")
flag.Parse()
// 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.
@@ -66,18 +112,12 @@ func getApps() ([]config.App, error) {
// getNumServersAndRecipes totals servers and recipes.
func getNumServersAndRecipes(apps []config.App) (int, int) {
var (
servers []string
recipes []string
)
servers := make(map[string]struct{})
recipes := make(map[string]struct{})
for _, app := range apps {
if !slices.Contains(servers, app.Server) {
servers = append(servers, app.Server)
}
if !slices.Contains(recipes, app.Recipe) {
recipes = append(recipes, app.Recipe)
}
servers[app.Server] = struct{}{}
recipes[app.Recipe] = struct{}{}
}
return len(servers), len(recipes)
@@ -91,223 +131,350 @@ type errorMsg struct{ err error }
type appsDeployStatusMsg struct {
statuses map[string]map[string]string
serverErrors map[string]error
catalogueErr error
}
// getAppsDeployStatus retrieves apps deployment status from the servers they
// are deployed on. Servers which cannot be reached (e.g. SSH login issues) are
// skipped and reported via serverErrors so that statuses for the remaining
// servers can still be rendered.
func getAppsDeployStatus(m model) tea.Msg {
var apps []config.App
// 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)
}
for _, row := range m.table.GetVisibleRows() {
app, ok := row.Data["app"].(config.App)
// 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
}
apps = append(apps, app)
}
type serverResult struct {
server string
status stack.StackStatus
err error
}
version, ok := status[keyVersion]
if !ok {
continue
}
servers := make(map[string][]config.App)
for _, app := range apps {
servers[app.Server] = append(servers[app.Server], app)
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
status[keyUpdates] = unknownStatus
continue
}
ch := make(chan serverResult, len(servers))
for server := range servers {
go func(s string) {
cl, err := client.New(s)
updates, ok := catalogueVersions[app.Recipe]
if !ok {
updates, err = recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
ch <- serverResult{server: s, err: err}
return
status[keyUpdates] = unknownStatus
continue
}
ch <- serverResult{server: s, status: stack.GetAllDeployedServices(cl, s)}
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)
}
statuses := make(map[string]map[string]string)
all := make([]stack.StackStatus, 0, len(servers))
serverErrors := make(map[string]error)
for range servers {
res := <-ch
if res.err != nil {
serverErrors[res.server] = res.err
continue
}
res := <-results
if res.status.Err != nil {
serverErrors[res.server] = res.status.Err
continue
}
for _, service := range res.status.Services {
result := make(map[string]string)
name := service.Spec.Labels[convert.LabelNamespace]
if _, ok := statuses[name]; !ok {
result["status"] = "deployed"
}
if chaos, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.chaos", name)]; ok {
result["chaos"] = chaos
}
if chaosVersion, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.chaos-version", name)]; ok {
result["chaosVersion"] = chaosVersion
}
if autoUpdate, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.autoupdate", name)]; ok {
result["autoUpdate"] = autoUpdate
} else {
result["autoUpdate"] = "false"
}
version, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.version", name)]
if !ok {
continue
}
result["version"] = version
statuses[name] = result
}
all = append(all, res.status)
}
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
return errorMsg{err}
}
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{})
)
catalogueVersions := make(map[string][]string)
for _, app := range apps {
var newUpdates []string
if status, ok := statuses[app.StackName()]; ok {
if version, ok := status["version"]; ok {
updates, ok := catalogueVersions[app.Recipe]
if !ok {
updates, err = recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
return errorMsg{err}
}
catalogueVersions[app.Recipe] = updates
}
if _, ok := seen[app.Server]; ok {
continue
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
statuses[app.StackName()]["updates"] = "🤷"
continue
}
seen[app.Server] = struct{}{}
servers = append(servers, app.Server)
}
for _, update := range updates {
parsedUpdate, err := tagcmp.Parse(update)
if err != nil {
statuses[app.StackName()]["updates"] = "🤷"
continue
}
all, serverErrors := fetchAll(newServerFetcher(), servers, maxConcurrentServers)
statuses := parseServiceStatuses(all)
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
newUpdates = append(newUpdates, update)
}
}
// 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 len(newUpdates) == 0 {
statuses[app.StackName()]["updates"] = "✅"
} else {
statuses[app.StackName()]["updates"] = fmt.Sprintf("%v", len(newUpdates))
}
}
if hasDeployedVersion(apps, statuses) {
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
catalogueErr = err
} else {
resolveUpdates(apps, statuses, catl)
}
}
return appsDeployStatusMsg{statuses: statuses, serverErrors: serverErrors}
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["app"].(config.App)
app, ok := row.Data[keyApp].(config.App)
if !ok {
continue
}
appStatus := appStatuses.statuses[app.StackName()]
var (
version = appStatus["version"]
updates = appStatus["updates"]
status = appStatus["status"]
chaos = appStatus["chaos"]
chaosVersion = appStatus["chaosVersion"]
autoUpdate = appStatus["autoUpdate"]
)
if status == "" {
row.Data["status"] = "🤷"
} else {
row.Data["status"] = status
}
if version == "" {
row.Data["version"] = "🤷"
} else {
row.Data["version"] = version
row.Data["updates"] = updates
}
if chaos == "" {
row.Data["chaos"] = "🤷"
} else {
row.Data["chaos"] = chaos
}
if chaosVersion == "" {
row.Data["chaos-version"] = "🤷"
} else {
row.Data["chaos-version"] = chaosVersion
}
if autoUpdate == "" {
row.Data["autoUpdate"] = "🤷"
} else {
row.Data["autoUpdate"] = autoUpdate
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 }
// initTable loads the table layout from local-first data sources (~/.abra).
func initTable(m model) tea.Msg {
var rows []table.Row
// 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 m.apps {
for _, app := range apps {
rows = append(rows, table.NewRow(table.RowData{
"domain": app.Domain,
"server": app.Server,
"recipe": app.Recipe,
"status": "🤷",
"version": "🤷",
"updates": "🤷",
"chaos": "🤷",
"chaos-version": "🤷",
"auto-update": "🤷",
"app": app, // attach app itself for faster lookups
keyDomain: app.Domain,
keyServer: app.Server,
keyRecipe: app.Recipe,
keyStatus: unknownStatus,
keyVersion: unknownStatus,
keyUpdates: unknownStatus,
keyChaos: unknownStatus,
keyChaosVersion: unknownStatus,
keyAutoUpdate: unknownStatus,
keyApp: app,
}))
}
colStyle := lipgloss.NewStyle().Align(lipgloss.Left)
columns := []table.Column{
table.NewFlexColumn("domain", "Domain", 2).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("server", "Server", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("recipe", "Recipe", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("status", "Status", 1).WithStyle(colStyle),
table.NewFlexColumn("version", "Version", 2).WithStyle(colStyle),
table.NewFlexColumn("updates", "Updates", 2).WithStyle(colStyle),
table.NewFlexColumn("chaos", "Chaos", 1).WithStyle(colStyle),
table.NewFlexColumn("chaos-version", "Chaos version", 2).WithStyle(colStyle),
table.NewFlexColumn("auto-update", "Auto-update", 1).WithStyle(colStyle),
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
}
width, height, err := term.GetSize(0)
if err != nil {
return errorMsg{err}
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()
@@ -315,16 +482,24 @@ func initTable(m model) tea.Msg {
keymap.PageDown = key.NewBinding(key.WithKeys("right", "l", "pgdown", "ctrl+d"))
keymap.PageUp = key.NewBinding(key.WithKeys("left", "h", "pgup", "ctrl+u"))
t := table.
return table.
New(columns).
Filtered(true).
Focused(true).
WithPageSize(height - 10).
WithRows([]table.Row(rows)).
WithPageSize(pageSize(height)).
WithRows(rows).
WithTargetWidth(width).
WithKeyMap(keymap)
}
return initTableMsg{table: t}
// 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.
@@ -346,23 +521,26 @@ type model struct {
err error
}
// getFilteredApps retrieves all visible apps.
func (m model) getFilteredApps() []config.App {
var servers []config.App
// 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["app"].(config.App)
app, ok := row.Data[keyApp].(config.App)
if !ok {
continue
}
servers = append(servers, app)
apps = append(apps, app)
}
return servers
return apps
}
// updateCount updates the apps/servers/recipes count.
func (m *model) updateCount() {
if m.table.GetIsFilterActive() {
apps := m.getFilteredApps()
apps := visibleApps(m)
m.numFilteredApps = len(apps)
m.numFilteredServers, m.numFilteredRecipes = getNumServersAndRecipes(apps)
} else {
@@ -383,8 +561,8 @@ func (m model) Init() tea.Cmd {
// Update handles updates to the TUI via I/O and the user.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
cmds = append(cmds, cmd)
@@ -400,13 +578,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.table.GetIsFilterInputFocused() && !m.pollingStatus {
if !m.pollingStatus {
m.pollingStatus = true
return m, func() tea.Msg { return getAppsDeployStatus(m) }
cmds = append(cmds, func() tea.Msg { return getAppsDeployStatus(m) })
}
}
case initTableMsg:
@@ -414,17 +597,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case appsDeployStatusMsg:
m.pollingStatus = false
m.table = renderAppsDeployStatus(&m, msg)
if len(msg.serverErrors) > 0 {
var errors []string
for server, err := range msg.serverErrors {
errors = append(errors, fmt.Sprintf("%s: %s", server, err))
}
sort.Strings(errors)
m.err = fmt.Errorf("%s", strings.Join(errors, ", "))
if err := summariseProblems(msg); err != nil {
m.err = err
}
case tea.WindowSizeMsg:
m.table = m.table.WithTargetWidth(msg.Width)
m.table = m.table.WithPageSize(msg.Height - 10)
m.table = m.table.WithPageSize(pageSize(msg.Height))
case errorMsg:
m.pollingStatus = false
m.err = msg.err
@@ -459,7 +638,18 @@ func (m model) View() string {
// main is the command-line entrypoint.
func main() {
handleCliFlags()
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)