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)
+836
View File
@@ -0,0 +1,836 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
tea "github.com/charmbracelet/bubbletea"
"github.com/docker/docker/api/types/swarm"
"github.com/evertras/bubble-table/table"
)
// testApp builds an app pinned to an explicit stack name so that tests do not
// depend on abra's stack name sanitisation rules.
func testApp(domain, server, recipeName, stackName string) config.App {
return config.App{
Name: domain,
Domain: domain,
Server: server,
Recipe: recipeName,
Env: config.AppEnv{"STACK_NAME": stackName},
}
}
// labelledService builds a swarm service carrying the Co-op Cloud labels for a
// stack, using the same label keys as the production code.
func labelledService(stackName string, values map[string]string) swarm.Service {
labels := map[string]string{convert.LabelNamespace: stackName}
for suffix, value := range values {
labels[labelKey(stackName, suffix)] = value
}
return swarm.Service{
Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Labels: labels}},
}
}
// testModel builds a model backed by a table of the given apps.
func testModel(apps []config.App) model {
return model{
apps: apps,
table: newTable(newRows(apps), 200, 40),
}
}
// keyMsg builds a rune key message.
func keyMsg(r rune) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}
}
// send delivers a key to the model and returns the updated model.
func send(t *testing.T, m model, r rune) (model, tea.Cmd) {
t.Helper()
updated, cmd := m.Update(keyMsg(r))
next, ok := updated.(model)
if !ok {
t.Fatalf("Update returned a %T, want a model", updated)
}
return next, cmd
}
// containsQuit reports whether cmd is, or batches, bubbletea's quit command.
// The quit message type is unexported, so the commands are compared by code
// pointer. Only batch closures are invoked, which never run their children.
func containsQuit(cmd tea.Cmd) bool {
if cmd == nil {
return false
}
if reflect.ValueOf(cmd).Pointer() == reflect.ValueOf(tea.Quit).Pointer() {
return true
}
batch, ok := cmd().(tea.BatchMsg)
if !ok {
return false
}
for _, child := range batch {
if containsQuit(child) {
return true
}
}
return false
}
// rowFor returns the first visible row of a table.
func rowFor(t *testing.T, tbl table.Model) table.Row {
t.Helper()
rows := tbl.GetVisibleRows()
if len(rows) != 1 {
t.Fatalf("expected exactly one visible row, got %v", len(rows))
}
return rows[0]
}
// B3: most stacks run more than one service, and the last service read must not
// wipe out the status of the stack.
func TestParseServiceStatusesMultiServiceStack(t *testing.T) {
const name = "myapp_com_example"
statuses := parseServiceStatuses([]stack.StackStatus{{
Services: []swarm.Service{
labelledService(name, map[string]string{
"version": "1.0.0",
"chaos": "true",
}),
// A second service in the same stack, as any app with a database
// has, must not drop the keys the first service set.
labelledService(name, map[string]string{
"version": "1.0.0",
}),
},
}})
status, ok := statuses[name]
if !ok {
t.Fatalf("expected a status for stack %q, got %v", name, statuses)
}
if got := status[keyStatus]; got != "deployed" {
t.Errorf("status = %q, want %q", got, "deployed")
}
if got := status[keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
if got := status[keyChaos]; got != "true" {
t.Errorf("chaos = %q, want %q", got, "true")
}
}
// B4: a deployed stack with no version label is still deployed.
func TestParseServiceStatusesMissingVersionLabel(t *testing.T) {
const name = "myapp_com_example"
statuses := parseServiceStatuses([]stack.StackStatus{{
Services: []swarm.Service{
labelledService(name, map[string]string{"autoupdate": "true"}),
},
}})
status, ok := statuses[name]
if !ok {
t.Fatalf("expected a status for stack %q, got %v", name, statuses)
}
if got := status[keyStatus]; got != "deployed" {
t.Errorf("status = %q, want %q", got, "deployed")
}
if got, ok := status[keyVersion]; ok {
t.Errorf("version = %q, want it to be absent", got)
}
if got := status[keyAutoUpdate]; got != "true" {
t.Errorf("auto-update = %q, want %q", got, "true")
}
}
func TestParseServiceStatusesDefaults(t *testing.T) {
const name = "myapp_com_example"
statuses := parseServiceStatuses([]stack.StackStatus{
{Err: errors.New("ssh: unreachable")},
{Services: []swarm.Service{
{Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{
Labels: map[string]string{},
}}},
labelledService(name, map[string]string{"version": "1.0.0"}),
}},
})
status, ok := statuses[name]
if !ok {
t.Fatalf("expected a status for stack %q", name)
}
if got := status[keyAutoUpdate]; got != "false" {
t.Errorf("auto-update = %q, want %q", got, "false")
}
}
// B2: the auto-update column is keyed "auto-update" and must be rendered.
func TestRenderAppsDeployStatusAutoUpdate(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", name)})
rendered := renderAppsDeployStatus(&m, appsDeployStatusMsg{
statuses: map[string]map[string]string{
name: {
keyStatus: "deployed",
keyVersion: "1.0.0",
keyUpdates: "✅",
keyAutoUpdate: "true",
},
},
})
row := rowFor(t, rendered)
if got := row.Data[keyAutoUpdate]; got != "true" {
t.Errorf("auto-update = %q, want %q", got, "true")
}
if got := row.Data[keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
}
func TestRenderAppsDeployStatusUnknown(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", name)})
rendered := renderAppsDeployStatus(&m, appsDeployStatusMsg{
statuses: map[string]map[string]string{},
})
row := rowFor(t, rendered)
for _, key := range statusKeys {
if got := row.Data[key]; got != unknownStatus {
t.Errorf("%s = %q, want %q", key, got, unknownStatus)
}
}
}
// B7: an unparseable catalogue tag must not be masked by a later ✅.
func TestResolveUpdates(t *testing.T) {
tests := []struct {
name string
deployed string
versions []string
want string
}{
{name: "up to date", deployed: "1.1.0", versions: []string{"1.0.0", "1.1.0"}, want: "✅"},
{name: "no catalogue entry", deployed: "1.0.0", versions: nil, want: "✅"},
{name: "one update", deployed: "1.0.0", versions: []string{"1.0.0", "1.1.0"}, want: "1"},
{name: "several updates", deployed: "1.0.0", versions: []string{"1.1.0", "1.2.0", "2.0.0"}, want: "3"},
{name: "newer is excluded", deployed: "2.0.0", versions: []string{"1.0.0"}, want: "✅"},
{name: "unparseable catalogue tag", deployed: "1.0.0", versions: []string{"latest"}, want: unknownStatus},
{name: "unparseable deployed tag", deployed: "nightly", versions: []string{"1.0.0"}, want: unknownStatus},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const name = "myapp_com_example"
var versions recipe.RecipeVersions
for _, version := range test.versions {
versions = append(versions, map[string]map[string]recipe.ServiceMeta{
version: {"app": recipe.ServiceMeta{Image: "img", Tag: version}},
})
}
catl := recipe.RecipeCatalogue{
"myrecipe": recipe.RecipeMeta{Versions: versions},
}
statuses := map[string]map[string]string{
name: {keyStatus: "deployed", keyVersion: test.deployed},
}
resolveUpdates([]config.App{testApp("example.com", "srv", "myrecipe", name)}, statuses, catl)
if got := statuses[name][keyUpdates]; got != test.want {
t.Errorf("updates = %q, want %q", got, test.want)
}
})
}
}
func TestResolveUpdatesSkipsUnknownApps(t *testing.T) {
const name = "myapp_com_example"
statuses := map[string]map[string]string{
name: {keyStatus: "deployed", keyVersion: "1.0.0"},
}
apps := []config.App{
testApp("example.com", "srv", "myrecipe", name),
// Not deployed at all, so it must be left alone.
testApp("other.com", "srv", "myrecipe", "other_com_other"),
}
resolveUpdates(apps, statuses, recipe.RecipeCatalogue{})
if got := statuses[name][keyUpdates]; got != "✅" {
t.Errorf("updates = %q, want %q", got, "✅")
}
if _, ok := statuses["other_com_other"]; ok {
t.Error("expected no status for an app which is not deployed")
}
}
// B1: pressing q while the fuzzy filter has focus must type a q, not quit.
func TestUpdateFilterInputDoesNotQuit(t *testing.T) {
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", "myapp_com_example")})
m, _ = send(t, m, '/')
if !m.table.GetIsFilterInputFocused() {
t.Fatal("expected the filter input to have focus after pressing /")
}
m, cmd := send(t, m, 'q')
if containsQuit(cmd) {
t.Error("pressing q inside the filter quit cctuip")
}
if got := m.table.GetCurrentFilter(); got != "q" {
t.Errorf("filter = %q, want %q", got, "q")
}
}
func TestUpdateQuitsOutsideFilter(t *testing.T) {
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", "myapp_com_example")})
_, cmd := send(t, m, 'q')
if !containsQuit(cmd) {
t.Error("expected pressing q to quit when the filter is not focused")
}
}
// B5: bubble-table renders nothing at all on a non-positive page size.
func TestPageSizeClamped(t *testing.T) {
tests := []struct {
height int
want int
}{
{height: 40, want: 30},
{height: 11, want: 1},
{height: 10, want: 1},
{height: 4, want: 1},
{height: 0, want: 1},
{height: -5, want: 1},
}
for _, test := range tests {
if got := pageSize(test.height); got != test.want {
t.Errorf("pageSize(%v) = %v, want %v", test.height, got, test.want)
}
tbl := newTable(newRows([]config.App{testApp("a.com", "srv", "r", "s")}), 80, test.height)
if got := tbl.PageSize(); got < 1 {
t.Errorf("newTable(%v rows tall) gave page size %v, want at least 1", test.height, got)
}
// A non-positive page size makes bubble-table render no rows at all,
// so assert the row really does reach the screen.
if view := tbl.View(); !strings.Contains(view, "a.com") {
t.Errorf("newTable(%v rows tall) rendered no row:\n%v", test.height, view)
}
}
}
func TestFetchAllRespectsConcurrencyLimit(t *testing.T) {
var (
mu sync.Mutex
running int
peak int
)
fetcher := func(string) stack.StackStatus {
mu.Lock()
running++
if running > peak {
peak = running
}
mu.Unlock()
time.Sleep(2 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
return stack.StackStatus{}
}
var servers []string
for i := 0; i < 20; i++ {
servers = append(servers, fmt.Sprintf("srv-%v", i))
}
fetchAll(fetcher, servers, 3)
if peak > 3 {
t.Errorf("ran %v servers at once, want at most 3", peak)
}
if peak == 0 {
t.Error("expected the fetcher to be called")
}
}
func TestFetchAllCollectsServerErrors(t *testing.T) {
fetcher := func(server string) stack.StackStatus {
if server != "ok" {
return stack.StackStatus{Err: fmt.Errorf("ssh: cannot reach %s", server)}
}
return stack.StackStatus{Services: []swarm.Service{
labelledService("myapp_com_example", map[string]string{"version": "1.0.0"}),
}}
}
all, serverErrors := fetchAll(fetcher, []string{"ok", "broken-1", "broken-2"}, 2)
if len(serverErrors) != 2 {
t.Errorf("got %v server errors, want 2: %v", len(serverErrors), serverErrors)
}
for _, server := range []string{"broken-1", "broken-2"} {
if _, ok := serverErrors[server]; !ok {
t.Errorf("expected an error for server %q", server)
}
}
if len(all) != 1 {
t.Fatalf("got %v results, want 1: a healthy server must still be returned", len(all))
}
// The healthy server's statuses survive alongside the errors.
if got := parseServiceStatuses(all)["myapp_com_example"][keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
}
func TestFetchAllEmpty(t *testing.T) {
called := false
fetcher := func(string) stack.StackStatus {
called = true
return stack.StackStatus{}
}
all, serverErrors := fetchAll(fetcher, nil, 0)
if called {
t.Error("expected no servers to be queried")
}
if len(all) != 0 || len(serverErrors) != 0 {
t.Errorf("got %v results and %v errors, want none", len(all), len(serverErrors))
}
}
func TestSummariseProblems(t *testing.T) {
if err := summariseProblems(appsDeployStatusMsg{}); err != nil {
t.Errorf("expected no error when nothing went wrong, got %v", err)
}
tests := []struct {
name string
msg appsDeployStatusMsg
want string
}{
{
name: "single server",
msg: appsDeployStatusMsg{
serverErrors: map[string]error{"a": errors.New("boom")},
},
want: "a: boom",
},
{
name: "sorted",
msg: appsDeployStatusMsg{
serverErrors: map[string]error{
"b": errors.New("boom"),
"a": errors.New("boom"),
},
},
want: "a: boom, b: boom",
},
{
name: "truncated",
msg: appsDeployStatusMsg{
serverErrors: map[string]error{
"a": errors.New("boom"),
"b": errors.New("boom"),
"c": errors.New("boom"),
"d": errors.New("boom"),
},
},
want: "a: boom, b: boom, c: boom, …and 1 more",
},
{
name: "catalogue",
msg: appsDeployStatusMsg{catalogueErr: errors.New("offline")},
want: "catalogue: offline",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := summariseProblems(test.msg)
if err == nil {
t.Fatal("expected an error, got nil")
}
if err.Error() != test.want {
t.Errorf("error = %q, want %q", err.Error(), test.want)
}
})
}
}
func TestGetNumServersAndRecipes(t *testing.T) {
tests := []struct {
name string
apps []config.App
wantServers int
wantRecipes int
}{
{
name: "empty",
},
{
name: "deduplicated",
apps: []config.App{
testApp("a.com", "srv1", "r1", "s1"),
testApp("b.com", "srv1", "r1", "s2"),
testApp("c.com", "srv2", "r2", "s3"),
},
wantServers: 2,
wantRecipes: 2,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
servers, recipes := getNumServersAndRecipes(test.apps)
if servers != test.wantServers {
t.Errorf("servers = %v, want %v", servers, test.wantServers)
}
if recipes != test.wantRecipes {
t.Errorf("recipes = %v, want %v", recipes, test.wantRecipes)
}
})
}
}
func TestVisibleAppsCountsFiltered(t *testing.T) {
apps := []config.App{
testApp("a.com", "srv1", "r1", "s1"),
testApp("b.com", "srv2", "r2", "s2"),
}
m := testModel(apps)
m.numApps = len(apps)
m.updateCount()
if m.numFilteredApps != 2 {
t.Errorf("numFilteredApps = %v, want 2", m.numFilteredApps)
}
m, _ = send(t, m, '/')
m, _ = send(t, m, 'a')
if !m.table.GetIsFilterActive() {
t.Fatal("expected the filter to be active")
}
if got := visibleApps(&m); len(got) != 1 || got[0].Domain != "a.com" {
t.Errorf("visibleApps = %v, want just a.com", got)
}
}
func TestNewFlagSetLongForms(t *testing.T) {
tests := []struct {
arg string
wantHelp bool
wantVersion bool
}{
{arg: "-h", wantHelp: true},
{arg: "--help", wantHelp: true},
{arg: "-v", wantVersion: true},
{arg: "--version", wantVersion: true},
}
for _, test := range tests {
t.Run(test.arg, func(t *testing.T) {
var helpFlag bool
var versionFlag bool
fs := newFlagSet(&helpFlag, &versionFlag)
fs.SetOutput(io.Discard)
if err := fs.Parse([]string{test.arg}); err != nil {
t.Fatalf("unexpected error: %s", err)
}
if helpFlag != test.wantHelp {
t.Errorf("help = %v, want %v", helpFlag, test.wantHelp)
}
if versionFlag != test.wantVersion {
t.Errorf("version = %v, want %v", versionFlag, test.wantVersion)
}
})
}
}
func TestNewFlagSetRejectsUnknownFlag(t *testing.T) {
var helpFlag bool
var versionFlag bool
fs := newFlagSet(&helpFlag, &versionFlag)
fs.SetOutput(io.Discard)
if err := fs.Parse([]string{"--nope"}); err == nil {
t.Error("expected an error for an unknown flag")
}
}
func TestLabelKey(t *testing.T) {
if got, want := labelKey("myapp_com_example", "version"), "coop-cloud.myapp_com_example.version"; got != want {
t.Errorf("labelKey = %q, want %q", got, want)
}
}
// deployedStatus is a fully populated status for a deployed stack.
func deployedStatus(name string) map[string]string {
return map[string]string{
keyStatus: "deployed",
keyVersion: "1.0.0",
keyUpdates: "✅",
keyChaos: "false",
keyChaosVersion: "abc123",
keyAutoUpdate: "true",
}
}
func TestUpdateAppliesAppsDeployStatus(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", name)})
m.pollingStatus = true
m, _ = send(t, m, 'j') // any key, to prove the poll result lands
updated, _ := m.Update(appsDeployStatusMsg{
statuses: map[string]map[string]string{name: deployedStatus(name)},
})
m = updated.(model)
if m.pollingStatus {
t.Error("expected polling to have stopped")
}
if m.err != nil {
t.Errorf("unexpected error banner: %v", m.err)
}
row := rowFor(t, m.table)
for key, want := range deployedStatus(name) {
if got := row.Data[key]; got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
}
func TestUpdateReportsServerErrors(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", name)})
updated, _ := m.Update(appsDeployStatusMsg{
statuses: map[string]map[string]string{name: deployedStatus(name)},
serverErrors: map[string]error{"broken": errors.New("ssh: no route")},
})
m = updated.(model)
if m.err == nil {
t.Fatal("expected an error banner for an unreachable server")
}
if want := "broken: ssh: no route"; m.err.Error() != want {
t.Errorf("error = %q, want %q", m.err.Error(), want)
}
// The reachable server's status is still rendered alongside the banner.
if got := rowFor(t, m.table).Data[keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
}
func TestUpdateClearsErrorOnKeypress(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", "s")})
m.err = errors.New("something went wrong")
m, _ = send(t, m, 'j')
if m.err != nil {
t.Errorf("expected the banner to be dismissed, got %v", m.err)
}
}
func TestUpdateStartsPolling(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", "s")})
m, cmd := send(t, m, 's')
if !m.pollingStatus {
t.Error("expected polling to have started")
}
if containsQuit(cmd) {
t.Error("pressing s must not quit")
}
// A second s while polling is ignored rather than starting a duplicate.
polling, _ := send(t, m, 's')
if !polling.pollingStatus {
t.Error("expected polling to still be in progress")
}
}
func TestUpdateResizes(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", "s")})
updated, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 4})
m = updated.(model)
if got := m.table.PageSize(); got != 1 {
t.Errorf("page size = %v, want 1 for a 4 row terminal", got)
}
if !strings.Contains(m.table.View(), "a.com") {
t.Errorf("expected the row to survive a resize to 4 rows:\n%v", m.table.View())
}
}
// TestGetApps exercises the local-first data path against a fake ~/.abra.
func TestGetApps(t *testing.T) {
dir := t.TempDir()
originalServers := config.SERVERS_DIR
t.Cleanup(func() { config.SERVERS_DIR = originalServers })
for _, app := range []struct {
server string
name string
body string
}{
{server: "srvB", name: "zebra.com", body: "DOMAIN=zebra.com\nTYPE=recipeZ\n"},
{server: "srvB", name: "alpha.com", body: "DOMAIN=alpha.com\nRECIPE=recipeA\n"},
{server: "srvA", name: "middle.net", body: "DOMAIN=middle.net\nTYPE=recipeA\n"},
} {
serverDir := filepath.Join(dir, "servers", app.server)
if err := os.MkdirAll(serverDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(serverDir, app.name+".env")
if err := os.WriteFile(path, []byte(app.body), 0o644); err != nil {
t.Fatal(err)
}
}
config.SERVERS_DIR = filepath.Join(dir, "servers")
apps, err := getApps()
if err != nil {
t.Fatalf("getApps: %s", err)
}
var domains []string
for _, app := range apps {
domains = append(domains, app.Domain)
}
want := []string{"alpha.com", "middle.net", "zebra.com"}
if !reflect.DeepEqual(domains, want) {
t.Errorf("domains = %v, want %v sorted by name", domains, want)
}
if got, recipes := getNumServersAndRecipes(apps); got != 2 || recipes != 2 {
t.Errorf("got %v servers and %v recipes, want 2 and 2", got, recipes)
}
}
func TestView(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "r", "s")})
m.numFilteredApps = 1
m.numFilteredServers = 1
m.numFilteredRecipes = 1
view := m.View()
for _, want := range []string{"Servers: 1, Apps: 1, Recipes: 1", "a.com", "cctuip " + version} {
if !strings.Contains(view, want) {
t.Errorf("view is missing %q:\n%v", want, view)
}
}
if strings.Contains(view, "⚠") {
t.Errorf("view should have no error banner:\n%v", view)
}
m.err = errors.New("boom")
if !strings.Contains(m.View(), "⚠ boom") {
t.Errorf("view is missing the error banner:\n%v", m.View())
}
m.pollingStatus = true
if !strings.Contains(m.View(), "querying app status") {
t.Errorf("view is missing the polling indicator:\n%v", m.View())
}
}
-1
View File
@@ -10,7 +10,6 @@ require (
github.com/charmbracelet/lipgloss v0.6.0
github.com/docker/docker v20.10.24+incompatible
github.com/evertras/bubble-table v0.15.2
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
golang.org/x/term v0.6.0
)
-2
View File
@@ -1115,8 +1115,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=