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