Compare commits
3
Commits
e6147a832b
...
26f6122ca2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f6122ca2 | ||
|
|
c4e31f9880 | ||
|
|
371c189226 |
+26
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
name: coopcloud.tech/cctuip
|
||||||
|
steps:
|
||||||
|
- name: make check
|
||||||
|
image: golang:1.20
|
||||||
|
commands:
|
||||||
|
- make check
|
||||||
|
|
||||||
|
- name: make build
|
||||||
|
image: golang:1.20
|
||||||
|
commands:
|
||||||
|
- make build
|
||||||
|
depends_on:
|
||||||
|
- make check
|
||||||
|
|
||||||
|
- name: make test
|
||||||
|
image: golang:1.20
|
||||||
|
commands:
|
||||||
|
- make test
|
||||||
|
depends_on:
|
||||||
|
- make check
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: deps
|
||||||
|
temp: {}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
cctuip
|
cctuip
|
||||||
|
cctuip-dev
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
GOPATH := $(shell go env GOPATH)
|
||||||
|
LDFLAGS := "-X 'main.version=$(VERSION)'"
|
||||||
|
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
|
||||||
|
|
||||||
|
export GOPRIVATE=coopcloud.tech
|
||||||
|
|
||||||
|
all: format check build test
|
||||||
|
|
||||||
|
run:
|
||||||
|
@go run -ldflags=$(LDFLAGS) .
|
||||||
|
|
||||||
|
install:
|
||||||
|
@go install -ldflags=$(LDFLAGS) .
|
||||||
|
|
||||||
|
build-dev:
|
||||||
|
@go build -v -ldflags=$(LDFLAGS) -o cctuip-dev .
|
||||||
|
|
||||||
|
build:
|
||||||
|
@go build -v -ldflags=$(DIST_LDFLAGS) -o cctuip .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -f cctuip cctuip-dev
|
||||||
|
@rm -f '$(GOPATH)/bin/cctuip'
|
||||||
|
|
||||||
|
format:
|
||||||
|
@gofmt -s -w .
|
||||||
|
|
||||||
|
check:
|
||||||
|
@test -z $$(gofmt -s -l .) || \
|
||||||
|
(echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
|
||||||
|
@go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
@go test ./... -cover -v
|
||||||
|
|
||||||
|
loc:
|
||||||
|
@find . -name "*.go" | xargs wc -l
|
||||||
@@ -15,19 +15,58 @@ This is a prototype to gather some feedback.
|
|||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
* `up/down`: scroll up / down (`jk` vim bindings also work)
|
* `up/down`: scroll up / down (`jk` vim bindings also work)
|
||||||
* `page up/down`: ;page up down (`ctrl+u/d` vim binding also work)
|
* `page up/down`: page up / page down (`ctrl+u/d` vim bindings also work)
|
||||||
|
* `home`/`g`: jump to the first row
|
||||||
|
* `end`/`G`: jump to the last row
|
||||||
|
* `shift+left`/`shift+right`: scroll between columns when they overflow
|
||||||
* `/` or `f`: fuzzy filter against domain, server & recipe
|
* `/` or `f`: fuzzy filter against domain, server & recipe
|
||||||
* `enter`: focus on table, select row, confirm
|
* `enter` or `esc`: confirm the filter and return focus to the table (`esc`
|
||||||
* `esc`: reset filter
|
also clears it)
|
||||||
* `q`: quit
|
|
||||||
* `s`: retrieve app deployment status (filter first to reduce scope)
|
* `s`: retrieve app deployment status (filter first to reduce scope)
|
||||||
|
* `q` or `ctrl+c`: quit
|
||||||
|
|
||||||
|
Servers are queried at most five at a time, so a large fleet does not open one
|
||||||
|
SSH connection per server all at once.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
Unreachable servers (e.g. SSH login issues) are skipped and reported in a
|
Unreachable servers (e.g. SSH login issues) are skipped and reported in a
|
||||||
banner; statuses for the remaining servers are still shown. Press any key to
|
banner; statuses for the remaining servers are still shown. The banner shows
|
||||||
dismiss the banner and try again.
|
the first few problems and a count of the rest. Press any key to dismiss it
|
||||||
|
and try again.
|
||||||
|
|
||||||
|
If the recipe catalogue cannot be fetched, the deployment statuses are still
|
||||||
|
rendered and only the `Updates` column is left unknown.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
or, with Go installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
go install coopcloud.tech/cctuip@latest
|
||||||
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```
|
```
|
||||||
go run cctuip.go
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```
|
||||||
|
make check # gofmt and go vet
|
||||||
|
make test # unit tests
|
||||||
|
make format # apply gofmt
|
||||||
|
make build # build ./cctuip
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -20,12 +22,12 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/evertras/bubble-table/table"
|
"github.com/evertras/bubble-table/table"
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is the cctuip version.
|
// version is the cctuip version. This is overridden at build time with
|
||||||
const version = "v0.1.0"
|
// -ldflags "-X 'main.version=$(VERSION)'", see the Makefile.
|
||||||
|
var version = "v0.1.0"
|
||||||
|
|
||||||
// help is the cctuip CLI help output.
|
// help is the cctuip CLI help output.
|
||||||
const help = `cctuip [options]
|
const help = `cctuip [options]
|
||||||
@@ -33,18 +35,62 @@ const help = `cctuip [options]
|
|||||||
cctuip is a Co-op Cloud TUI.
|
cctuip is a Co-op Cloud TUI.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h output help
|
-h, --help output help
|
||||||
-v output version
|
-v, --version output version
|
||||||
`
|
`
|
||||||
|
|
||||||
var helpFlag bool
|
// Table row, column and per-stack status keys. The same keys are used for all
|
||||||
var versionFlag bool
|
// 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.
|
// statusKeys are the columns rendered from the per-stack status maps, in the
|
||||||
func handleCliFlags() {
|
// order they appear in the table.
|
||||||
flag.BoolVar(&helpFlag, "h", false, "output help")
|
var statusKeys = []string{
|
||||||
flag.BoolVar(&versionFlag, "v", false, "output version")
|
keyStatus,
|
||||||
flag.Parse()
|
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.
|
// getApps retrieves app metadata from the abra API.
|
||||||
@@ -66,18 +112,12 @@ func getApps() ([]config.App, error) {
|
|||||||
|
|
||||||
// getNumServersAndRecipes totals servers and recipes.
|
// getNumServersAndRecipes totals servers and recipes.
|
||||||
func getNumServersAndRecipes(apps []config.App) (int, int) {
|
func getNumServersAndRecipes(apps []config.App) (int, int) {
|
||||||
var (
|
servers := make(map[string]struct{})
|
||||||
servers []string
|
recipes := make(map[string]struct{})
|
||||||
recipes []string
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
if !slices.Contains(servers, app.Server) {
|
servers[app.Server] = struct{}{}
|
||||||
servers = append(servers, app.Server)
|
recipes[app.Recipe] = struct{}{}
|
||||||
}
|
|
||||||
if !slices.Contains(recipes, app.Recipe) {
|
|
||||||
recipes = append(recipes, app.Recipe)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(servers), len(recipes)
|
return len(servers), len(recipes)
|
||||||
@@ -91,121 +131,110 @@ type errorMsg struct{ err error }
|
|||||||
type appsDeployStatusMsg struct {
|
type appsDeployStatusMsg struct {
|
||||||
statuses map[string]map[string]string
|
statuses map[string]map[string]string
|
||||||
serverErrors map[string]error
|
serverErrors map[string]error
|
||||||
|
catalogueErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAppsDeployStatus retrieves apps deployment status from the servers they
|
// labelKey builds the Co-op Cloud label key for a stack label suffix.
|
||||||
// are deployed on. Servers which cannot be reached (e.g. SSH login issues) are
|
func labelKey(stackName, suffix string) string {
|
||||||
// skipped and reported via serverErrors so that statuses for the remaining
|
return fmt.Sprintf("coop-cloud.%s.%s", stackName, suffix)
|
||||||
// servers can still be rendered.
|
|
||||||
func getAppsDeployStatus(m model) tea.Msg {
|
|
||||||
var apps []config.App
|
|
||||||
|
|
||||||
for _, row := range m.table.GetVisibleRows() {
|
|
||||||
app, ok := row.Data["app"].(config.App)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
apps = append(apps, app)
|
|
||||||
}
|
|
||||||
|
|
||||||
type serverResult struct {
|
|
||||||
server string
|
|
||||||
status stack.StackStatus
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
servers := make(map[string][]config.App)
|
|
||||||
for _, app := range apps {
|
|
||||||
servers[app.Server] = append(servers[app.Server], app)
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan serverResult, len(servers))
|
|
||||||
for server := range servers {
|
|
||||||
go func(s string) {
|
|
||||||
cl, err := client.New(s)
|
|
||||||
if err != nil {
|
|
||||||
ch <- serverResult{server: s, err: err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch <- serverResult{server: s, status: stack.GetAllDeployedServices(cl, s)}
|
|
||||||
}(server)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
statuses := make(map[string]map[string]string)
|
||||||
serverErrors := make(map[string]error)
|
|
||||||
for range servers {
|
for _, result := range all {
|
||||||
res := <-ch
|
if result.Err != nil {
|
||||||
if res.err != nil {
|
|
||||||
serverErrors[res.server] = res.err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if res.status.Err != nil {
|
|
||||||
serverErrors[res.server] = res.status.Err
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, service := range res.status.Services {
|
for _, service := range result.Services {
|
||||||
result := make(map[string]string)
|
|
||||||
name := service.Spec.Labels[convert.LabelNamespace]
|
name := service.Spec.Labels[convert.LabelNamespace]
|
||||||
|
if name == "" {
|
||||||
if _, ok := statuses[name]; !ok {
|
continue
|
||||||
result["status"] = "deployed"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if chaos, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.chaos", name)]; ok {
|
status, ok := statuses[name]
|
||||||
result["chaos"] = chaos
|
if !ok {
|
||||||
|
status = map[string]string{keyStatus: "deployed"}
|
||||||
|
statuses[name] = status
|
||||||
}
|
}
|
||||||
|
|
||||||
if chaosVersion, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.chaos-version", name)]; ok {
|
if chaos, ok := service.Spec.Labels[labelKey(name, "chaos")]; ok {
|
||||||
result["chaosVersion"] = chaosVersion
|
status[keyChaos] = chaos
|
||||||
}
|
}
|
||||||
|
|
||||||
if autoUpdate, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.autoupdate", name)]; ok {
|
if chaosVersion, ok := service.Spec.Labels[labelKey(name, "chaos-version")]; ok {
|
||||||
result["autoUpdate"] = autoUpdate
|
status[keyChaosVersion] = chaosVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
if autoUpdate, ok := service.Spec.Labels[labelKey(name, "autoupdate")]; ok {
|
||||||
|
status[keyAutoUpdate] = autoUpdate
|
||||||
} else {
|
} else {
|
||||||
result["autoUpdate"] = "false"
|
status[keyAutoUpdate] = "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
version, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.version", name)]
|
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 {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result["version"] = version
|
|
||||||
|
|
||||||
statuses[name] = result
|
version, ok := status[keyVersion]
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
catl, err := recipe.ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
|
||||||
return errorMsg{err}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if !ok {
|
||||||
updates, err = recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
|
continue
|
||||||
if err != nil {
|
|
||||||
return errorMsg{err}
|
|
||||||
}
|
|
||||||
catalogueVersions[app.Recipe] = updates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedVersion, err := tagcmp.Parse(version)
|
parsedVersion, err := tagcmp.Parse(version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
statuses[app.StackName()]["updates"] = "🤷"
|
status[keyUpdates] = unknownStatus
|
||||||
continue
|
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 {
|
for _, update := range updates {
|
||||||
parsedUpdate, err := tagcmp.Parse(update)
|
parsedUpdate, err := tagcmp.Parse(update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
statuses[app.StackName()]["updates"] = "🤷"
|
parseFailed = true
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
||||||
@@ -213,101 +242,239 @@ func getAppsDeployStatus(m model) tea.Msg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(newUpdates) == 0 {
|
switch {
|
||||||
statuses[app.StackName()]["updates"] = "✅"
|
case parseFailed:
|
||||||
} else {
|
status[keyUpdates] = unknownStatus
|
||||||
statuses[app.StackName()]["updates"] = fmt.Sprintf("%v", len(newUpdates))
|
case len(newUpdates) == 0:
|
||||||
}
|
status[keyUpdates] = "✅"
|
||||||
|
default:
|
||||||
|
status[keyUpdates] = fmt.Sprintf("%v", len(newUpdates))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return appsDeployStatusMsg{statuses: statuses, serverErrors: serverErrors}
|
// 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.
|
// renderAppsDeployStatus renders the deployment statuses into the table.
|
||||||
func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model {
|
func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model {
|
||||||
for _, row := range m.table.GetVisibleRows() {
|
for _, row := range m.table.GetVisibleRows() {
|
||||||
app, ok := row.Data["app"].(config.App)
|
app, ok := row.Data[keyApp].(config.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
appStatus := appStatuses.statuses[app.StackName()]
|
|
||||||
var (
|
status := appStatuses.statuses[app.StackName()]
|
||||||
version = appStatus["version"]
|
for _, key := range statusKeys {
|
||||||
updates = appStatus["updates"]
|
row.Data[key] = statusOrUnknown(status, key)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.table
|
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 }
|
type initTableMsg struct{ table table.Model }
|
||||||
|
|
||||||
// initTable loads the table layout from local-first data sources (~/.abra).
|
// newRows builds a table row per app, seeded with unknown values.
|
||||||
func initTable(m model) tea.Msg {
|
func newRows(apps []config.App) []table.Row {
|
||||||
var rows []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{
|
rows = append(rows, table.NewRow(table.RowData{
|
||||||
"domain": app.Domain,
|
keyDomain: app.Domain,
|
||||||
"server": app.Server,
|
keyServer: app.Server,
|
||||||
"recipe": app.Recipe,
|
keyRecipe: app.Recipe,
|
||||||
"status": "🤷",
|
keyStatus: unknownStatus,
|
||||||
"version": "🤷",
|
keyVersion: unknownStatus,
|
||||||
"updates": "🤷",
|
keyUpdates: unknownStatus,
|
||||||
"chaos": "🤷",
|
keyChaos: unknownStatus,
|
||||||
"chaos-version": "🤷",
|
keyChaosVersion: unknownStatus,
|
||||||
"auto-update": "🤷",
|
keyAutoUpdate: unknownStatus,
|
||||||
"app": app, // attach app itself for faster lookups
|
keyApp: app,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
colStyle := lipgloss.NewStyle().Align(lipgloss.Left)
|
return rows
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
width, height, err := term.GetSize(0)
|
// pageSize returns how many table rows fit in the given terminal height.
|
||||||
if err != nil {
|
// bubble-table mis-paginates on a non-positive page size and silently renders
|
||||||
return errorMsg{err}
|
// 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 := table.DefaultKeyMap()
|
||||||
@@ -315,16 +482,24 @@ func initTable(m model) tea.Msg {
|
|||||||
keymap.PageDown = key.NewBinding(key.WithKeys("right", "l", "pgdown", "ctrl+d"))
|
keymap.PageDown = key.NewBinding(key.WithKeys("right", "l", "pgdown", "ctrl+d"))
|
||||||
keymap.PageUp = key.NewBinding(key.WithKeys("left", "h", "pgup", "ctrl+u"))
|
keymap.PageUp = key.NewBinding(key.WithKeys("left", "h", "pgup", "ctrl+u"))
|
||||||
|
|
||||||
t := table.
|
return table.
|
||||||
New(columns).
|
New(columns).
|
||||||
Filtered(true).
|
Filtered(true).
|
||||||
Focused(true).
|
Focused(true).
|
||||||
WithPageSize(height - 10).
|
WithPageSize(pageSize(height)).
|
||||||
WithRows([]table.Row(rows)).
|
WithRows(rows).
|
||||||
WithTargetWidth(width).
|
WithTargetWidth(width).
|
||||||
WithKeyMap(keymap)
|
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.
|
// model is the TUI application state.
|
||||||
@@ -346,23 +521,26 @@ type model struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFilteredApps retrieves all visible apps.
|
// visibleApps retrieves all apps currently visible in the table.
|
||||||
func (m model) getFilteredApps() []config.App {
|
func visibleApps(m *model) []config.App {
|
||||||
var servers []config.App
|
var apps []config.App
|
||||||
|
|
||||||
for _, row := range m.table.GetVisibleRows() {
|
for _, row := range m.table.GetVisibleRows() {
|
||||||
app, ok := row.Data["app"].(config.App)
|
app, ok := row.Data[keyApp].(config.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
servers = append(servers, app)
|
|
||||||
|
apps = append(apps, app)
|
||||||
}
|
}
|
||||||
return servers
|
|
||||||
|
return apps
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCount updates the apps/servers/recipes count.
|
// updateCount updates the apps/servers/recipes count.
|
||||||
func (m *model) updateCount() {
|
func (m *model) updateCount() {
|
||||||
if m.table.GetIsFilterActive() {
|
if m.table.GetIsFilterActive() {
|
||||||
apps := m.getFilteredApps()
|
apps := visibleApps(m)
|
||||||
m.numFilteredApps = len(apps)
|
m.numFilteredApps = len(apps)
|
||||||
m.numFilteredServers, m.numFilteredRecipes = getNumServersAndRecipes(apps)
|
m.numFilteredServers, m.numFilteredRecipes = getNumServersAndRecipes(apps)
|
||||||
} else {
|
} else {
|
||||||
@@ -383,8 +561,8 @@ func (m model) Init() tea.Cmd {
|
|||||||
|
|
||||||
// Update handles updates to the TUI via I/O and the user.
|
// Update handles updates to the TUI via I/O and the user.
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmd tea.Cmd
|
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
m.table, cmd = m.table.Update(msg)
|
m.table, cmd = m.table.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
@@ -400,13 +578,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
m.updateCount()
|
m.updateCount()
|
||||||
|
|
||||||
|
// While the fuzzy filter has focus every keypress belongs to it.
|
||||||
|
if m.table.GetIsFilterInputFocused() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q":
|
case "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "s":
|
case "s":
|
||||||
if !m.table.GetIsFilterInputFocused() && !m.pollingStatus {
|
if !m.pollingStatus {
|
||||||
m.pollingStatus = true
|
m.pollingStatus = true
|
||||||
return m, func() tea.Msg { return getAppsDeployStatus(m) }
|
cmds = append(cmds, func() tea.Msg { return getAppsDeployStatus(m) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case initTableMsg:
|
case initTableMsg:
|
||||||
@@ -414,17 +597,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case appsDeployStatusMsg:
|
case appsDeployStatusMsg:
|
||||||
m.pollingStatus = false
|
m.pollingStatus = false
|
||||||
m.table = renderAppsDeployStatus(&m, msg)
|
m.table = renderAppsDeployStatus(&m, msg)
|
||||||
if len(msg.serverErrors) > 0 {
|
|
||||||
var errors []string
|
if err := summariseProblems(msg); err != nil {
|
||||||
for server, err := range msg.serverErrors {
|
m.err = err
|
||||||
errors = append(errors, fmt.Sprintf("%s: %s", server, err))
|
|
||||||
}
|
|
||||||
sort.Strings(errors)
|
|
||||||
m.err = fmt.Errorf("%s", strings.Join(errors, ", "))
|
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.table = m.table.WithTargetWidth(msg.Width)
|
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:
|
case errorMsg:
|
||||||
m.pollingStatus = false
|
m.pollingStatus = false
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -459,7 +638,18 @@ func (m model) View() string {
|
|||||||
|
|
||||||
// main is the command-line entrypoint.
|
// main is the command-line entrypoint.
|
||||||
func main() {
|
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 {
|
if helpFlag {
|
||||||
fmt.Print(help)
|
fmt.Print(help)
|
||||||
|
|||||||
+836
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ require (
|
|||||||
github.com/charmbracelet/lipgloss v0.6.0
|
github.com/charmbracelet/lipgloss v0.6.0
|
||||||
github.com/docker/docker v20.10.24+incompatible
|
github.com/docker/docker v20.10.24+incompatible
|
||||||
github.com/evertras/bubble-table v0.15.2
|
github.com/evertras/bubble-table v0.15.2
|
||||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
|
|
||||||
golang.org/x/term v0.6.0
|
golang.org/x/term v0.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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-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-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-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-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/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=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
|||||||
Reference in New Issue
Block a user