3 Commits
Author SHA1 Message Date
Michael Wesemann 9b0d218174 [mike@mwxm4] 2026-09-07 11:37:41 +02:00
Michael Wesemann 1c14902d7c [mike@mwxm4] 2026-09-06 14:11:46 +02:00
Michael Wesemann 321c69ebaa [mike@mwxm4] 2026-09-06 13:27:00 +02:00
18 changed files with 1755 additions and 482 deletions
+47 -1
View File
@@ -45,7 +45,7 @@ finds it readable by others.
| --- | --- |
| *(nothing)* | browse the machines interactively (see below) |
| `vm` | the same, spelled out |
| `vm -l [-m <re>]` | print them instead; all vCenters unless `-v` names one |
| `vm -l [-m <re>] [--sort <order>] [--reverse]` | print them instead; all vCenters unless `-v` names one |
| `snap -l <vm>` | list a machine's snapshots |
| `snap -n <vm>` | take a snapshot, name printed |
| `snap -r <vm> -s <snap>` | remove one snapshot |
@@ -84,10 +84,52 @@ are the only part anyone scrolls for. A value too long for the width is carried
onto a continuation line under its own label rather than cut off at the edge,
including one long word such as a datastore path, so a narrow terminal loses
nothing. The machine's name, vCenter, datacenter and host are the title.
^o sort the table (see below)
^r ask the servers again
esc clear the filter, or leave when there is none
^c leave
The columns are name, vCenter, power, address, host, vCPUs, CPU load, memory and
memory in use, and the guest's operating system. The two load figures are
percentages of what the machine is allowed to use and of what it has configured;
a machine that is not running has no load rather than a load of zero and shows a
dash. Past 75 % they turn yellow, past 90 % red.
A terminal too narrow for all of that gives columns up, least useful first: the
guest's operating system, then the host, then the address, then the vCPU count —
so what survives longest is what a glance is for. Each step only ever takes a
column away, never brings one back, so dragging a window narrower does not
rearrange the table. An eighty-column terminal keeps everything but the operating
system and the host.
### Sorting
`^o` puts a legend on the status line and the next key picks the order, so the
list stays on screen while it rearranges itself:
sort: n·name p·power c·cpu% m·mem% s·size u·cpus v·vc h·host a·ip r·reverse
Each order comes with its own direction, because that is what asking for it
means: by name is a to z, by processor load is the busiest first. `r` reverses
whatever is current. Anything that is not a choice — Esc, a stray letter — leaves
the table as it was.
The title says which order the table is in (`↓ cpu load`) and the heading of that
column is lit in the same colour, so the state is visible without asking. A
missing value is never a small one: a stopped machine has no load and a machine
whose guest is silent has no address, and both sort to the bottom whichever
direction the order runs. Machines that compare equal stay in name order, so
flipping the direction on a screen full of identical figures does not reshuffle
them.
The order survives `^r`, and the selection follows the machine it was on. `gvm vm
-l --sort cpu% --reverse` takes the same orders by letter or by name.
A vCenter that does not answer is named in the title in red — `on v308, v108
v38 unreachable` — for as long as the list is open, and the reason is on the
status line when it opens. Only the servers whose machines are actually there are
named as holding them.
Nothing acts on a machine from the table. Everything that changes one lives in
the machine's own sheet, which `⏎` opens — a row of a table of two hundred
machines is something the eye runs past, not something anyone has read. In the
@@ -206,6 +248,10 @@ things hold for all of them:
One power operation per command line; two is a mistake, not a sequence, and gvm
says so instead of guessing.
The printed listing (`vm -l`) is the same table: the same columns, the same
cells, the same colours, fitted to the terminal when there is one and written out
in full into a pipe, where the colours are left off.
## Colours
The palette is [mwxcol](https://git.micw.org/mike/mwxcol), copied into
+110
View File
@@ -3,6 +3,7 @@ package main
import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
@@ -644,3 +645,112 @@ func renderToPipe(t *testing.T, b *browser, draw func()) string {
}
return string(out)
}
// A control sequence has to be swallowed whole. Reading a fixed number of bytes
// left the rest of a longer one in the stream, where the next read took it for
// typing: Ctrl-Up put "5A" into the filter and F5 put a tilde in it, having first
// jumped to the top of the list.
func TestEscapeSequencesAreConsumedWhole(t *testing.T) {
for _, c := range []struct {
send string
want specialKey
note string
}{
{"\x1b[A", keyUp, "up"},
{"\x1b[B", keyDown, "down"},
{"\x1b[C", keyRight, "right"},
{"\x1b[D", keyLeft, "left"},
{"\x1b[H", keyHome, "home"},
{"\x1b[F", keyEnd, "end"},
{"\x1b[Z", keyShiftTab, "shift-tab"},
{"\x1b[3~", keyDelete, "delete"},
{"\x1b[5~", keyPgUp, "page up"},
{"\x1b[6~", keyPgDn, "page down"},
// Modified arrows and function keys: not answered, but not leaked either.
{"\x1b[1;5A", keyNone, "ctrl-up"},
{"\x1b[1;2D", keyNone, "shift-left"},
{"\x1b[15~", keyNone, "F5"},
{"\x1b[200~", keyNone, "a bracketed paste opening"},
{"\x1b[<0;10;20M", keyNone, "a mouse report"},
} {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
kr := newKeyReader(r)
w.WriteString(c.send + "x") // an "x" behind it, to see what was left over
if got := kr.next(); got.special != c.want {
t.Errorf("%s (%q): decoded as %v, want %v", c.note, c.send, got.special, c.want)
}
// Whatever the sequence was, the very next key must be the x — nothing of
// the sequence may arrive as text.
next := kr.next()
if next.special != keyRune || next.r != 'x' {
t.Errorf("%s (%q): the next key is %v/%q, want the x — part of the sequence leaked",
c.note, c.send, next.special, next.r)
}
w.Close()
r.Close()
}
}
// A lone Esc is still a lone Esc: it is how every dangerous question is abandoned.
func TestLoneEscapeIsStillEscape(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
kr := newKeyReader(r)
w.WriteString("\x1b")
if got := kr.next(); got.special != keyEsc {
t.Errorf("a lone Esc decoded as %v", got.special)
}
}
// Every vCenter task gvm waits for has to be waited for with a bound. This is a
// property of the source rather than of anything a test can provoke — a task that
// hangs is exactly what no simulator will do — so the source is what is checked.
//
// Taking a snapshot was the one that got away: it waited on the session's own
// context, which has no deadline, and would have frozen the interactive list
// mid-draw with no key being read.
func TestEveryTaskWaitIsBounded(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
found := 0
for _, name := range files {
if strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(string(src), "\n") {
code := strings.TrimSpace(line)
if !strings.Contains(code, ".Wait(") || strings.HasPrefix(code, "//") {
continue
}
if strings.Contains(code, "wg.Wait()") {
continue // a WaitGroup, not a vCenter task
}
found++
// The one place a task may be waited on is inside waitTask, which
// gives it a deadline of its own.
if name != "power.go" || !strings.Contains(code, "task.Wait(wctx)") {
t.Errorf("%s:%d waits on a task outside waitTask: %s", name, i+1, code)
}
}
}
if found == 0 {
t.Error("no task wait found at all — this check has stopped checking anything")
}
}
+278 -66
View File
@@ -68,10 +68,12 @@ var (
colAside = cGrey.fg() // present, seldom read: host, guest os, uuids
colOK = cGreen.fg()
colOff = cDark.fg()
colBusy = cYellow.fg() // filling up
colFull = cRed.fg() // full
)
const (
listHelp = "type to filter ↑/↓ move ⏎ details ^r reload esc clear/quit ^c quit"
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll ^a actions ^s snapshot esc/⏎ back ^c quit"
gutter = 2 // the pointer's two columns, in front of every row
colSep = 2
@@ -89,6 +91,12 @@ type vmRow struct {
vm mo.VirtualMachine
}
// id is what makes this machine this machine. A name does not: two vCenters may
// each hold a "web01", and following the selection by name alone moved the cursor
// to the other server's machine whenever sorting or filtering brought that one up
// first. A reference is unique within its server, so the two together are unique.
func (r vmRow) id() string { return r.vc.Name + "/" + r.ref.Value }
func (r vmRow) power() types.VirtualMachinePowerState { return r.vm.Summary.Runtime.PowerState }
func (r vmRow) powerShort() string {
@@ -147,6 +155,51 @@ func (r vmRow) memory() string {
return units.ByteSize(int64(r.vm.Summary.Config.MemorySizeMB) * 1024 * 1024).String()
}
// cpuLoad is how busy the machine's processors are, as a share of what it is
// allowed to use. A machine that is not running has no load rather than a load of
// zero, and the second return value says which of the two it is — printing 0 for a
// stopped machine would read as "idle" when it means "not there".
func (r vmRow) cpuLoad() (float64, bool) {
limit := r.vm.Summary.Runtime.MaxCpuUsage
if !r.running() || limit <= 0 {
return 0, false
}
return 100.0 / float64(limit) * float64(r.vm.Summary.QuickStats.OverallCpuUsage), true
}
// memLoad is how much of its configured memory the guest is actually working
// with — the figure that says whether a machine is sized right, and the same one
// the sheet shows as "guest".
func (r vmRow) memLoad() (float64, bool) {
size := r.vm.Summary.Config.MemorySizeMB
if !r.running() || size <= 0 {
return 0, false
}
return 100.0 / float64(size) * float64(r.vm.Summary.QuickStats.GuestMemoryUsage), true
}
// loadCell and loadColor turn one of those into a column. Percentages carry no
// sign: the header has it, and four characters are worth more than a symbol
// repeated down the page.
func loadCell(pct float64, known bool) string {
if !known {
return "-"
}
return SF("%.0f", pct)
}
func loadColor(pct float64, known bool) string {
switch {
case !known:
return colOff
case pct >= 90:
return colFull
case pct >= 75:
return colBusy
}
return colSize
}
// haystack is what the filter matches against: everything on the line, so typing
// an address or a host name narrows the list just as well as a name does.
func (r vmRow) haystack() string {
@@ -163,6 +216,16 @@ type browseColumn struct {
flex bool // may grow into whatever width is left over
cell func(vmRow) string // the text
color func(vmRow) string // its colour, nil to leave it the row's own
// expendable is the order in which columns are given up on a terminal too
// narrow for all of them: 1 goes first. Zero means never — the name and the
// power state are what a list of machines is for.
//
// It is deliberately separate from the order the columns are shown in. The
// two used to be the same thing, so what a narrow terminal lost was simply
// whatever happened to be listed last, and the memory figures went before the
// guest's operating system did.
expendable int
}
// browseColumns in display order, which is also priority order: as many as fit
@@ -170,33 +233,68 @@ type browseColumn struct {
func fixed(c string) func(vmRow) string { return func(vmRow) string { return c } }
var browseColumns = []browseColumn{
{header: "NAME", width: 24, flex: true, color: fixed(colName),
// 22 is the name's minimum, not its width: it grows into whatever is left
// over. One notch narrower than it reads, because it buys the address column
// its place on a terminal of eighty.
{header: "NAME", width: 22, flex: true, color: fixed(colName),
cell: func(r vmRow) string { return r.name }},
{header: "VC", width: 4, color: fixed(colWhere),
{header: "VC", width: 4, color: fixed(colWhere), expendable: 8,
cell: func(r vmRow) string { return r.vc.Name }},
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor},
{header: "HOST", width: 10, color: fixed(colAside),
// The address is the widest thing here that is not a name, and it is a
// lookup field: on a terminal this narrow nobody is looking an address up,
// they are glancing at what is busy. So it goes before the small figures do.
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 3},
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
cell: func(r vmRow) string { return r.host }},
{header: "CPU", width: 3, color: fixed(colSize),
{header: "CPU", width: 3, color: fixed(colSize), expendable: 4,
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize)},
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside)},
{header: "CPU%", width: 4, expendable: 7,
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 5},
{header: "MEM%", width: 4, expendable: 6,
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
expendable: 1},
}
// fitColumns takes columns from the left while they fit, then hands the width
// left over to the ones allowed to grow.
// fitColumns decides which columns a terminal of this width shows: give up the
// least useful until what is left fits, then hand the width left over to the ones
// allowed to grow. The columns keep the order they are declared in; what changes
// is which of them are there.
//
// Giving up strictly in the declared order of expendability is what makes the
// layout behave when a terminal is dragged narrower: each step is a subset of the
// one before, so columns only ever disappear. Taking a dropped column back
// because the space freed by a later one now fits it — which sounds like an
// improvement — makes a narrower terminal show *more* columns than a wider one,
// and a column that appears as the window shrinks is worse than a column that is
// simply gone. Where a column deserves to survive longer, its rank says so.
func fitColumns(width int) []browseColumn {
var chosen []browseColumn
used := 0
for _, c := range browseColumns {
if used+c.width+colSep > width {
break
}
chosen = append(chosen, c)
used += c.width + colSep
shown := make([]bool, len(browseColumns))
for i := range shown {
shown[i] = true
}
if len(chosen) == 0 { // a very narrow terminal still gets the name
for shownWidth(shown) > width {
i := leastUseful(shown)
if i < 0 {
break // nothing left that may be given up
}
shown[i] = false
}
chosen := make([]browseColumn, 0, len(browseColumns))
for i, c := range browseColumns {
if shown[i] {
chosen = append(chosen, c)
}
}
// A terminal too narrow even for what may not be given up: the name, cut to
// whatever there is. Better one column of truth than a row that wraps.
if tableWidth(chosen) > width {
first := browseColumns[0]
first.width = max(width, 1)
return []browseColumn{first}
@@ -209,7 +307,7 @@ func fitColumns(width int) []browseColumn {
}
}
if flex > 0 {
if extra := (width - used) / flex; extra > 0 {
if extra := (width - tableWidth(chosen)) / flex; extra > 0 {
for i := range chosen {
if chosen[i].flex {
chosen[i].width += extra
@@ -220,12 +318,72 @@ func fitColumns(width int) []browseColumn {
return chosen
}
// tableWidth is what these columns occupy: their own widths plus one gap between
// each pair. No trailing gap — the last column ends the line.
func tableWidth(cs []browseColumn) int {
if len(cs) == 0 {
return 0
}
w := colSep * (len(cs) - 1)
for _, c := range cs {
w += c.width
}
return w
}
func shownWidth(shown []bool) int {
n, w := 0, 0
for i, on := range shown {
if on {
n++
w += browseColumns[i].width
}
}
if n == 0 {
return 0
}
return w + colSep*(n-1)
}
// leastUseful is the column to give up next, or -1 when every one that is left
// may not be.
func leastUseful(shown []bool) int {
at, worst := -1, 0
for i, on := range shown {
e := browseColumns[i].expendable
if !on || e == 0 {
continue
}
if at < 0 || e < worst {
at, worst = i, e
}
}
return at
}
// -------------------------------------------------------------------- reading
// gatherVMs asks every server at once and returns the machines of all of them,
// along with the sessions they were read over — the caller owns those and closes
// them. A server that does not answer costs a warning, not the listing.
func gatherVMs(targets []VCenter) ([]vmRow, []*session, error) {
// sweep is what one pass over the configured servers found: the machines, the
// connections they were read over, which servers answered, and which did not and
// why.
//
// The failures used to be printed on the spot and forgotten. In the printed list
// that was fine; in the interactive one the message was wiped by the first screen
// a moment later, and the title went on naming the server as though its machines
// were in the list. A vCenter that is down has to be visible in the thing that is
// meant to show every vCenter — so the sweep hands its failures back and lets the
// caller show them where they will be read.
type sweep struct {
rows []vmRow
sessions []*session
answered []string // the servers that did
failed []string // the ones that did not, each with its reason
lost []string // their names alone, for the title
}
// gatherVMs asks every server at once and returns what came back. A server that
// does not answer costs a line in the report, not the listing.
func gatherVMs(targets []VCenter) (sweep, error) {
type result struct {
rows []vmRow
sess *session
@@ -243,32 +401,31 @@ func gatherVMs(targets []VCenter) ([]vmRow, []*session, error) {
}
wg.Wait()
var rows []vmRow
var sessions []*session
failed := 0
var s sweep
for i := range res {
if res[i].sess != nil {
sessions = append(sessions, res[i].sess)
s.sessions = append(s.sessions, res[i].sess)
}
if res[i].err != nil {
PE(res[i].err.Error())
failed++
s.failed = append(s.failed, res[i].err.Error())
s.lost = append(s.lost, targets[i].Name)
continue
}
rows = append(rows, res[i].rows...)
s.answered = append(s.answered, targets[i].Name)
s.rows = append(s.rows, res[i].rows...)
}
if failed == len(targets) {
return nil, sessions, errf("no vCenter answered")
if len(s.answered) == 0 {
return s, errf("no vCenter answered")
}
sort.Slice(rows, func(a, b int) bool {
x, y := strings.ToLower(rows[a].name), strings.ToLower(rows[b].name)
sort.Slice(s.rows, func(a, b int) bool {
x, y := strings.ToLower(s.rows[a].name), strings.ToLower(s.rows[b].name)
if x != y {
return x < y
}
return rows[a].vc.Name < rows[b].vc.Name
return s.rows[a].vc.Name < s.rows[b].vc.Name
})
return rows, sessions, nil
return s, nil
}
// gatherOne is one server: the machines, and the names of the hosts their
@@ -328,6 +485,12 @@ type browser struct {
sel int // index into view
scroll int
sortBy int // which of sortOrders the rows are in (sort.go)
sortDesc bool // that order reversed
answered []string // the vCenters this list actually holds
lost []string // and the ones it does not, because they did not answer
detail []sheetLine // non-nil while a machine's sheet is on screen
dtitle string
dvc string // the vCenter, in the title, in the colour its column has
@@ -348,7 +511,7 @@ type browser struct {
status string
statusCol string
prompt string // a question waiting for one key, in place of the status
prompt *prompt // a question waiting for one key, in place of the status
edit *editor // a line being typed there, same place
tty *os.File
@@ -370,12 +533,16 @@ func browseVMs(targets []VCenter, filter string) error {
defer func() { closeSessions(b.sessions) }()
PF("asking %s ...\n", vcNames(targets))
rows, sessions, err := gatherVMs(targets)
b.rows, b.sessions = rows, sessions
found, err := gatherVMs(targets)
b.rows, b.sessions = found.rows, found.sessions
b.answered, b.lost = found.answered, found.lost
if err != nil {
for _, why := range found.failed {
PE(why)
}
return err
}
if len(rows) == 0 {
if len(found.rows) == 0 && len(found.failed) == 0 {
return errf("no virtual machines found")
}
@@ -384,7 +551,14 @@ func browseVMs(targets []VCenter, filter string) error {
}
defer b.close()
b.refilter()
b.applySort() // by name, ascending: sortOrders lists that one first
// Said once, before the first keystroke clears it. The title keeps the short
// version for as long as the list is open.
if len(found.failed) > 0 {
b.setStatus(colErr, strings.Join(found.failed, " · "))
}
b.loop()
return nil
}
@@ -491,6 +665,8 @@ func (b *browser) listKey(k key) bool {
b.move(len(b.view))
case keyEnter:
b.openDetail()
case keyCtrlO:
b.sortPrompt()
case keyCtrlA, keyCtrlS:
// Nothing acts on a machine from the list. The cursor sits on a row that
// is one line of a table, and a table of two hundred machines is read by
@@ -552,7 +728,7 @@ func (b *browser) detailKey(k key) {
func (b *browser) refilter() {
keep := ""
if cur := b.current(); cur != nil {
keep = cur.name
keep = cur.id()
}
needle := strings.ToLower(strings.TrimSpace(b.filter))
@@ -566,7 +742,7 @@ func (b *browser) refilter() {
b.sel, b.scroll = 0, 0
if keep != "" {
for i, idx := range b.view {
if b.rows[idx].name == keep {
if b.rows[idx].id() == keep {
b.sel = i
break
}
@@ -594,17 +770,23 @@ func (b *browser) reload() {
b.setStatus(colWarn, "reloading ...")
b.render()
rows, sessions, err := gatherVMs(b.targets)
found, err := gatherVMs(b.targets)
if err != nil {
closeSessions(sessions)
closeSessions(found.sessions)
b.setStatus(colErr, err.Error())
return
}
old := b.sessions
b.rows, b.sessions = rows, sessions
b.refilter()
b.rows, b.sessions = found.rows, found.sessions
b.answered, b.lost = found.answered, found.lost
b.applySort() // which refilters; the order the table was in survives a reload
closeSessions(old)
b.setStatus(colInfo, SF("reloaded: %d machines", len(rows)))
if len(found.failed) > 0 {
b.setStatus(colErr, strings.Join(found.failed, " · "))
return
}
b.setStatus(colInfo, SF("reloaded: %d machines", len(found.rows)))
}
// ------------------------------------------------------------------ snapshots
@@ -749,13 +931,24 @@ func (b *browser) input(label string) (string, bool) {
}
}
// prompt is a question waiting for a single key, shown where the status line
// would be. The hint and the colour travel with the question rather than being
// assumed by the renderer: the sort legend is also a one-key question, and it is
// neither a warning nor answerable with y — which is exactly what the renderer
// used to append to it.
type prompt struct {
text string
hint string
col string
}
// ask puts one question on the status line and waits for a single key. Only "y"
// means yes — every other key, Esc and Ctrl-C included, means no.
func (b *browser) ask(question string) bool {
b.prompt = question
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
b.render()
k := b.keys.next()
b.prompt = ""
b.prompt = nil
return k.special == keyRune && (k.r == 'y' || k.r == 'Y')
}
@@ -800,12 +993,25 @@ func (b *browser) renderList() {
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
segLine(&sb, cols,
seg{colTitle, "gvm " + version},
// Only the servers whose machines are actually in the list are named as
// holding them; the ones that did not answer are named as missing, in red, for
// as long as the list is open.
title := []seg{
{colTitle, "gvm " + version},
{colDim, " "},
{colInfo, SF("%d machines", len(b.rows))},
{colDim, " on "},
{colWhere, strings.Join(b.answered, ", ")}, // the colour the VC column has
}
if len(b.lost) > 0 {
title = append(title,
seg{colDim, " "},
seg{colErr, strings.Join(b.lost, ", ") + " unreachable"})
}
title = append(title,
seg{colDim, " "},
seg{colInfo, SF("%d machines", len(b.rows))},
seg{colDim, " on "},
seg{colWhere, vcNames(b.targets)}) // the colour the VC column has
seg{colMatch, b.sortLabel()}) // pink: the one thing here that was chosen
segLine(&sb, cols, title...)
if b.filter == "" {
segLine(&sb, cols, seg{colDim, "filter: (type to narrow the list)"})
@@ -818,12 +1024,18 @@ func (b *browser) renderList() {
seg{colInfo, SF("%d of %d", len(b.view), len(b.rows))})
}
header := make([]string, len(cs))
headings := []seg{{colHeader, strings.Repeat(" ", gutter)}}
for i, c := range cs {
header[i] = padRight(truncate(c.header, c.width), c.width)
if i > 0 {
headings = append(headings, seg{colHeader, strings.Repeat(" ", colSep)})
}
col := colHeader
if b.sortedColumn(c.header) {
col = colMatch // the same pink the title uses for the order
}
headings = append(headings, seg{col, padRight(truncate(c.header, c.width), c.width)})
}
segLine(&sb, cols, seg{colHeader, strings.Repeat(" ", gutter) +
strings.Join(header, strings.Repeat(" ", colSep))})
segLine(&sb, cols, headings...)
for i := b.scroll; i < end; i++ {
b.rowLine(&sb, cols, cs, b.rows[b.view[i]], i == b.sel)
@@ -835,10 +1047,10 @@ func (b *browser) renderList() {
switch {
case b.edit != nil:
b.editLine(&sb, cols)
case b.prompt != "":
case b.prompt != nil:
segLine(&sb, cols,
seg{colWarn, b.prompt},
seg{colDim, " y = yes, anything else = no"})
seg{b.prompt.col, b.prompt.text},
seg{colDim, b.prompt.hint})
case b.status != "":
segLine(&sb, cols, seg{b.statusCol, b.status})
case len(b.view) == 0:
@@ -990,10 +1202,10 @@ func (b *browser) renderDetail() {
switch {
case b.edit != nil:
b.editLine(&sb, cols)
case b.prompt != "":
case b.prompt != nil:
segLine(&sb, cols,
seg{colWarn, b.prompt},
seg{colDim, " y = yes, anything else = no"})
seg{b.prompt.col, b.prompt.text},
seg{colDim, b.prompt.hint})
case b.status != "":
segLine(&sb, cols, seg{b.statusCol, b.status})
case len(lines)-end > 0:
+385 -6
View File
@@ -1,10 +1,14 @@
package main
import (
"io"
"os"
"slices"
"strings"
"testing"
"time"
"github.com/fatih/color"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
@@ -57,7 +61,12 @@ func testRow(name string, on bool, ip string) vmRow {
func testBrowser(names ...string) *browser {
b := &browser{targets: []VCenter{{Name: "v308"}}}
for i, n := range names {
b.rows = append(b.rows, testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1)))
r := testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1))
// A reference of its own for each: testRow hands out one fixed vm-42,
// which is fine for a machine on its own and useless for a list, where
// the selection follows a machine by its reference.
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + Itoa(i)}
b.rows = append(b.rows, r)
}
b.refilter()
return b
@@ -384,16 +393,28 @@ func TestHighlight(t *testing.T) {
}
}
// stripEscapes removes every control sequence, not only the colours. It used to
// stop at the first "m", which ends a colour and nothing else — so on a whole
// frame it ran past the "\x1b[K" that ends each line, swallowed the line break
// with it, and handed back one very long line.
//
// A sequence is ESC [ then parameters then one final byte in 0x40..0x7e, which is
// what "m" (colour), "K" (clear to end of line) and "H" (move the cursor) are.
func stripEscapes(s string) string {
var out strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == 0x1b {
for i < len(s) && s[i] != 'm' {
i++
}
if s[i] != 0x1b {
out.WriteByte(s[i])
continue
}
out.WriteByte(s[i])
i++
if i < len(s) && s[i] == '[' {
i++ // the bracket is itself in the final-byte range, so step over it
}
for i < len(s) && (s[i] < 0x40 || s[i] > 0x7e) {
i++
}
// i is on the final byte; the loop's own i++ moves past it
}
return out.String()
}
@@ -746,3 +767,361 @@ func TestSheetValuesAreColouredAndWrapKeepsIt(t *testing.T) {
t.Fatal("the sheet has no path line")
}
}
// What a narrow terminal gives up, and in what order. The guest's operating
// system goes first; the name and the power state never go at all.
func TestNarrowTerminalGivesUpTheRightColumns(t *testing.T) {
headers := func(cs []browseColumn) []string {
out := make([]string, len(cs))
for i, c := range cs {
out[i] = c.header
}
return out
}
has := func(cs []browseColumn, header string) bool {
return slices.Contains(headers(cs), header)
}
// Everything fits somewhere wide.
wide := fitColumns(200)
if len(wide) != len(browseColumns) {
t.Fatalf("a wide terminal shows %v, not all of them", headers(wide))
}
// The first thing to go is the guest's operating system, before any figure.
oneLess := fitColumns(tableWidth(browseColumns) - 1)
if has(oneLess, "GUEST OS") {
t.Errorf("the first column given up was not GUEST OS: %v", headers(oneLess))
}
if len(oneLess) != len(browseColumns)-1 {
t.Errorf("more than one column was given up: %v", headers(oneLess))
}
for _, keep := range []string{"CPU%", "MEM%", "MEM", "CPU", "IP", "HOST"} {
if !has(oneLess, keep) {
t.Errorf("%s was given up before GUEST OS: %v", keep, headers(oneLess))
}
}
// The set only ever shrinks as the terminal narrows, and it shrinks in the
// order declared.
prev := len(browseColumns)
for width := 200; width >= 10; width-- {
cs := fitColumns(width)
if len(cs) > prev {
t.Fatalf("%d columns wide shows %d columns, wider showed %d", width, len(cs), prev)
}
prev = len(cs)
if tableWidth(cs) > width {
t.Errorf("%d columns wide needs %d", width, tableWidth(cs))
}
if !has(cs, "NAME") {
t.Errorf("%d columns wide has no NAME: %v", width, headers(cs))
}
// Below the point where even the mandatory columns fit, the name alone
// stands; above it the power state is always there.
if len(cs) > 1 && !has(cs, "PWR") {
t.Errorf("%d columns wide gave up PWR: %v", width, headers(cs))
}
// A percentage is never shown without the header that says it is one.
for _, c := range cs {
if c.header == "CPU%" || c.header == "MEM%" {
if c.width < 4 {
t.Errorf("%d columns wide: %s is only %d wide, too narrow for 100", width, c.header, c.width)
}
}
}
}
}
// The load figures themselves: a running machine has one, a stopped machine has
// none, and none is not zero.
func TestLoadCells(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
// 420 of 8400 MHz = 5 %, and 4096 of 8192 MB = 50 %.
if got := loadCell(r.cpuLoad()); got != "5" {
t.Errorf("cpu load reads %q, want 5", got)
}
if got := loadCell(r.memLoad()); got != "50" {
t.Errorf("memory load reads %q, want 50", got)
}
off := r
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
for _, got := range []string{loadCell(off.cpuLoad()), loadCell(off.memLoad())} {
if got != "-" {
t.Errorf("a stopped machine reports a load of %q, want a dash", got)
}
}
if loadColor(off.cpuLoad()) != colOff {
t.Error("the dash is not in the disabled colour")
}
// No denominator is also no load, however the machine claims to be running.
noLimit := r
noLimit.vm.Summary.Runtime.MaxCpuUsage = 0
if got := loadCell(noLimit.cpuLoad()); got != "-" {
t.Errorf("with no cpu limit the load reads %q, want a dash", got)
}
noMem := r
noMem.vm.Summary.Config.MemorySizeMB = 0
if got := loadCell(noMem.memLoad()); got != "-" {
t.Errorf("with no configured memory the load reads %q, want a dash", got)
}
}
// A machine under pressure has to be visible without reading the number.
func TestLoadColoursRiseWithTheLoad(t *testing.T) {
for _, c := range []struct {
pct float64
want string
note string
}{
{0, colSize, "idle"},
{74, colSize, "busy but not worth a colour"},
{75, colBusy, "filling up"},
{89, colBusy, "still filling up"},
{90, colFull, "full"},
{100, colFull, "quite full"},
{143, colFull, "over its share"},
} {
if got := loadColor(c.pct, true); got != c.want {
t.Errorf("%.0f %% (%s) is coloured %q, want %q", c.pct, c.note, got, c.want)
}
}
// Both thresholds are palette colours, and neither is the ordinary one.
if colBusy == colSize || colFull == colSize || colBusy == colFull {
t.Error("the three load colours are not three different colours")
}
}
// The ladder of layouts, written out. Dragging a terminal narrower may only ever
// take columns away — a column that appears as the window shrinks is worse than
// one that is simply gone — and this is where that is pinned down, together with
// the width at which each one goes.
func TestColumnLadder(t *testing.T) {
at := func(width int) string {
var h []string
for _, c := range fitColumns(width) {
h = append(h, c.header)
}
return strings.Join(h, " ")
}
// The widths are what fitColumns is handed, which is the terminal less the two
// columns the pointer occupies: 78 here is a terminal of eighty. Both ends of
// each rung are given, so a width change moves the boundary and not only the
// example.
const (
all = "NAME VC PWR IP HOST CPU CPU% MEM MEM% GUEST OS"
nine = "NAME VC PWR IP HOST CPU CPU% MEM MEM%"
eight = "NAME VC PWR IP CPU CPU% MEM MEM%"
seven = "NAME VC PWR CPU CPU% MEM MEM%"
six = "NAME VC PWR CPU% MEM MEM%"
five = "NAME VC PWR CPU% MEM%"
four = "NAME VC PWR CPU%"
three = "NAME VC PWR"
two = "NAME PWR"
one = "NAME"
)
for _, c := range []struct {
width int
want string
}{
{200, all}, {109, all},
{108, nine}, {89, nine},
{88, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
{76, seven}, {60, seven},
{59, six}, {55, six},
{54, five}, {46, five},
{45, four}, {40, four},
{39, three}, {34, three},
{33, two}, {28, two},
{27, one}, {10, one},
} {
if got := at(c.width); got != c.want {
t.Errorf("%d columns wide shows\n %s\nwant\n %s", c.width, got, c.want)
}
}
// And the sets form a chain: each one is contained in the next wider one.
var wider []string
for width := 10; width <= 200; width++ {
var here []string
for _, c := range fitColumns(width) {
here = append(here, c.header)
}
for _, h := range wider {
if !slices.Contains(here, h) && h != "NAME" {
t.Fatalf("%d columns wide lost %s, which a narrower terminal showed: %v", width, h, here)
}
}
wider = here
}
}
// The printed table and the interactive one come from one definition, so a value
// cannot be spelled two ways. It used to show a bare 32 where the list showed
// 32.0MB, and had neither the power state, the host, nor the load figures.
func TestPrintedListMatchesTheInteractiveOne(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
for _, c := range fitColumns(200) {
want := c.cell(r)
// The same cell function feeds both, so this is really a check that the
// printed table takes its cells from here rather than formatting its own.
if want == "" && c.header != "IP" {
t.Errorf("the %s column has nothing to print", c.header)
}
}
// The things the printed listing used to be missing.
var headers []string
for _, c := range fitColumns(200) {
headers = append(headers, c.header)
}
for _, want := range []string{"PWR", "HOST", "CPU%", "MEM%", "MEM"} {
if !slices.Contains(headers, want) {
t.Errorf("the shared table has no %s column", want)
}
}
// Sizes are spelled as sizes in both.
var mem browseColumn
for _, c := range browseColumns {
if c.header == "MEM" {
mem = c
}
}
if got := mem.cell(r); got != "8.0GB" {
t.Errorf("the memory column reads %q, want 8.0GB and not a bare number", got)
}
}
// Padding happens before colouring, or the escapes are counted as columns — which
// is what made `gvm host` shift a line three columns for a longer hostname.
func TestPrintedCellsArePaddedNotColoured(t *testing.T) {
for _, c := range []struct {
text string
width int
right bool
want string
}{
{"esx1", 8, false, "esx1 "},
{"esx1", 8, true, " esx1"},
{"1.46", 6, true, " 1.46"},
{"DC0_C0_H0", 6, false, "DC0_C…"}, // too long: cut, never overflowing
{"", 4, false, " "},
} {
got := pad(c.text, c.width, c.right)
if got != c.want {
t.Errorf("pad(%q, %d, %v) = %q, want %q", c.text, c.width, c.right, got, c.want)
}
if len([]rune(got)) != c.width {
t.Errorf("pad(%q, %d) gave %d columns", c.text, c.width, len([]rune(got)))
}
}
// A coloured value still occupies exactly its column: the escapes go on after
// the padding, never before it.
painted := paint(pad("esx1", 8, false), cWhite.fg())
if len([]rune(stripEscapes(painted))) != 8 {
t.Errorf("a coloured cell is %d columns wide", len([]rune(stripEscapes(painted))))
}
}
// Into a pipe nothing may be cut. Fitting the printed table to the interactive
// minimum width truncated machine names at twenty-two characters, silently and
// with an ellipsis, in output whose whole purpose is to be read by something
// else.
func TestPipedListingCutsNothing(t *testing.T) {
t.Setenv("NO_COLOR", "1") // read by fatih/color at init, so also forced below
noColor := color.NoColor
color.NoColor = true
defer func() { color.NoColor = noColor }()
long := "a-machine-with-a-name-of-forty-three-chars"
rows := []vmRow{
testRow(long, true, "10.0.0.5"),
testRow("web01", true, "10.0.0.6"),
}
out := captureStdout(t, func() { printList(rows) })
if !strings.Contains(out, long) {
t.Errorf("the long name was cut:\n%s", out)
}
if strings.Contains(out, "…") {
t.Errorf("something was truncated:\n%s", out)
}
// Every column still lines up: the widths grew, they did not go away.
var widths []int
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
widths = append(widths, len([]rune(line)))
}
for i := 1; i < len(widths); i++ {
if widths[i] > widths[0]+2 && widths[0] < len(long) {
t.Errorf("line %d is %d wide, the heading %d — the columns do not line up",
i, widths[i], widths[0])
}
}
}
// captureStdout runs f with stdout on a pipe and hands back what it wrote.
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
saved := os.Stdout
os.Stdout = w
done := make(chan string, 1)
go func() {
out, _ := io.ReadAll(r)
done <- string(out)
}()
f()
w.Close()
os.Stdout = saved
return <-done
}
// The selection follows the machine, not its name: two vCenters may each hold a
// "web01", and following the name alone moved the cursor to the other server's
// machine whenever sorting brought that one up first.
func TestSelectionFollowsTheMachineNotTheName(t *testing.T) {
same := func(vc, moref string, on bool) vmRow {
r := testRow("web01", on, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: moref}
return r
}
b := &browser{rows: []vmRow{same("v308", "vm-1", true), same("v108", "vm-9", false)}}
b.refilter()
b.move(1) // the second one: same name, other vCenter
cur := b.current()
if cur.vc.Name != "v108" {
t.Fatalf("the cursor is on %s, expected v108", cur.vc.Name)
}
if cur.id() == b.rows[0].id() {
t.Fatal("two machines on two servers share an identity")
}
// Sorting by power puts the running one first; the cursor must stay on the
// stopped machine it was on, not jump to the namesake now under it.
for i, o := range sortOrders {
if o.key == 'p' {
b.sortBy, b.sortDesc = i, o.natural
}
}
b.applySort()
if got := b.current(); got.vc.Name != "v108" {
t.Errorf("after sorting the cursor sits on %s/%s, want the v108 machine",
got.vc.Name, got.name)
}
}
-4
View File
@@ -4,10 +4,8 @@ go 1.26.1
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/eknkc/basex v1.0.1
github.com/fatih/color v1.19.0
github.com/integrii/flaggy v1.8.0
github.com/tidwall/gjson v1.18.0
github.com/vmware/govmomi v0.53.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)
@@ -18,8 +16,6 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.34.0 // indirect
-8
View File
@@ -7,8 +7,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY=
github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -35,12 +33,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/vmware/govmomi v0.53.0 h1:e1bZCotAq7wm4xy95ePN2uoWwz28pNp/ewZZhpBY7/4=
github.com/vmware/govmomi v0.53.0/go.mod h1:EWfuzPfxT5NV+aS2we02SLFdhvJkgeY7t7+TszgBSMY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+5 -1
View File
@@ -81,6 +81,10 @@ func run() error {
subVM.Bool(&vmList, "l", "list", "Print the machines instead of browsing them")
subVM.Bool(&vmInteractive, "i", "interactive", "Browse the machines (the default)")
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
var vmSort string
var vmReverse bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, power, cpu%, mem%, size, cpus, vc, host, ip")
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
// The destructive options deliberately have no short letter: --revert and
// --removeall have to be spelled out, so neither can be reached by a slip of
@@ -139,7 +143,7 @@ func run() error {
return err
}
if vmList { // -l prints; anything else browses
return lsvm(targets, vmMatch)
return lsvm(targets, vmMatch, vmSort, vmReverse)
}
return browseVMs(targets, vmMatch)
+32 -46
View File
@@ -35,40 +35,36 @@ func hoststat(vc VCenter, telemetry string) error {
return err
}
P()
P("Name CPU Used Mem Total Mem VM ON Status Connected")
printRow(hostColumns, "", nil) // the heading, from the same widths as the rows
for _, host := range hosts {
hn := shortHost(host.Name)
total := len(host.Vm)
on := countOn(host.Vm, running)
var usedMem, totalMem int64
usedMem = int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
cpustr := " -"
usedMem := int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
totalMem := int64(0)
cpu, cpuKnown := 0.0, false
if hw := host.Summary.Hardware; hw != nil {
totalMem = hw.MemorySize
if cpu, ok := cpuPercent(hw, host.Summary.QuickStats); ok {
cpustr = colorPercent(cpu)
}
cpu, cpuKnown = cpuPercent(hw, host.Summary.QuickStats)
}
cpuText, cpuCol := "-", colOff
if cpuKnown {
cpuText, cpuCol = SF("%.2f", cpu), loadColor(cpu, true)
}
PF("%4s %s %8s %8s %3d %3d %6s %s\n",
Cwb(hn),
cpustr,
units.ByteSize(usedMem),
units.ByteSize(totalMem),
total,
on,
colorStatus(host.OverallStatus),
host.Runtime.ConnectionState,
)
printRow(hostColumns, "", []cell{
{hn, cWhite.fg()},
{cpuText, cpuCol},
{units.ByteSize(usedMem).String(), colSize},
{units.ByteSize(totalMem).String(), colSize},
{Itoa(total), colSize},
{Itoa(on), colSize},
{string(host.OverallStatus), statusColor(host.OverallStatus)},
{string(host.Runtime.ConnectionState), colAside},
})
if telemetry != "" {
cpu := 0.0
if hw := host.Summary.Hardware; hw != nil {
cpu, _ = cpuPercent(hw, host.Summary.QuickStats)
}
post(telemetry, SF("vm,%s,%.2f,%d,%d,%d,%d,%s,%s",
hn, cpu, usedMem, totalMem, total, on,
host.OverallStatus, host.Runtime.ConnectionState))
@@ -95,10 +91,13 @@ func vmstat(vc VCenter) error {
return err
}
PF("%-25s %-10s %-10s\n", "Host", "Total VMs", "PoweredOn")
P(SR("-", 47))
printRow(countColumns, "", nil)
for _, host := range hosts {
PF("%-25s %-10d %-10d\n", host.Name, len(host.Vm), countOn(host.Vm, running))
printRow(countColumns, "", []cell{
{host.Name, cWhite.fg()},
{Itoa(len(host.Vm)), colSize},
{Itoa(countOn(host.Vm, running)), colSize},
})
}
return nil
}
@@ -143,30 +142,17 @@ func cpuPercent(hw *types.HostHardwareSummary, qs types.HostListSummaryQuickStat
return 100.0 / float64(totalMHz) * float64(qs.OverallCpuUsage), true
}
func colorPercent(p float64) string {
s := SF("%6.2f", p)
switch {
case p > 90:
return Crb(s)
case p > 50:
return Cyb(s)
case p > 2:
return Cwb(s)
}
return s
}
func colorStatus(st types.ManagedEntityStatus) string {
s := string(st)
switch s {
// statusColor: vSphere's own words for how a host is doing, in the palette's.
func statusColor(st types.ManagedEntityStatus) string {
switch string(st) {
case "green":
return Cgb(s)
return colOK
case "yellow":
return Cyb(s)
return colBusy
case "red":
return Crb(s)
return colFull
}
return s
return colAside
}
// shortHost is the hostname without its domain, which is all the first column
+3 -2
View File
@@ -40,8 +40,9 @@ func vmlog(cfg Config, vc VCenter, minutes int, mail bool) error {
return fmt.Errorf("%s: cannot read the event log: %w", vc.Name, err)
}
PF("%-20s | %-15s | %s\n", "Time", "Severity", "Message")
P(SR("-", 80))
// No heading and no rule: the lines below are comma-separated on purpose —
// they are what the mail carries and what a script would cut up — and a
// pipe-separated heading above them promised a table that never came.
msg := ""
for _, e := range events {
+107
View File
@@ -0,0 +1,107 @@
// print.go — columns for the commands that print rather than draw.
//
// The one rule: pad first, colour afterwards. A colour is a handful of escape
// bytes that occupy no columns on screen, but %8s counts them all the same — so a
// coloured value handed to a width verb is never padded, and any value longer
// than its column shoves everything after it to the right. `gvm host` did exactly
// that: a host called DC0_C0_H0 shifted its whole line three columns against the
// one above it, and the heading, being written by hand, lined up with neither.
package main
import (
"strings"
"github.com/fatih/color"
)
// printColumn is one column of a printed table: what it is called, how wide it
// is, and whether its values are numbers, which read better against the right.
type printColumn struct {
header string
width int
right bool
}
// cell is one value with the colour it is shown in.
type cell struct {
text string
col string
}
var hostColumns = []printColumn{
{header: "HOST", width: 10},
{header: "CPU%", width: 6, right: true},
{header: "USED MEM", width: 9, right: true},
{header: "TOTAL MEM", width: 9, right: true},
{header: "VM", width: 4, right: true},
{header: "ON", width: 4, right: true},
{header: "STATUS", width: 7},
{header: "CONNECTED", width: 12},
}
var countColumns = []printColumn{
{header: "HOST", width: 24},
{header: "VM", width: 4, right: true},
{header: "ON", width: 4, right: true},
}
// printRow writes one row, or the heading when cells is nil. Every value is
// padded to its column as plain text and only then coloured, so a long value is
// cut instead of pushing its neighbours along.
func printRow(cols []printColumn, indent string, cells []cell) {
out := make([]string, 0, len(cols))
for i, c := range cols {
text, col := c.header, colHeader
if cells != nil {
if i >= len(cells) {
break
}
text, col = cells[i].text, cells[i].col
}
out = append(out, paint(pad(text, c.width, c.right), col))
}
PF("%s%s\n", indent, strings.TrimRight(strings.Join(out, " "), " "))
}
// widen grows every column to the longest thing it holds, header included. For
// output that is going somewhere without a width of its own, where cutting a
// value would lose it rather than merely hide it.
func widen(pcs []printColumn, body [][]cell) {
for i := range pcs {
w := len([]rune(pcs[i].header))
for _, cells := range body {
if i >= len(cells) {
continue
}
if n := len([]rune(cells[i].text)); n > w {
w = n
}
}
pcs[i].width = w
}
}
// pad fits text to width — truncating what is too long — against the left or,
// for numbers, the right.
func pad(text string, width int, right bool) string {
t := truncate(text, width)
gap := width - len([]rune(t))
if gap <= 0 {
return t
}
if right {
return SR(" ", gap) + t
}
return t + SR(" ", gap)
}
// paint colours text, but only where colour belongs. The escapes the interactive
// screen uses are written straight out and would otherwise end up in a pipe;
// fatih/color already works out whether that is the case, so its answer is the
// one used here rather than a second opinion.
func paint(text, col string) string {
if col == "" || color.NoColor {
return text
}
return col + text + attrOff
}
+92 -12
View File
@@ -85,7 +85,8 @@ func powerOf(t *testing.T, s *session, ref types.ManagedObjectReference) types.V
// oneRow connects and returns the machine of that name as the list would hold it.
func oneRow(t *testing.T, vc VCenter, name string) (*session, vmRow) {
t.Helper()
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
@@ -116,7 +117,8 @@ func TestSimGatherVMs(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -160,7 +162,8 @@ func TestSimBrowseAndOpenDetail(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -220,7 +223,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
@@ -235,7 +239,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
}
// The sheet has to show it now.
rows, sessions, err = gatherVMs([]VCenter{vc})
found, err = gatherVMs([]VCenter{vc})
rows, sessions = found.rows, found.sessions
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
@@ -264,10 +269,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
vc := simVCenter(t)
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
if err := lsvm([]VCenter{vc}, ""); err != nil {
if err := lsvm([]VCenter{vc}, "", "", false); err != nil {
t.Errorf("lsvm: %v", err)
}
if err := lsvm([]VCenter{vc}, "DC0"); err != nil {
if err := lsvm([]VCenter{vc}, "DC0", "", false); err != nil {
t.Errorf("lsvm with a pattern: %v", err)
}
if err := hoststat(vc, ""); err != nil {
@@ -281,7 +286,7 @@ func TestSimReadOnlyCommands(t *testing.T) {
}
// A pattern that is not a regexp is a message, not a panic.
if err := lsvm([]VCenter{vc}, "web("); err == nil {
if err := lsvm([]VCenter{vc}, "web(", "", false); err == nil {
t.Error("lsvm accepted a broken pattern")
}
}
@@ -321,7 +326,8 @@ func TestSimInteractiveSnapshot(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -642,7 +648,8 @@ func TestSimBrowserDestructiveNeedsTheName(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -734,7 +741,8 @@ func TestSimBrowserPowerOffNeedsTheName(t *testing.T) {
quiet(t)
vc := simVCenter(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -800,7 +808,8 @@ func TestSimMenuRereadsTheRow(t *testing.T) {
quiet(t)
vc, model := simVCenterModel(t)
rows, sessions, err := gatherVMs([]VCenter{vc})
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
@@ -882,3 +891,74 @@ func TestBrowserSaysWhenTheRowCannotBeReread(t *testing.T) {
t.Error("a stale row is not flagged as a warning")
}
}
// A vCenter that does not answer has to be visible in the list that is meant to
// show every vCenter. It used to be printed and then wiped by the first screen,
// while the title went on naming the server as though its machines were there.
func TestSimUnreachableVCenterIsNamed(t *testing.T) {
quiet(t)
live := simVCenter(t)
dead := VCenter{
Name: "dead", URL: "http://127.0.0.1:9/",
User: "user", Password: "pass", Datacenter: "DC0",
}
found, err := gatherVMs([]VCenter{live, dead})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("one server answering should not be a failure: %v", err)
}
if len(found.rows) == 0 {
t.Fatal("the machines of the server that answered were thrown away")
}
if strings.Join(found.answered, ",") != "sim" {
t.Errorf("answered: %v, want only sim", found.answered)
}
if strings.Join(found.lost, ",") != "dead" {
t.Errorf("lost: %v, want only dead", found.lost)
}
if len(found.failed) != 1 || !strings.Contains(found.failed[0], "dead") {
t.Errorf("the reason does not name the server: %v", found.failed)
}
// The title names one as holding the machines and the other as missing, and
// does not claim the dead one holds anything.
b := &browser{targets: []VCenter{live, dead}, rows: found.rows,
answered: found.answered, lost: found.lost}
b.applySort()
t.Setenv("COLUMNS", "120")
t.Setenv("LINES", "14")
frame := stripEscapes(renderToPipe(t, b, b.renderList))
title := strings.SplitN(frame, "\r\n", 2)[0]
if !strings.Contains(title, "on sim") {
t.Errorf("the title does not say whose machines these are: %q", title)
}
if !strings.Contains(title, "dead unreachable") {
t.Errorf("the title does not say that dead is missing: %q", title)
}
if strings.Contains(title, "on dead") || strings.Contains(title, "sim, dead") {
t.Errorf("the title still claims the list covers dead: %q", title)
}
}
// Every vCenter failing is a failure of the command; one failing is not.
func TestSimAllVCentersUnreachable(t *testing.T) {
quiet(t)
dead := func(name, port string) VCenter {
return VCenter{Name: name, URL: "http://127.0.0.1:" + port + "/",
User: "u", Password: "p", Datacenter: "DC0"}
}
found, err := gatherVMs([]VCenter{dead("a", "9"), dead("b", "9")})
closeSessions(found.sessions)
if err == nil {
t.Fatal("no server answered and that was not an error")
}
if len(found.failed) != 2 {
t.Errorf("%d reasons for two failures", len(found.failed))
}
if len(found.answered) != 0 {
t.Errorf("answered: %v", found.answered)
}
}
+17 -3
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
@@ -85,10 +86,23 @@ func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string
if err != nil {
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
}
if err := task.Wait(s.ctx); err != nil {
return fmt.Errorf("%s: the snapshot %s failed: %w", s.vc.Name, name, err)
// Bounded, like every other task gvm waits for. This one was not: it waited
// on the session's own context, which has no deadline, so a snapshot that
// vCenter never finished froze the interactive list with the screen mid-draw
// and no key being read — the one place where waiting for ever is worst.
return waitTask(s.ctx, task, snapshotWait, SF("snapshot %s of %s", name, vmName(s, ref)))
}
// vmName is the machine's name for a message, from its reference alone. Cheap:
// one property, and only asked for when something has gone wrong enough to be
// worth naming.
func vmName(s *session, ref types.ManagedObjectReference) string {
var mvm mo.VirtualMachine
if err := object.NewVirtualMachine(s.client.Client, ref).
Properties(s.ctx, ref, []string{"name"}, &mvm); err != nil {
return ref.Value
}
return nil
return mvm.Name
}
// snapRemove removes one snapshot, with its children left where they are, and
+268
View File
@@ -0,0 +1,268 @@
// sort.go — the order the table is in.
//
// One key opens a legend on the status line and the next key picks the order, so
// the list stays on screen while it rearranges itself in front of you. Ordinary
// letters cannot be used on their own: the list's filter swallows those.
//
// Every order has a natural direction, because that is what asking for it means.
// Sorting by name means a to z; sorting by processor load means the busiest
// first, and having to reverse it every time would be a nuisance dressed up as
// consistency. `r` reverses whatever is current.
package main
import (
"sort"
"strings"
)
// sortOrder is one way of arranging the table.
type sortOrder struct {
key rune // the letter that picks it
name string // what it is called, in the title and the legend
natural bool // its own direction: true means largest or busiest first
cmp func(a, b vmRow) int
}
// sortOrders in the order the legend lists them: the two that identify a machine
// first, then what it is doing, then what it is made of, then where it lives.
var sortOrders = []sortOrder{
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
{key: 'p', name: "power", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(powerRank(a), powerRank(b)) }},
{key: 'c', name: "cpu load", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
{key: 'm', name: "memory in use", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
{key: 's', name: "memory size", natural: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
}},
{key: 'u', name: "processors", natural: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.NumCpu), int(b.vm.Summary.Config.NumCpu))
}},
{key: 'v', name: "vcenter", cmp: func(a, b vmRow) int { return cmpText(a.vc.Name, b.vc.Name) }},
{key: 'h', name: "host", cmp: func(a, b vmRow) int { return cmpText(a.host, b.host) }},
{key: 'a', name: "address", cmp: func(a, b vmRow) int { return cmpAddress(a, b) }},
}
// sortReverse is the one legend entry that is not an order of its own.
const sortReverse = 'r'
// powerRank puts a running machine above a suspended one above a stopped one, so
// that "by power" means what an operator means by it.
func powerRank(r vmRow) int {
switch r.powerShort() {
case "on":
return 3
case "susp":
return 2
case "off":
return 1
}
return 0
}
func cmpText(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }
func cmpInt(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
// A value that is not there is not a small value. Wherever one can be missing —
// a load figure on a stopped machine, an address on a machine whose guest is not
// talking — the machine belongs at the *bottom* of the list, and which end of the
// comparison that is depends on which way the order naturally runs.
//
// Load runs downwards by nature (busiest first), so an unknown load has to
// compare as the smallest. An address runs upwards (a to z), so a missing address
// has to compare as the largest. Same rule, opposite polarity; the two functions
// below say so where it can be checked.
// cmpLoad orders two machines by a load figure, unknown lowest — which puts it
// last under the busiest-first direction this order is asked for with.
func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
x, xok := load(a)
y, yok := load(b)
switch {
case !xok && !yok:
return 0
case !xok:
return -1
case !yok:
return 1
case x < y:
return -1
case x > y:
return 1
}
return 0
}
// cmpAddress orders by address, unknown highest — which puts it last under the
// a-to-z direction this order is asked for with.
func cmpAddress(a, b vmRow) int {
x, y := a.ip(), b.ip()
switch {
case x == "-" && y == "-":
return 0
case x == "-":
return 1
case y == "-":
return -1
}
return cmpText(x, y)
}
// ------------------------------------------------------------------ the sorting
func (b *browser) order() sortOrder { return sortOrders[b.sortBy] }
// applySort rearranges the rows and rebuilds what is on screen. The selection
// follows the machine it was on, which refilter already sees to.
//
// Machines that compare equal are left in name order, always ascending, whichever
// way the sort itself runs: a screen full of machines all at 0 % that reshuffles
// when the direction is flipped would look like the numbers had changed.
func (b *browser) applySort() {
sortRows(b.rows, b.sortBy, b.sortDesc)
b.refilter()
}
// sortRows is the sorting itself, without a browser: `gvm vm -l` orders the same
// rows the same way.
func sortRows(rows []vmRow, by int, desc bool) {
o := sortOrders[by]
sort.SliceStable(rows, func(i, j int) bool {
x, y := rows[i], rows[j]
n := o.cmp(x, y)
if desc {
n = -n
}
if n != 0 {
return n < 0
}
return cmpText(x.name, y.name) < 0
})
}
// findOrder resolves what was asked for on the command line — a letter or a name,
// as the legend spells them — to one of the orders. Empty means the first, which
// is by name.
func findOrder(s string) (int, error) {
if s == "" {
return 0, nil
}
for i, o := range sortOrders {
if s == string(o.key) || s == o.name || s == shortName(o.name) {
return i, nil
}
}
names := make([]string, len(sortOrders))
for i, o := range sortOrders {
names[i] = shortName(o.name)
}
return 0, errf("cannot sort by %q — try one of: %s", s, strings.Join(names, ", "))
}
// sortLabel is the order as the title shows it: which way, and by what.
func (b *browser) sortLabel() string {
arrow := "↑"
if b.sortDesc {
arrow = "↓"
}
return arrow + " " + b.order().name
}
// sortLegend is the one line offering the choices. Short by necessity — it shares
// the status line — and the title says what the order is anyway, so nobody who
// misses it is lost.
func sortLegend() string {
parts := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
parts = append(parts, string(o.key)+"·"+shortName(o.name))
}
parts = append(parts, string(sortReverse)+"·reverse")
return "sort: " + strings.Join(parts, " ")
}
// shortName is the legend's spelling: the title has room for the whole name, one
// line shared with the status does not.
func shortName(name string) string {
switch name {
case "cpu load":
return "cpu%"
case "memory in use":
return "mem%"
case "memory size":
return "size"
case "processors":
return "cpus"
case "vcenter":
return "vc"
case "address":
return "ip"
}
return name
}
// sortPrompt puts the legend up and waits for one key. Anything that is not a
// choice leaves the order alone: this is the one prompt in the list that is
// reached by accident, and doing nothing is the right answer to a stray key.
func (b *browser) sortPrompt() {
b.prompt = &prompt{text: sortLegend(), col: colValue} // a menu, not a warning
b.render()
k := b.keys.next()
b.prompt = nil
if k.special != keyRune {
return
}
if k.r == sortReverse {
b.sortDesc = !b.sortDesc
b.applySort()
b.setStatus(colInfo, "sorted "+b.sortLabel())
return
}
for i, o := range sortOrders {
if o.key == k.r {
b.sortBy, b.sortDesc = i, o.natural
b.applySort()
b.setStatus(colInfo, "sorted "+b.sortLabel())
return
}
}
}
// sortedColumn reports whether this column is the one the table is ordered by, so
// its heading can be lit up. Matching by name rather than by index keeps the two
// tables — the columns and the orders — free to be listed in different orders.
func (b *browser) sortedColumn(header string) bool {
switch b.order().name {
case "name":
return header == "NAME"
case "power":
return header == "PWR"
case "cpu load":
return header == "CPU%"
case "memory in use":
return header == "MEM%"
case "memory size":
return header == "MEM"
case "processors":
return header == "CPU"
case "vcenter":
return header == "VC"
case "host":
return header == "HOST"
case "address":
return header == "IP"
}
return false
}
+287
View File
@@ -0,0 +1,287 @@
package main
import (
"io"
"os"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// sortRow builds a machine with the few figures the orders compare.
func sortRow(name, vc, host, ip string, cpu, memMB int32, mhz, memUse int32, on bool) vmRow {
state := types.VirtualMachinePowerStatePoweredOff
if on {
state = types.VirtualMachinePowerStatePoweredOn
}
r := vmRow{
vc: VCenter{Name: vc},
ref: types.ManagedObjectReference{Value: "vm-" + name},
name: name,
host: host,
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{Name: name, NumCpu: cpu, MemorySizeMB: memMB},
Runtime: types.VirtualMachineRuntimeInfo{
PowerState: state, MaxCpuUsage: 1000,
},
QuickStats: types.VirtualMachineQuickStats{
OverallCpuUsage: mhz, GuestMemoryUsage: memUse,
},
}},
}
if ip != "" {
r.vm.Guest = &types.GuestInfo{IpAddress: ip}
}
return r
}
// sortFixture: four machines chosen so that every order puts them in a different
// sequence, and one of them is stopped so the "unknown load" rule is exercised.
func sortFixture() []vmRow {
return []vmRow{
// name vc host ip cpu mem mhz memuse on
sortRow("delta", "v108", "esx2", "10.0.0.4", 2, 4096, 100, 2048, true), // cpu 10%, mem 50%
sortRow("alpha", "v308", "esx1", "10.0.0.1", 8, 16384, 900, 1638, true), // cpu 90%, mem 10%
sortRow("charlie", "v38", "esx3", "", 1, 2048, 500, 1536, true), // cpu 50%, mem 75%
sortRow("bravo", "v308", "esx1", "10.0.0.2", 4, 8192, 0, 0, false), // stopped
}
}
func order(t *testing.T, key rune, desc bool) []string {
t.Helper()
b := &browser{rows: sortFixture()}
for i, o := range sortOrders {
if o.key == key {
b.sortBy, b.sortDesc = i, desc
}
}
b.applySort()
out := make([]string, len(b.rows))
for i, r := range b.rows {
out[i] = r.name
}
return out
}
func TestSortOrders(t *testing.T) {
for _, c := range []struct {
key rune
desc bool
want string
note string
}{
{'n', false, "alpha bravo charlie delta", "by name, a to z"},
{'n', true, "delta charlie bravo alpha", "by name, reversed"},
// The busiest first, and the machine that is not running has no load at
// all, so it goes last however loud the others are.
{'c', true, "alpha charlie delta bravo", "by cpu load, busiest first"},
{'c', false, "bravo delta charlie alpha", "by cpu load, quietest first"},
{'m', true, "charlie delta alpha bravo", "by memory in use, fullest first"},
{'s', true, "alpha bravo delta charlie", "by memory size: 16G 8G 4G 2G"},
{'u', true, "alpha bravo delta charlie", "by processors, most first"},
// Running before stopped, and equal machines stay in name order.
{'p', true, "alpha charlie delta bravo", "by power, running first"},
{'v', false, "delta alpha bravo charlie", "by vcenter: v108 v308 v308 v38, as text"},
{'h', false, "alpha bravo delta charlie", "by host"},
// A machine with no address has not got a small one: it goes last, in the
// a-to-z direction this order is asked for with.
{'a', false, "alpha bravo delta charlie", "by address, the addressless last"},
} {
if got := strings.Join(order(t, c.key, c.desc), " "); got != c.want {
t.Errorf("%s:\n got %s\n want %s", c.note, got, c.want)
}
}
}
// Machines that compare equal keep name order whichever way the sort runs. A
// screen full of machines all at 0 % that reshuffled when the direction flipped
// would look as though the numbers had changed.
func TestEqualMachinesKeepNameOrder(t *testing.T) {
rows := []vmRow{
sortRow("zulu", "v308", "esx1", "", 1, 1024, 0, 0, false),
sortRow("mike", "v308", "esx1", "", 1, 1024, 0, 0, false),
sortRow("alpha", "v308", "esx1", "", 1, 1024, 0, 0, false),
}
for _, desc := range []bool{false, true} {
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
for i, o := range sortOrders {
if o.key == 'c' {
b.sortBy = i
}
}
b.applySort()
var got []string
for _, r := range b.rows {
got = append(got, r.name)
}
if strings.Join(got, " ") != "alpha mike zulu" {
t.Errorf("reversed=%v: equal machines came out %v", desc, got)
}
}
}
// Picking an order takes its own direction with it: nobody asking for the
// processor load wants the idle machines first.
func TestEachOrderHasItsOwnDirection(t *testing.T) {
for _, o := range sortOrders {
switch o.name {
case "name", "vcenter", "host", "address":
if o.natural {
t.Errorf("%s reads downwards by default", o.name)
}
default:
if !o.natural {
t.Errorf("%s does not put the largest first by default", o.name)
}
}
}
}
// The prompt: a letter picks an order, r reverses, and anything else leaves the
// table as it was — it is the one prompt in the list reached by accident.
func TestSortPrompt(t *testing.T) {
newBrowser := func() (*browser, func(string)) {
b := &browser{rows: sortFixture()}
b.applySort()
screen, keys, _ := headlessBrowser(t)
b.tty, b.keys = screen.tty, screen.keys
return b, func(s string) { keys.WriteString(s); b.sortPrompt() }
}
b, press := newBrowser()
press("c")
if b.order().name != "cpu load" || !b.sortDesc {
t.Errorf("c gave %q desc=%v, want cpu load busiest first", b.order().name, b.sortDesc)
}
if b.rows[0].name != "alpha" {
t.Errorf("the busiest machine is not first: %s", b.rows[0].name)
}
press("r")
if !strings.Contains(b.sortLabel(), "↑") || b.rows[0].name != "bravo" {
t.Errorf("r did not reverse: %q, first %s", b.sortLabel(), b.rows[0].name)
}
before := b.order().name
press("\x1b") // Esc
if b.order().name != before {
t.Errorf("Esc changed the order to %q", b.order().name)
}
press("Z") // not a choice
if b.order().name != before {
t.Errorf("a stray letter changed the order to %q", b.order().name)
}
if b.prompt != nil {
t.Error("the legend stayed on screen")
}
}
// The title says which order the table is in, the heading of that column is lit,
// and exactly one column is.
func TestSortIsVisibleInTheTable(t *testing.T) {
b := &browser{rows: sortFixture()}
for i, o := range sortOrders {
b.sortBy, b.sortDesc = i, o.natural
b.applySort()
if !strings.Contains(b.sortLabel(), o.name) {
t.Errorf("the title says %q for the %s order", b.sortLabel(), o.name)
}
arrow := "↑"
if o.natural {
arrow = "↓"
}
if !strings.HasPrefix(b.sortLabel(), arrow) {
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
}
lit := 0
for _, c := range browseColumns {
if b.sortedColumn(c.header) {
lit++
}
}
if lit != 1 {
t.Errorf("the %s order lights %d column headings, want exactly 1", o.name, lit)
}
}
}
// Every order is offered, and the legend fits a terminal of eighty.
func TestSortLegend(t *testing.T) {
legend := sortLegend()
for _, o := range sortOrders {
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the legend does not offer %q for %s: %s", string(o.key), o.name, legend)
}
}
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
t.Errorf("the legend does not offer the reverse: %s", legend)
}
if n := len([]rune(legend)); n > 78 { // a terminal of eighty, less the gutter
t.Errorf("the legend is %d columns wide: %s", n, legend)
}
// Distinct letters, or one of them would be unreachable.
seen := map[rune]bool{sortReverse: true}
for _, o := range sortOrders {
if seen[o.key] {
t.Errorf("%q is the letter for more than one order", string(o.key))
}
seen[o.key] = true
}
}
// The sort legend is a menu, not a question: it must not be dressed as a warning
// and must not have the yes/no hint hung on it, which is what happened while the
// two shared one string.
func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
b := &browser{rows: sortFixture()}
b.applySort()
screen, keys, cleanup := headlessBrowser(t)
defer cleanup()
b.tty, b.keys = screen.tty, screen.keys
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "16")
// Render the legend by asking for it and reading the frame the prompt puts up,
// with the keystroke that answers it queued behind.
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
b.tty = w
keys.WriteString("n")
done := make(chan string, 1)
go func() {
out, _ := io.ReadAll(r)
done <- string(out)
}()
b.sortPrompt()
w.Close()
frame := stripEscapes(<-done)
if !strings.Contains(frame, "sort:") {
t.Fatalf("the legend was not drawn:\n%s", frame)
}
if strings.Contains(frame, "y = yes") {
t.Error("the yes/no hint was hung on the sort legend")
}
for _, line := range strings.Split(frame, "\r\n") {
if strings.Contains(line, "sort:") && len([]rune(line)) > 100 {
t.Errorf("the legend line is %d columns wide: %q", len([]rune(line)), line)
}
}
}
+8 -233
View File
@@ -1,126 +1,26 @@
// ======================================================================================= go toolbox (mwx'2026)
//
// What gvm actually uses. The toolbox this came from carries a good deal more —
// base62 encoders, a mysql row reader, viper and gjson wrappers, an ip-range
// access check — and none of it was ever called here. Carried along, it was three
// dependencies and a page of code that nothing exercised and no test covered,
// including one access check that had never been wired up and would have panicked
// on the first malformed entry in its list. It is gone; the toolbox it belongs to
// still has it.
package main
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"flag"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/eknkc/basex"
"github.com/tidwall/gjson"
)
var tbversion = "0.6.0"
var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w="
var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
// Updating oneself lives in selfupdate.go now: releases of a Gitea instance,
// fetched over TLS and probed before they replace the running binary. What used
// to be here downloaded a binary over plain http and applied it unverified.
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
addrs, err := net.InterfaceAddrs() // once, not once per configured network
if err != nil {
PE("Error getting addresses")
os.Exit(1)
}
match := 0
for _, validnet := range NETS {
_, ipNet, err := net.ParseCIDR(validnet)
if err != nil { // a typo in the list is a typo, not a nil dereference
PE("bad network " + validnet)
continue
}
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
if ipNet.Contains(ipnet.IP) {
match++
}
}
}
}
}
if match == 0 {
PE("access violation, permission denied")
os.Exit(1)
}
}
func Enc(str string) string { // ----------------------------------------------------------------- encode string
enc, err := basex.NewEncoding(Db64(CHRS))
if err != nil {
return ""
}
return enc.Encode([]byte(Rndstr(2) + str))
}
func Dec(str string) string { // ----------------------------------------------------------------- decode string
enc, err := basex.NewEncoding(Db64(CHRS))
if err != nil {
return ""
}
b, err := enc.Decode(str)
if err != nil || len(b) < 2 { // the 2 bytes are the Rndstr(2) prefix Enc puts in front
return ""
}
return string(b[2:])
}
func Db64(txt string) string { // ------------------------------------------------------------- string to base64
d, _ := base64.StdEncoding.DecodeString(txt)
return string(d)
}
func Rndstr(n int) string { // ------------------------------------------------------- random string with length
b := make([]rune, n)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(LR))))
b[i] = LR[n.Int64()]
}
return string(b)
}
func Input(msg string, def string) string { // -------------------------------- AlecAivazis/survey: input string
tmp := ""
err := survey.AskOne(&survey.Input{Message: msg, Default: def}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return tmp
}
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
tmp := ""
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return tmp
}
func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no
if overwrite {
@@ -149,16 +49,6 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
}
}
func Getid(n int) string { // --------------------------------------------------------- get base62 random string
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ret := make([]byte, n)
for i := 0; i < n; i++ {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
ret[i] = letters[num.Int64()]
}
return string(ret)
}
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
ret := make([]byte, n)
@@ -169,70 +59,6 @@ func GETid(n int) string { // --------------------------------------------------
return string(ret)
}
func Mytable(rows *sql.Rows) []map[string]interface{} { // ----------------------------- load mysql result table
defer rows.Close()
columns, _ := rows.Columns()
count := len(columns)
tableData := make([]map[string]interface{}, 0)
values := make([]interface{}, count)
valuePtrs := make([]interface{}, count)
for rows.Next() {
for i := 0; i < count; i++ {
valuePtrs[i] = &values[i]
}
rows.Scan(valuePtrs...)
entry := make(map[string]interface{})
for i, col := range columns {
var v interface{}
val := values[i]
b, ok := val.([]byte)
if ok {
v = string(b)
} else {
v = val
}
entry[col] = v
}
tableData = append(tableData, entry)
}
return (tableData)
}
func Checkip(network string, ip string) bool { // ---------- check if ip is in range (cidr address or single ip)
if net.ParseIP(ip) == nil {
return false
}
_, subnet, err := net.ParseCIDR(network)
if err == nil {
if subnet.Contains(net.ParseIP(ip)) {
return true
}
} else {
if network == ip {
return true
}
}
return false
}
func Isflagpassed(name string) bool { // -------------------------------------------------- check if flag is set
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func Body(r *http.Response) string { // ------------------------------------------------------------ http body
body, err := io.ReadAll(r.Body)
if err == nil {
return string(body)
}
return ""
}
func Atoi(s string) int { // ------------------------------------------------------------------------------ atoi
i, err := strconv.Atoi(s)
if err != nil {
@@ -245,14 +71,6 @@ func Itoa(i int) string { // ---------------------------------------------------
return strconv.Itoa(i)
}
func GJA(j string, k string) []string { // -------------------------------------------------- convert gjson array
var ret []string
for _, c := range gjson.Get(j, k).Array() {
ret = append(ret, c.String())
}
return ret
}
// ------------------------------------------------------------------------------------------------ print simple
func P(a ...any) (n int, err error) { return fmt.Fprintln(os.Stdout, a...) }
@@ -276,51 +94,8 @@ func PO(msg ...string) (n int, err error) {
// errf is fmt.Errorf under a shorter name, to go with the P/PF/SF family.
func errf(format string, a ...any) error { return fmt.Errorf(format, a...) }
// ------------------------------------------------------------------------------------------ regular expression
func ReplaceFirst(re *regexp.Regexp, str, replace string) string {
loc := re.FindStringIndex(str)
if loc == nil {
return str
}
return str[:loc[0]] + replace + str[loc[1]:]
}
// -------------------------------------------------------------------------------------------- string functions
func Shortstr(s string, length int) string {
runes := []rune(s)
if len(runes) <= length {
return s
}
return string(runes[:length-2]) + ".."
}
func SR(str string, n int) string { return strings.Repeat(str, n) }
func RemoveAllMatches(slice []string, target string) []string { // remove matching string from array
// A new slice, not slice[:0]: filtering in place writes through the caller's
// backing array, so the caller's own variable is left holding shifted,
// duplicated entries — and it looks like a pure function at the call site.
result := make([]string, 0, len(slice))
for _, v := range slice {
if v != target {
result = append(result, v)
}
}
return result
}
// -------------------------------------------------------------------------------------------- system functions
func prgname() string { // program name
exepath, err := os.Executable()
if err != nil {
PE(SF("Error getting executable path: %s", err))
return ""
}
exename := filepath.Base(exepath)
return exename
}
// ========================================================================================================= END
+43 -13
View File
@@ -83,6 +83,7 @@ const (
keyShiftTab
keyEnter
keyCtrlA
keyCtrlO
keyCtrlR
keyCtrlS
keyCtrlC
@@ -136,6 +137,8 @@ func (kr *keyReader) next() key {
return key{special: keyCtrlC}
case 0x01:
return key{special: keyCtrlA}
case 0x0f:
return key{special: keyCtrlO}
case 0x12:
return key{special: keyCtrlR}
case 0x13:
@@ -183,11 +186,39 @@ func (kr *keyReader) readEscape() key {
if b2 != '[' {
return key{special: keyEsc}
}
b3, ok := kr.readByte()
if !ok {
return key{special: keyEsc}
// Read the whole sequence before deciding what it was. A control sequence is
// ESC [ then parameter bytes then one final byte in 0x40..0x7e, and how many
// parameters there are depends on the key *and* on which modifiers were held:
// the plain up arrow is "ESC [ A", the same key with control is "ESC [ 1;5A".
//
// Reading a fixed number of bytes instead — one, and for the digits one more
// for the "~" — left the rest of a longer sequence in the stream, where the
// next read took it for typing. Ctrl-Up put "5A" into the filter and F5 put a
// tilde in it, having first jumped to the top of the list.
params := make([]byte, 0, 8)
var final byte
for {
b, ok := kr.readByte()
if !ok {
return key{special: keyEsc}
}
if b >= 0x40 && b <= 0x7e {
final = b
break
}
if len(params) < cap(params) {
params = append(params, b)
}
}
switch b3 {
// Only the unmodified keys are answered. A sequence with modifiers, or one
// this does not know, is swallowed whole and ignored — which is the point:
// what must not happen is for half of it to arrive as text.
if len(params) > 0 && final != '~' {
return key{special: keyNone}
}
switch final {
case 'A':
return key{special: keyUp}
case 'B':
@@ -202,22 +233,21 @@ func (kr *keyReader) readEscape() key {
return key{special: keyEnd}
case 'Z':
return key{special: keyShiftTab}
case '1', '3', '4', '5', '6':
kr.readByte() // consume trailing '~'
switch b3 {
case '1':
case '~':
switch string(params) {
case "1", "7":
return key{special: keyHome}
case '3':
case "3":
return key{special: keyDelete}
case '4':
case "4", "8":
return key{special: keyEnd}
case '5':
case "5":
return key{special: keyPgUp}
case '6':
case "6":
return key{special: keyPgDn}
}
}
return key{special: keyEsc}
return key{special: keyNone}
}
func utf8SeqLen(b byte) int {
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.0.17
+72 -86
View File
@@ -1,110 +1,96 @@
// vm.go — the virtual machine listing.
// vm.go — the printed machine listing.
//
// The same columns, cells and colours as the interactive list, from the same
// table: `gvm vm -l` and `gvm vm` used to disagree about the very same machine,
// the printed one showing a bare 32 where the other showed 32.0MB, and lacking
// the power state, the host and both load figures entirely. There is one
// definition of what a row of machines looks like (browse.go) and both views
// render it.
package main
import (
"fmt"
"regexp"
"sort"
"sync"
"github.com/fatih/color"
)
// lsvm lists the virtual machines of every server it is given, sorted by name
// across all of them. One unreachable vCenter is a warning and not the end: the
// old version called log.Fatal inside the loop, which threw away everything the
// servers before it had already answered.
//
// The servers are asked at the same time. Three logins one after another are
// three round trips to three machines, and there is nothing to be gained by
// waiting for each in turn.
func lsvm(targets []VCenter, match string) error {
// lsvm prints the machines of every server it is given. One unreachable vCenter
// is a line of complaint, not the end of the listing.
func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
re, err := regexp.Compile("(?i)" + match)
if err != nil {
return fmt.Errorf("bad pattern %q: %w", match, err)
return errf("bad pattern %q: %w", match, err)
}
by, err := findOrder(orderBy)
if err != nil {
return err
}
const (
xfmt = "%-32s %-8s %4s %-15s %3d %8d\n"
xfmts = "%-32s %-8s %4s %-15s %3s %8s\n"
)
PF(xfmts, "VM NAME", "ID", "VM", "IP", "CPU", "MEMORY")
PF(xfmts, SR("=", 32), SR("=", 8), SR("=", 4), SR("=", 15), SR("=", 3), SR("=", 8))
rows := make([][]string, len(targets))
errs := make([]error, len(targets))
var wg sync.WaitGroup
for i, vc := range targets {
wg.Add(1)
go func(i int, vc VCenter) {
defer wg.Done()
rows[i], errs[i] = listOne(vc, re, xfmt)
}(i, vc)
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
for _, why := range found.failed {
PE(why) // said before the table, where it will not be scrolled past
}
if err != nil {
return err
}
wg.Wait()
var all []string
failed := 0
for i := range targets {
if errs[i] != nil {
PE(errs[i].Error())
failed++
continue
rows := make([]vmRow, 0, len(found.rows))
for _, r := range found.rows {
if re.MatchString(r.name) {
rows = append(rows, r)
}
all = append(all, rows[i]...)
}
sortRows(rows, by, reverse != sortOrders[by].natural)
sort.Strings(all)
for _, line := range all {
PN(line)
}
// Nothing at all came back: that is a failure of the command, not a listing
// that happens to be empty, and the exit status should say so.
if failed == len(targets) {
return fmt.Errorf("no vCenter answered")
}
printList(rows)
return nil
}
// listOne collects the formatted lines of a single server.
func listOne(vc VCenter, re *regexp.Regexp, xfmt string) ([]string, error) {
s, err := connect(vc)
if err != nil {
return nil, err
}
defer s.close()
// printList writes the table. On a terminal it is fitted to the width, the same
// as the interactive one; into a pipe every column is written and every column is
// made as wide as the longest thing in it.
//
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
// machine names off at twenty-two characters — silently, with an ellipsis, into
// output whose whole purpose is to be read by something else.
func printList(rows []vmRow) {
cols := fitColumns(printWidth())
vms, err := s.vms("summary", "guest")
if err != nil {
return nil, err
pcs := make([]printColumn, len(cols))
for i, c := range cols {
pcs[i] = printColumn{header: c.header, width: c.width}
}
var out []string
for _, vm := range vms {
name := vm.Summary.Config.Name
if name == "" || !re.MatchString(name) {
continue
body := make([][]cell, 0, len(rows))
for _, r := range rows {
cells := make([]cell, len(cols))
for i, c := range cols {
col := ""
if c.color != nil {
col = c.color(r)
}
cells[i] = cell{text: c.cell(r), col: col}
}
// Both of these are optional in vSphere and absent for a machine that
// is being created or deleted while the listing runs — reading them
// unguarded is a nil dereference at exactly the wrong moment.
ip := "-"
if vm.Guest != nil && vm.Guest.IpAddress != "" {
ip = vm.Guest.IpAddress
}
id := "-"
if vm.Summary.Vm != nil {
id = vm.Summary.Vm.Value
}
out = append(out, SF(xfmt,
Shortstr(name, 32),
id,
vc.Name,
ip,
vm.Summary.Config.NumCpu,
vm.Summary.Config.MemorySizeMB))
body = append(body, cells)
}
if color.NoColor { // no terminal, so nothing to fit into and nothing to cut for
widen(pcs, body)
}
printRow(pcs, "", nil)
for _, cells := range body {
printRow(pcs, "", cells)
}
return out, nil
}
// printWidth is what the table is laid out for. color.NoColor is the answer to
// "is this a terminal" that the colours already go by, so the two cannot disagree
// about where the output is headed.
func printWidth() int {
if color.NoColor {
return tableWidth(browseColumns)
}
cols, _ := termSize()
return max(cols, 20)
}