diff --git a/cctuip.go b/cctuip.go index 36d0bcc..332f7c8 100644 --- a/cctuip.go +++ b/cctuip.go @@ -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) diff --git a/cctuip_test.go b/cctuip_test.go new file mode 100644 index 0000000..24e0ce1 --- /dev/null +++ b/cctuip_test.go @@ -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()) + } +} diff --git a/go.mod b/go.mod index 9cdf735..07370a8 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index c5ab8fa..32a51fb 100644 --- a/go.sum +++ b/go.sum @@ -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=