19 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 269bce195d [mike@mwxm4]
Version 1.2.0.

A minor step, asked for by hand: build.sh only ever bumps the last number, so
this is the one kind of version change that is a decision rather than
bookkeeping. `var version` in gvm.go tracks the MAJOR.MINOR line and follows it
— a test compares the two and would fail otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:21:32 +02:00
Michael WesemannandClaude Opus 5 313c9ed880 [mike@mwxm4]
Fourteen problems from a second review of the live and estate work, verified by
reproduction before each fix. Two of them crash.

The crashes:

* A half-failed refresh replaced the rows and returned before sorting and
  refiltering, leaving the view describing the list from before. Every drawn
  row was then a different machine, the cursor sat on one nobody was looking
  at, and renderList — which follows the view without asking — panicked as soon
  as the new list was shorter. Reproduced: "index out of range [2] with length
  2". The rows and the view are now made to agree whatever the sweep returned;
  the error is reported after, because it is information and not a reason to
  leave the screen inconsistent.
* estate.move repeated the whole step while scanning past headings, so a page
  key that landed on a cluster name leapt another page: page-up from row nine
  went to row one, page-down from three to eleven. It steps once and then walks
  by ones.

The rest:

* The next tick was timed from before the sweep, so a sweep slower than the
  interval left no idle time and handed nextWithin a zero deadline — every
  keystroke then raced an expired timer for the loop.
* A machine that was switched off kept its sparkline for the session: a busy
  shape beside a CPU% of "-". Its history goes with it now.
* Ambiguity in the changed line was judged from the new sweep only, so "web01
  is gone" — built from the old one — stayed a coin toss in exactly the case
  the code exists for. Judged over both, and by identity rather than by server.
* A refresh never updated which servers had answered, so the title kept naming
  one whose machines were stale while the status line said it had not answered.
* The title recomputed the interval at render time, promising "live 2s" while
  the pending refresh was nine seconds away.
* A host vCenter has lost touch with kept drawing a 0 % bar from its cached
  statistics — the picture of an idle host, which is the one thing bar() exists
  to keep separate.
* Enter on the estate reported the filtered list's count, which a text match
  can inflate; it reports the host's own and says the filter is a name match.
* listHelp had lost "/quit" making room for ^e, leaving the main screen with no
  advertised way out.
* The estate screen padded names without truncating them, so a long one shifted
  every column right; it now cuts like every other table in gvm.
* estateProps asked every host for its array of machine references and never
  read it.
* changesBetween copied every vmRow — a whole property document each — into a
  map and out again, twice a tick, to compare four scalars. Indexes now.
* The estate's three format strings are one function and a width table, and the
  width is measured from the format rather than counted by hand: at exactly 100
  columns the wide layout is 105 wide and lost its right-hand figure. Three
  layouts now, the widest that fits whole, and both kinds of number survive all
  of them — tested at nine widths from 60 columns up.

One test of my own was wrong rather than the code: TestSimEstateScreen assumed
the first host carries machines, which the simulator does not guarantee. It
failed about one run in three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:21:25 +02:00
Michael WesemannandClaude Opus 5 d5450d5f46 [mike@mwxm4]
Five problems found by going back over yesterday's and today's work.

The first is a crash, and it is older than the features that made it easy to
hit:

* current() indexed b.rows[b.view[b.sel]] with only the view's own length
  checked. The view holds indexes into the rows, and every refresh has a moment
  — rows replaced, view not yet rebuilt — where an index of the old list points
  past the end of the new one. refilter asks exactly that question in exactly
  that moment, to remember which machine the cursor was on. So a machine
  disappearing from the inventory while the cursor sat near the end of the list
  took gvm down with an index out of range: rare with ^r, which is where it has
  been waiting since reload() was written, and every ten seconds once live mode
  ticks on its own. Both steps are bounds-checked now.
* An open sheet was left on screen when the machine it was of went away:
  openDetail returned early and the old sheet stayed, a page of facts about
  something that no longer exists with nothing on it to say so. It now closes.
  Reachable without a keystroke, by a live refresh.
* A quiet tick cleared the whole status line, including a message somebody had
  just produced by pressing a key — the address they copied, or the reason a
  resize was refused. It now clears only the changed line it wrote itself.
* The changed line said "web01 off" where two vCenters each hold a web01, which
  is a coin toss. The server goes in front, and only where the name is
  ambiguous: paying the width on every line for the rare case is the wrong
  trade, and the table underneath has a column for it.
* A failed refresh with no sessions left said "no machine could be re-read ()".

Also: ^r on the estate screen no longer flashes the machine list underneath
while it reads, and keeps the cursor on the host it was on. Two write-only
fields dropped from estateRow.

Checked and found correct on the way past: the trend column is correctly absent
from the issues listing (which drops the load figures it belongs to), the diff
survives a re-sort between the sweeps it compares, the sheet's scroll position
is clamped sanely when a tick makes the sheet shorter, ^e cannot be stacked
under the sheet in a way Esc cannot unwind, and liveIn cannot go negative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:03:29 +02:00
Michael WesemannandClaude Opus 5 0b0412bd56 [mike@mwxm4]
The estate on one screen: ^e.

estate.go answers what the machine list cannot — where is there still room —
which is a question gvm itself started asking the day `size` could give a
machine four more processors.

Every host of every server that answered, grouped under its cluster, with two
kinds of number beside it:

* What is allocated: every vCPU and megabyte its machines have been promised,
  added up. It exceeds the host routinely and is meant to, so the ratio is the
  figure — 1.5x of memory is a decision somebody made and 8.0x is one somebody
  forgot. Blank where there is room to spare: a column of 0.4x down a screen of
  healthy hosts is noise where the point is to find the one that is over.
* What is in use, as a bar and a percentage, from the host itself. 2.2x
  allocation at 41 % load is fine and the same host at 90 % is not, and no
  allocation figure tells those apart. An unknown load draws nothing rather
  than an empty trough — a host at one per cent fills none of the bar either,
  and "almost idle" must not look like "I cannot see this host".

Only running machines are charged to a host: a parked one has been promised
nothing it is using, and counting it would make a host of parked machines look
full when that is exactly what it is not. They stay in the ON/VM count.

The allocations come from the rows the list already holds, so nothing is read
twice, and they are matched to hosts by reference rather than by name — the
lesson host.go carries a comment about.

⏎ on a host goes back to the list filtered to it, because the answer to "what
is on this one" is the table everybody can already read, and Esc undoes it.
^r reads the screen again; live mode does not tick here, since this screen
reads the hosts itself.

A page jump that would land outside the screen stops at the end of its travel
rather than doing nothing — page-up from the second host had no row a whole
page above it, and "no row" has to mean the first one.

Tested against the simulator: the grouping, that a heading is the sum of its
hosts, that every placed machine is charged to exactly one of them, and that
Enter comes back with the list narrowed to the host under the cursor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:25:45 +02:00
Michael WesemannandClaude Opus 5 89b0029340 [mike@mwxm4]
Live mode: ^l and the list stops being a snapshot.

live.go holds it, because the three parts only mean anything together:

* The refresh, over the connections that are already open — ^r logs in again,
  which is how a dead session is recovered, and doing that every ten seconds
  would be three logins a minute for nothing. Every two seconds while vCenter
  is doing anything, so a clone's progress is watched rather than waited for.
* What changed, on the line under the table: power states, snapshots, tasks
  starting and finishing, machines arriving and leaving, vCenter beginning to
  complain. Four of them and a count of the rest. A table says what is; this is
  the only thing on the screen that says what became.
* The CPU~ column: six sweeps of each machine's load, one character each, on a
  fixed 0-100 scale. A line fitted to its samples would make a machine idling
  between 1 and 2 per cent look like one swinging between 40 and 80.

The samples cannot live on the rows, which every sweep throws away, so the
browser keeps them and writes the drawing back onto the rows. A machine that is
not running has no load rather than zero, so nothing is sampled from it.

A tick holds still for every screen that asks something — menu, picker,
confirmation, a half-typed name — because being ten seconds out of date beats
any of them moving under a hand. A sheet is refreshed, at the line it was being
read at. Nothing in here acts on a machine.

Two things it must not do, and does not: move the viewport under a cursor that
did not move (refilter is written for a filter being typed, where going back to
the top is right), or empty half the list because one vCenter is restarting —
those rows are kept and the server is named.

The column ladder is renumbered to make room: the trend is given up before
every fact about a machine but after the guest's operating system, which is the
least read column in the table. Only the order ever mattered, not the figures.

tty.go gains ^l and nextWithin, which puts a deadline on the *first* byte only:
one expiring in the middle of "ESC [ A" would turn an arrow key into an Esc and
a stray letter in the filter.

Tested against the simulator: the refresh reuses the session (with it closed it
fails rather than reconnecting), notices a machine stopped behind gvm's back,
and says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:19:27 +02:00
Michael WesemannandClaude Opus 5 97d8d4191d [mike@mwxm4]
The socket test's assertion was position-blind: strings.Contains(long, "0 or")
matches "10 or 12, not 11" as readily as the "0 or 4" it was looking for, so it
was correct only for the three inputs it happened to try. It now matches on the
position the counts occupy, and sweeps 2, 4 and 8 cores per socket against
every count from 1 to 20 rather than three cases at one topology.

Checked against a deliberately broken fix first: it reports "0 or 4, not 1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:43:29 +02:00
Michael WesemannandClaude Opus 5 606dc38411 [mike@mwxm4]
Five fixes to the resize feature, from a review of db2f019.

* openMenu discarded every warning it raised. It clears the status line as its
  last act — the menu is a fresh screen — which wiped the warning set on the
  way there before a single frame was drawn. Both warnings are now held and put
  back after the wipe; this also brings back the pre-existing "showing what was
  last read" warning, which had been dying the same way since before this
  feature.
* A machine whose configuration could not be read is now refused rather than
  silently allowed. sizeObjection claimed nothing for known=false, so the
  confirmation offered "0B → 8.0GB" and hotly() called a shrink a hot-add. Not
  knowing what a machine has is a reason to leave it alone: both entries grey
  out with the reason.
* The socket refusal no longer names 0 as a count that fits. below is 0
  whenever the wanted count is under one whole socket, so asking for 2 with 4
  cores per socket said "0 or 4, not 2" — offering a number parseSize itself
  refuses. Below one socket only the count above is named.
* `gvm size -c 8 -m 1026m` no longer sets the vCPUs and then refuses the
  memory. Both are parsed and checked before either is sent, which is the same
  answer power.go gives to two operations on one command line.
* run()'s locals no longer shadow the sizeCPUs and sizeMemory constants.

Tests: the socket refusal never offering 0, the unreadable configuration being
refused at both the menu and the change, and — against the simulator — that a
command line with one possible and one impossible change leaves the machine
exactly as it was. That last one was checked against the old code first: it
failed with "the vCPUs were changed anyway: 3, was 1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:39:40 +02:00
Michael WesemannandClaude Opus 5 db2f01908e [mike@mwxm4]
Changing what a machine has: vCPUs and memory, in the action menu as c and m,
and on the command line as `gvm size`.

resize.go holds the rules, in the shape power.go uses — one objection function
in two lengths, so the menu's grey-out and the message cannot disagree:

* A running machine can only grow, and only where it was built to: CPU and
  memory hot-add are per-machine settings. Memory can never shrink while it
  runs (vSphere has no memory hot-remove) and vCPUs only with hot-remove on.
* The vCPU count must be a multiple of the cores per socket. The socket
  topology is never changed to make a number fit — that would rewrite somebody's
  per-socket licence — so the two counts that do fit are named instead.
* Memory is typed in GB (512m for MB) and must be whole multiples of 4 MB.
* The configuration is read again immediately before anything is sent: the
  menu's grey-out only says a change of this kind is possible at all.

The four config fields are not in the sweep — config is the machine's whole
configuration document — so they are read for one machine when the menu opens
and again when the change is made.

Nothing here loses anything, so it gets the plain y/n question, not the page
that wants YES typed: that one stays for pulling the plug, reverting and
removing snapshots.

`gvm size --vm <machine>` with no number changes nothing and prints what the
machine has, including which of the three hot-plug settings it was built with —
the line that decides whether a change needs a maintenance window.

Tests: the objection matrix (off/running × grow/shrink × settings), the socket
rule, the pointer handling in sizingFrom, what parseSize takes and refuses, the
menu entries, and against the simulator a real ReconfigVM whose numbers are read
back from the server rather than assumed.

.gitignore gains .claude/, which is the agent harness's scratch directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:30:02 +02:00
Michael WesemannandClaude Opus 5 fba15b7897 [mike@mwxm4]
The four read-only actions move out of the action menu onto the sheet's own
letters — e, h, y, w — and the menu keeps only what changes a machine. h logs
in as root by default, survives a ^C during the login, and y actually reaches
the clipboard and says what it put there.

* e h y w are keys of the detail sheet; the menu loses its last group, its
  separator and the guestItem/vsphereItem helpers. The "no address" reason the
  greyed-out entries carried is now hasAddress, said on the status line.
* defaultSSH is "ssh root@%h" — a template in ~/.gvmrc replaces it whole.
* holdTerminalSignals catches SIGINT and SIGQUIT while a child has the screen:
  in cooked mode the keystroke went to the whole foreground group and took gvm
  with it. Caught, not ignored — exec resets a caught signal to default in the
  child, while an ignored one is inherited and the ssh could not be aborted.
  interrupted() tells that keystroke from a fault, so the screen is no longer
  held for something somebody meant to do.
* toClipboard uses pbcopy/wl-copy/xclip/xsel where there is one and always
  sends OSC 52 as well; an ssh login uses the sequence alone. iTerm2 keeps
  OSC 52 behind a setting, which is why y appeared to do nothing. The status
  line now names what was copied — hostname or address — and which clipboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 08:51:18 +02:00
Michael Wesemann 4a5477bde7 [mike@mwxm4] 2026-09-08 17:15:05 +02:00
Michael Wesemann 137a799399 [mike@mwxm4] 2026-09-08 17:08:47 +02:00
Michael Wesemann b902059402 [mike@mwxm4] 2026-09-08 17:00:17 +02:00
Michael Wesemann dda9dc1e74 [mike@mwxm4] 2026-09-08 15:50:23 +02:00
Michael Wesemann d3fa1790a1 [mike@mwxm4] 2026-09-07 17:01:28 +02:00
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
Michael Wesemann 0558e42f2b [mike@mwxm4] 2026-09-06 12:55:06 +02:00
Michael Wesemann 5335e585d2 [mike@mwxm4] 2026-09-06 12:41:17 +02:00
50 changed files with 12593 additions and 804 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/
tmp/
gvm
.gvmrc
.claude/
+716 -28
View File
@@ -1,17 +1,22 @@
# gvm — VMware command line helper
A small command line tool for the VMware vCenters: list the virtual machines of
all of them at once, take and remove snapshots, look at what the ESXi hosts are
doing, and mail the vCenter event log.
all of them at once, take and remove snapshots, look at what the ESXi hosts and
the datastores are doing, report the snapshots nobody came back for, and mail
the vCenter event log.
gvm # interactive list of every machine, everywhere
gvm vm # the same thing, spelled out
gvm vm -l # the same as plain output
gvm vm -l -m web # only those whose name matches "web"
gvm vm -l --issues # only the machines with something wrong
gvm vm -l --json # the same listing as a document
gvm -v v108 snap -l myvm # the snapshots of myvm on v108
gvm -v v108 snap -n myvm # take one
gvm snap --old # every snapshot older than 30 days, everywhere
gvm -v v108 power -s myvm # ask its guest to shut down
gvm host # cpu, memory and machine counts per host
gvm ds # capacity, free space and over-commitment
gvm log -l # the last hour of events
gvm config # what gvm made of ~/.gvmrc
@@ -31,23 +36,59 @@ template there and says so — fill in the passwords and it works.
vcenter.v308.insecure = true
`-v <name>` picks a server by its full name; an unknown name is an error rather
than a silent fallback to the first one in the list. Every setting has an
environment spelling that wins over the file — `GVM_VCENTER_V308_PASSWORD`,
than a silent fallback to the first one in the list. The commands that sweep
every server — `gvm`, `gvm vm -l`, `gvm snap --old` — also take a list,
`-v v308,v108`, in the order given and with a repeat counted once; the commands
that act on one machine refuse a list rather than taking the first of it. Every
setting has an environment spelling that wins over the file — `GVM_VCENTER_V308_PASSWORD`,
`GVM_MAILTO`, `GVM_DEFAULT` and so on — which is how to run gvm from cron
without the password living in a file.
The file holds passwords, so gvm creates it mode 0600 and complains when it
finds it readable by others.
### Passwords in the file
A password written into `~/.gvmrc` in the clear is sealed on the next run of gvm
and replaced in place by a `gvmenc1:...` word:
vcenter.v308.password = gvmenc1:otspj7CLz1/8vEUFHpfrKH/zKiVLqlOvVwQ…
Nothing else about the file changes — the keys, the spacing, the order, the blank
lines and the comments beside a setting are all left exactly as they were — and
gvm says which password it sealed. Only the value is sealed, never the file, so
`~/.gvmrc` stays readable and editable by hand.
`gvm config -p v308` asks for a password instead of taking it from the file and
writes it sealed straight away. That is the way to set one: a password typed into
the file stands there in the clear until the next run of gvm, and by then it has
been through the editor's swap file and whatever backs the home directory up.
`gvm config` says whether each password is sealed, still in the clear, or sealed
but no longer openable — it opens each one and throws it away, because "it is
sealed" is worth nothing if it does not open.
**What this is, and is not.** The key is compiled into gvm and is the same in
every copy of it, so whoever holds `~/.gvmrc` *and* a gvm binary can open the
value; prising the key out is an afternoon's work, not a cluster's. This is not a
vault. What it buys is that the password no longer stands in the clear in a
backup, in a home directory that syncs somewhere, in an editor's swap file, or on
a screen someone else is looking at. The 0600 is what keeps other local users
out. A value given in `GVM_VCENTER_*_PASSWORD` is taken as it stands, sealed or
not.
## Commands
| command | what it does |
| --- | --- |
| *(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 |
| `vm -l --issues` | only the machines with something wrong with them |
| `vm -l --json` | the same listing as a JSON document |
| `snap -l <vm>` | list a machine's snapshots |
| `snap -n <vm>` | take a snapshot, name printed |
| `snap --old [-d <days>] [-m]` | every snapshot older than that, on every vCenter, optionally by mail |
| `snap -r <vm> -s <snap>` | remove one snapshot |
| `snap --revert <vm> -s <snap>` | put the machine back to that snapshot |
| `snap --removeall <vm>` | remove all of them |
@@ -56,10 +97,15 @@ finds it readable by others.
| `power -b <vm>` | ask the guest to reboot (needs VMware Tools) |
| `power --off <vm>` | power off at the hypervisor — hard |
| `power --reset <vm>` | reset at the hypervisor — hard |
| `size --vm <vm>` | what it has: vCPUs, memory, and what may be changed while it runs |
| `size --vm <vm> -c 8 -m 16` | give it 8 vCPUs and 16 GB — `-m 512m` for megabytes |
| `host [-t]` | per-host cpu, memory, machine counts; `-t` also posts them |
| `host -c` | just the machine counts |
| `ds [-t]` | per-datastore capacity, free space, over-commitment; `-t` also posts them |
| `log -l [-m] [-t <min>]` | the event log, optionally by mail, default 60 minutes |
| `config` | the effective configuration, passwords not shown |
| `config -p <vcenter>` | ask for a password and store it sealed |
| `completion zsh\|bash` | print the shell completion script |
## The interactive list
@@ -72,14 +118,292 @@ help, as does anything gvm does not recognise:
line, so a name, an address, a host or "off" all work, and
the hit is picked out in the row
↑ ↓ PgUp PgDn move, Home/End for the ends
enter the machine's parameters: power, host, guest and tools,
cpu and memory in use, uptime, storage, guest filesystems,
network adapters, snapshots, uuid and moref
enter the machine's parameters: what is wrong with it and what is
being done to it, power, host, guest and tools, cpu and
memory in use, uptime, storage, guest filesystems, network
adapters, snapshots, uuid and moref
↑ ↓ in there scroll the sheet, esc/enter back to the list
e h y w in the sheet: recent events, ssh to the guest, copy what
ssh would connect to, open it in the vSphere client
The sheet is one line per thing worth knowing, values that belong together
joined with a middle dot and no section headings — an ordinary machine fits a
24-row terminal whole, and the identity numbers and the annotation at the bottom
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)
^w only the machines with something wrong with them (see below)
^e the whole estate on one screen (see below)
^l live: the list re-reads itself (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, snapshots, 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.
The snapshot column is how many the machine is dragging along, aged by colour:
past a week the count turns yellow, past a month red — a month being also what
`snap --old` reports on, so a red count means "this machine is in that report".
A machine with none shows a dash. The count is what the column says and the age
is only how it is said, which is why the sheet spells the date out: a colour
cannot be read in a pipe.
Two columns are not always there, because they hold an exception rather than a
property, and thirteen characters of blank down two hundred rows is thirteen
characters spent on nothing:
* **TASK** appears while vCenter is doing something to any machine in the list —
a clone, a migration, a consolidation, with its progress — and is gone again
when nothing is going on. A column that turns up because somebody started a
clone is not the layout shifting about: it is the news.
* **WHY** takes the guest operating system's place in the issues list (`^w`,
`--issues`), where every row has a reason to be there — and the four figures
go with it, so that the reason has the width. It is the last column that
listing gives up rather than the first: in a list whose every row is there
because of it, dropping the reason first leaves a list of machines with no
reason showing on any of them.
A terminal too narrow for all of that gives columns up, least useful first: the
guest's operating system, then the host, then the snapshot count, 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, the host and the snapshot count: the name
column's minimum is one notch narrower than it reads in order to buy the address
its place there, and the count is the one column here that has somewhere else to
be said — `^w`, `--issues` and `snap --old` all name it and date it.
### The estate
`^e` answers the question the machine list cannot: **where is there still room.**
Every host of every server that answered, grouped under its cluster, with what
it carries set against what it has:
Estate v308, v309 14 hosts · 212 machines
CLUSTER / HOST ON/VM vCPU x MEM ALLOC x CPU LOAD % MEM USED %
v308 · prod 58/61 196/96 2.0x 1.1TB/768.0GB 1.5x
▸ esx01 21/22 72/32 2.2x 384.0GB/256.0GB 1.5x ████······ 41 ███████··· 72
esx02 19/20 68/32 2.1x 360.0GB/256.0GB 1.4x ███······· 32 ██████···· 64
esx03 18/19 56/32 1.7x 376.0GB/256.0GB 1.4x █████····· 53 ████████·· 81
v308 · standalone 4/4 8/16 32.0GB/128.0GB ██········ 12 ██········ 21
Two kinds of number, and the difference between them is the whole point — which
is also what decides what a narrow terminal gives up. Three layouts, each the
widest that fits whole: everything with the load drawn as bars; the same figures
with the bars down to their percentages; and, narrower still, only the two
ratios and the two percentages. Both kinds of number survive all three, because
one of them alone says nothing.
**What is allocated** — every vCPU and every megabyte the machines on a host have
been promised, added up. It routinely exceeds the host and is meant to: the ratio
is the figure worth having, because 1.5x of memory is a decision somebody made
and 8.0x is one somebody forgot. It is left blank where a host has room to
spare, since a column of "0.4x" down a screen of healthy hosts is noise where
the point is to spot the one that is over.
**What is in use** — what the host itself reports it is doing, as a bar and a
percentage. A host at 2.2x allocation and 41 % load is fine; the same host at
90 % is not, and no allocation figure can tell those two apart. A load that is
not known draws nothing rather than an empty trough: a host at one per cent
fills none of the bar either, and "almost idle" must not look like "I cannot see
this host".
A host vCenter has lost touch with keeps its hardware figures — they do not
depend on reaching it — but reports no load at all rather than the nought its
cached statistics would give: an empty bar is what an idle host looks like.
Only running machines are charged to a host. A machine that is switched off has
been promised nothing it is using, and counting it would make a host of parked
machines look full when the whole point of parking them there was that it is
not — they are still in the ON/VM count, which is where that belongs.
`⏎` on a host is the other half: it goes back to the machine list with the filter
set to that host, so the answer to "what is on this one" is the table everybody
already knows how to read, and `Esc` undoes it. The count it reports is the
host's own, not the filtered list's: the filter is a text match over the whole
row and carries no server, so it can also catch another vCenter's host of the
same name or a machine named after a host, and a count taken from it would then
contradict the screen it came from. `^r` reads the screen again —
live mode deliberately does not tick here, because this screen reads the hosts
itself and a timer doing that every ten seconds would be paying for a screen
somebody is reading rather than watching.
The allocations are added up from the rows the list already holds, so no machine
is read twice for this, and they are matched to hosts by reference rather than by
name — the same lesson `gvm host` carries a comment about, where a host added by
address and renamed later reported zero machines while running dozens.
### Live
`^l` and the list stops being a snapshot. It re-reads itself every ten seconds —
every two while vCenter is doing anything at all, so a clone's progress is
something one watches finish rather than a figure one waits for — and the title
says so, because a screen that moves on its own with nothing to explain it reads
as a fault.
It refreshes over the connections that are already open. `^r` logs in again,
which is how a session that has died is recovered; doing that every ten seconds
would be three logins a minute for nothing.
Two things come with it.
**The line under the table says what just changed.** A table shows what is; this
is the only thing on the screen that says what *became*:
db01 off · web01 +1 snapshot · app07 clone · esx03: disks need consolidating
Power states, snapshots appearing and going, tasks starting and finishing,
machines arriving and leaving, and vCenter starting to complain. Four of them and
a count of the rest, because a line that has to be read carefully is a line
nobody reads. It is the reason to leave the thing open.
Where two vCenters hold a machine of the same name the server goes in front of
it — "v309 web01 off" — and only there: a name is not what makes a machine that
machine, and the width is worth spending on the rare case rather than on every
line. A quiet refresh clears that line and nothing else: a message somebody
produced by pressing a key is theirs to keep.
**The `CPU~` column is where each machine has just been.** Six sweeps of its
processor load, one character each:
NAME PWR CPU CPU% CPU~ MEM
app07 on 4 18 ▃▅█▆▄▂ 8.0GB
web01 on 4 82 ▄▂▁▃▅█ 8.0GB
The scale is fixed at 0 to 100 and never fitted to the samples: a line that
scales itself would make a machine idling between 1 and 2 per cent look exactly
like one swinging between 40 and 80. The column is there only once there is
something in it — a machine has a history after its second sweep — and it is the
first thing after the guest's operating system that a narrow terminal gives up:
everything else in the table is a fact about a machine, and this is a shape.
A machine that is not running has no load rather than a load of zero, so nothing
is sampled from it and it draws nothing — and what it had drawn before goes with
it, rather than leaving a busy shape beside a CPU% of "-".
The refresh holds still for every screen that asks something: a menu decides what
it offers from the state it was drawn with, the picker holds a list being chosen
from, and a half-typed name or a confirmation is an answer in progress. Being ten
seconds out of date is better than any of those moving under a hand. A machine's
sheet *is* refreshed, at the line it was being read at — watching one machine's
memory is a reason to have it open.
Nothing here acts on a machine. A refresh that could start or stop something
would be a timer with the power to do it, and that is the one thing a screen left
open unattended must not have.
### 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·pwr c·cpu% m·mem% z·snaps o·old w·why 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.
Thirteen choices are ninety-three columns, so a terminal narrower than that
gets them on two lines instead of one that runs off the right-hand edge, hiding
the very choices the legend exists to offer. They break where the meaning
breaks — what the machine is doing and what it wants doing to it, then what it
is made of and where it lives — and the second line takes the help line's row,
which describes keys that do nothing while a menu is waiting for one. Decided
at render time, so a window dragged wider gets the one line back:
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why
s·size u·cpus v·vc h·host a·ip r·reverse
Three of them are about the two columns that are new:
* `z` is by how many snapshots the machine is carrying, most first. Nought is a
figure here and not a missing one — nothing to clean up is a fact about the
machine — so a machine with none sorts where nought belongs: at the bottom
going down, at the top coming back up. The letter carries no mnemonic because
every letter that does was taken; `--sort snaps` spells it out.
* `o` is by the age of the machine's *oldest* snapshot, oldest first, which is
the order the housekeeping is done in — a different question from `#`, and the
more useful one: one snapshot from March wants attention before six from this
morning. A machine with no snapshots has no age and sorts to the bottom either
way round, the same as a stopped machine's load does.
* `w` is by what is wrong with the machine: broken above wants-a-look above
nothing to report, and within each the machine with the most to answer for
first. Sorting the reasons as text would put "alarm" above "disks need
consolidating" and mean nothing at all. Run the other way up it is the
machines that are fine, by name — a listing worth having too.
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.
### The machines that want looking at
A list of two hundred machines is read by running the eye down it, which is
exactly the wrong way to find the three that are broken. `^w` narrows it to
those, and each one carries the reason in place of its guest operating system:
NAME VC PWR SNAP IP HOST WHY
old01 v108 on 1 10.0.0.31 esx02 /var 97 % full · no VMware Tools
db01 v308 on 3 10.0.0.12 esx01 disks need consolidating · snapshot base is 63 days old
win7 v38 on - - esx07 vCenter says yellow
The four figures — vCPUs, processor load, memory and memory in use — are not
there. A machine is in this list because something is wrong with it, and how
hard its processors happen to be working at this second says nothing about any
of the reasons: they would be four columns of arithmetic between the machine's
name and the answer to the question that was asked. They are one keystroke away
in the ordinary list, and on the machine's own sheet.
Nothing new is asked of the servers: this is a filter over the sweep that is
already on screen, so it costs a keystroke and no waiting. `^w` again gives the
whole list back, the typed filter still applies inside it — `^w web` is the
broken web servers — and the title says `issues only` for as long as it is on,
because a filtered list that does not say so is a lie told by omission. `^o w`
puts the worst of them at the top.
What counts as an issue is deliberately narrow, because a list that cries wolf
is one nobody opens:
| reason | |
| --- | --- |
| disconnected, orphaned, inaccessible | vCenter cannot see the machine properly |
| waiting for an answer in vCenter | a question nobody has answered; the machine is stopped until somebody does |
| disks need consolidating | deltas left behind by a snapshot removal that did not finish, growing quietly |
| alarm: *name* | what vCenter itself is complaining about, by the name somebody gave the alarm |
| vCenter says red / yellow | the rolled-up status, when no alarm came with it to explain it |
| no VMware Tools | and only while the machine is running |
| */var* 97 % full | a guest filesystem past 90 %, named with the figure |
| snapshot *name* is 63 days old | past a month, which is where the table's red begins |
Broken is red and wants-a-look is yellow, worst first — which matters because
the column is truncated from the right. An alarm somebody has acknowledged is
one a person has dealt with already and is not reported; a machine that is
switched off is not a fault; and the things that are only true of a running
machine are not held against a stopped one.
The same list prints: `gvm vm -l --issues`, which is the morning's glance and
the one worth a cron job.
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
@@ -87,8 +411,12 @@ sheet:
^a the action menu (see below)
^s take a snapshot: a name, then a confirmation
e recent events
h ssh to the guest, as root
y copy that name or address to the clipboard
w open in the vSphere client
Pressing either in the table says so rather than doing nothing visible.
Pressing `^a` or `^s` in the table says so rather than doing nothing visible.
`^s` takes two steps.
@@ -103,24 +431,95 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
name is on the status line, and a sheet that is open jumps to its snapshot
section so the new one is there to see.
### The sheet's four letters
The four letters are the things that change nothing, on the machine or on the
vCenter: they read its history, copy its address, open it somewhere else. They
are letters of the sheet rather than entries in the menu because nothing they do
needs thinking about first, and — unlike the table, whose filter swallows every
ordinary letter — the sheet has nothing else to do with them:
* **`e`** puts the machine's own recent events at the foot of its sheet and
scrolls down to them — why is this thing off, who rebooted it, what happened
at four this morning. `gvm log` is the whole vCenter over the last hour, which
is the right shape for a mail and the wrong one for that question. They are
fetched when they are asked for: opening a machine stays one call.
* **`h`** logs in to the guest as root — `ssh root@<name>` — by its own hostname
where it reports one and by its address otherwise. The terminal goes back to
what it was for as long as that lasts. `ssh = ssh -l someone %h` in `~/.gvmrc`
replaces that command whole, root and all; the target is always one argument
and never goes through a shell, because it is a name the guest chose for
itself. `^C` while it hangs on a machine that is not answering kills the login
and no more than that: gvm catches the signal for as long as the child has the
screen — the terminal is in its ordinary mode there, where the keystroke goes
to every process in the foreground group — and comes back to the sheet saying
the login was interrupted.
* **`y`** copies exactly what `h` would connect to — the hostname where the
guest reports one, the address otherwise — and the status line names which of
the two it was and which clipboard it went into, because a clipboard is
invisible and "copied" on its own is something one has to go and check.
Two routes, because neither alone is enough. `pbcopy` (or `wl-copy`, `xclip`,
`xsel`) is the one that always works where there is one, and the terminal's own
OSC 52 escape sequence is the only one that reaches the right machine from the
far end of an ssh login — where a local `pbcopy` would copy into the clipboard
of a machine nobody is sitting at. So the sequence is always sent and the
command is used as well where there is one; a login is recognised by
`SSH_CONNECTION`, and there the sequence is the whole story. It is also the
route a terminal is free to ignore — iTerm2 keeps it behind *Applications in
terminal may access clipboard*, tmux behind `set-clipboard` — which is why the
line says when it was the only one used.
* **`w`** opens the machine's page in the vSphere client. The link needs the
vCenter's instance UUID, which is the serverGuid that client puts in its URLs
and the one thing gvm cannot work out from the configuration; where there is no
browser to hand off to, the URL is said and copied instead.
`h` and `y` need somewhere to connect to: on a machine whose guest is not
reporting an address they say so on the status line rather than doing nothing.
### The action menu
In a machine's sheet, `^a` opens the menu for it. Everything that changes a
machine lives there and nowhere else — the list is arrowed through and its filter
swallows every ordinary letter, so a hotkey that powered a machine off would sit
one fumbled control key away from an outage, and the sheet has to be opened first
anyway.
In a machine's sheet, `^a` opens the menu for it. It is everything that changes a
machine, and it lives there and nowhere else — the list is arrowed through and its
filter swallows every ordinary letter, so a hotkey that powered a machine off
would sit one fumbled control key away from an outage, and the sheet has to be
opened first anyway.
Above the choices the menu repeats the few lines of the sheet the choice depends
on — state, guest, hostname, address — taken from the sheet itself, so the two
cannot word the same fact differently. On a terminal too short for both, those
lines go one at a time, least useful first: the state stays longest because every
choice depends on it, then the address and the hostname, which say which machine
this is about.
n take a snapshot o power on
r revert to a snapshot ... s shut down the guest
d remove a snapshot ... b reboot the guest
D remove ALL snapshots S power off (hard)
B reset (hard)
──────────────────────────────── B reset (hard)
c change the vCPU count ...
m change the memory ...
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
always needs the shift key. What cannot be done right now is greyed out with the
reason next to it — "no VMware Tools", "already running" — rather than left out,
and picking it anyway spells the reason out instead of running it.
Of the power pairs, lowercase asks the guest and uppercase acts at the
hypervisor: the violent variant always needs the shift key. What cannot be done
right now is greyed out with the reason next to it — "no VMware Tools", "already
running", "no snapshots", "needs it off" — rather than left out, and picking it
anyway spells the reason out instead of running it.
The three groups are what the machine has been, what it is, and what it is
doing.
Snapshots are drawn as the tree they are — which state descends from which is
the whole point of a snapshot list — and the one the machine is running from
says so:
snapshots base (01.09.2026 02:00)
├─ after-patch (03.09.2026 09:12)
│ └─ hotfix (03.09.2026 16:40)
└─ before-boot (05.09.2026 07:00) ← current
The same drawing appears in the picker below and in `gvm snap -l`; there is one
function that draws it, so the three cannot drift apart.
`r` and `d` open a list of the machine's snapshots. What is chosen there is
carried on by its vSphere reference, not by its name: two snapshots of one
@@ -150,9 +549,13 @@ that has finished shutting down in its own time — `^r` reloads everything.
Nothing else here writes: no key changes a setting, and there is no way to delete
a machine.
Everything but the snapshot tree comes out of the one inventory sweep the list
makes at the start; the snapshots of a machine are fetched when its sheet is
opened.
The sweep the list makes at the start brings back everything the table and the
sheet show, the snapshot trees and the running tasks included — they are
properties of a machine, and reading them for every machine is one call, not one
per row. Two things are asked for afterwards, for one machine at a time: its
snapshots when its sheet is opened, because everything that acts on a snapshot
addresses it by reference and a reference out of a sweep that ran minutes ago may
name one somebody has since removed; and its events, when `e` asks for them.
It needs a terminal, and says so before it connects to anything — in a pipe or
under cron, use `gvm vm -l`.
@@ -182,6 +585,204 @@ 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.
### What a machine has
`c` and `m` in the menu, `gvm size` on the command line: the vCPU count and the
memory. Nothing is lost by either, so they get the plain y/n question rather than
the page that wants YES typed out — a number set wrongly is set back.
vSphere refuses most of what one might ask for on a running machine, and gvm
refuses it first, with the reason, rather than sending it to be bounced:
* **A running machine can only grow, and only where it was built to.** CPU and
memory hot-add are per-machine settings, turned on when the machine was made.
Without them the entry says "needs it off". Memory can never shrink while a
machine runs — there is no hot-remove for it in vSphere at all — and vCPUs only
where hot-remove is on as well.
* **The socket topology is the machine's own.** A vCPU count has to be a multiple
of the cores per socket, and gvm does not quietly change the sockets to make a
number fit: software is licensed per socket, and a tool that turns 2 sockets
into 4 to accept an odd number would be writing somebody an invoice. It names
the two counts that do fit instead.
* **Memory is whole multiples of 4 MB.** It is typed in gigabytes, because that
is what the sheet shows and what anybody says out loud; `512m` means megabytes,
and `1.5g` is 1536 MB.
* **Read again immediately before it is sent.** The menu's grey-out says only
that a change of this kind is possible at all; between drawing it and answering
the question somebody else may have started the machine.
* **A machine whose configuration cannot be read is left alone.** Not knowing
what it has means there is nothing honest to put on the left of the arrow, so
both entries grey out and say so rather than offering a change from a figure
gvm does not have.
* **`gvm size -c 8 -m 1026m` does neither.** Both numbers are read and checked
before either is sent: a command line that sets the vCPUs and then refuses the
memory has half happened, which is the one outcome nobody asked for.
`gvm size --vm <machine>` on its own changes nothing and says what there is:
machine web01 (running)
vCPU 4, in 1 per socket
memory 8.0GB
while it runs add vCPUs, add memory
That last line is the one worth having before planning the work — it is the
difference between a change now and a maintenance window. Where a machine has
none of the three settings it says so plainly: "nothing — it has to be powered
off to be changed".
Hot-added resources are not necessarily in use the moment vCenter reports the
task done: the message says as much, because an operating system does not always
notice on its own that it has been given another four processors.
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.
## The reports
Two things nothing in vCenter does for you, in the shape a cron job wants:
every server at once, one line per thing, and `-m` to put it in the post.
### Old snapshots
gvm snap --old # older than 30 days, on every vCenter
gvm snap --old -d 7 # or than a week
gvm snap --old -m # and mail it
Somebody takes a snapshot before an upgrade, the upgrade goes well, and the
snapshot stays. Six weeks later its delta disk is bigger than the machine and
the datastore is the thing that pages you.
3 snapshots older than 30 days, on 2 machines:
MACHINE VC SNAPSHOT AGE TAKEN SIZE
old01 v108 base 208d 12.02.2026 03:00 8.9GB
db01 v308 before-patch 63d 06.07.2026 22:14 41.2GB
db01 v308 hotfix 61d 08.07.2026 09:40 2.1GB
52.2GB in 3 snapshots, on 2 machines
Oldest first, which is the order the work is done in and puts the worst line
where a mail gets read. The age takes the table's colours — yellow past a week,
red past a month — and the mail carries the same table with the colours left
off, out of the same cells, so the two cannot come to different conclusions.
The size is what removing that snapshot would give back: its own state file and
the last link of each of its disk chains. The links in front of those belong to
its ancestors, and the delta the machine is writing to right now belongs to no
snapshot at all — so summing whole chains, which is the obvious thing to do,
reports the same delta once per descendant. A snapshot whose file layout could
not be read shows a dash rather than 0 B: nought bytes and "not known" are
different answers, and the second must not invite somebody to remove the wrong
snapshot.
The file layout is only asked for for the machines that actually have snapshots,
and for all of them at once per server — it lists every file of every machine,
which is far too much to carry through the ordinary sweep.
### Datastores
gvm ds # one line per datastore
gvm ds -t # and post the numbers
The gap next to `gvm host`: a cluster is watched by its processor load and its
memory, and then it falls over because a datastore filled up.
DATASTORE TYPE CAPACITY FREE USED% PROVISIONED OVER% VM STATUS STATE
ppb-ssd-1 VMFS 4.0TB 412GB 90 5.1TB 128 41 green ok
ppb-sata-2 VMFS 8.0TB 3.2TB 60 6.0TB 75 88 green ok
ppb-old VMFS - - - - - 3 red inaccessible
3 datastores 3.6TB of 12.0TB free (70 % used)
*Provisioned* is what has been promised out of the datastore: what is in use
plus what thin disks are still entitled to grow into. Past the capacity that is
a promise the datastore cannot keep if every machine takes what it was offered,
which is why it has a column of its own rather than being folded into "used" —
ordinary practice, so a hundred per cent is a word of warning in yellow and half
again as much is an alarm in red.
Every figure comes out of the datastore's summary, and vSphere only vouches for
those while the datastore is accessible: an unreachable one reports dashes
rather than zeroes, because a datastore that says 0 B free looks like an
emergency and one nobody can reach is a different one.
## The listing as a document
`gvm vm -l --json` is the same sweep, for something other than a person:
{
"generated": "2026-09-08T11:42:07+02:00",
"answered": ["v308", "v108"],
"failed": ["v38: login failed: ..."],
"count": 212,
"machines": [
{
"name": "db01",
"vcenter": "v308",
"power": "poweredOn",
"cpu_percent": 12.4,
"memory_percent": 64.1,
"snapshots": [{"name": "before-patch", "days": 63, "current": true, ...}],
"oldest_snapshot_days": 63,
"task": {"what": "consolid", "progress": 40, ...},
"issues": ["disks need consolidating"],
...
}
]
}
Two things about the shape, because a document is a promise:
It is one object and not an array of machines, because a listing that quietly
leaves out a vCenter which did not answer is worse than no listing at all — a
script handed a bare array cannot tell an empty cluster from an unreachable one.
The servers that answered and the ones that did not are in the document, and a
failure is *not* also printed as prose: a line of English in the middle of the
JSON would break whatever is reading it.
And a figure that is not known is `null`, never `0`. A stopped machine has no
processor load and a machine whose guest is silent has no address; a spreadsheet
that averages a column of zeroes reports a fleet that is idle.
`--json` and `--issues` mean `-l` without having to be told twice, and both take
`-m`, `--sort` and `--reverse` like any other listing.
## Shell completion
eval "$(gvm completion zsh)" # ~/.zshrc
gvm completion bash > /etc/bash_completion.d/gvm
Machine names are long and there are hundreds of them, which is what makes the
non-interactive half hard to type — `gvm -v v308 snap -l dbse<tab>`. The
completion offers them after the options that take a machine, the server names
after `-v`, and every subcommand and option otherwise.
That last half is not written down anywhere: flaggy generates it out of the
parser itself, so no list can fall behind the options that exist. gvm answers
the `completion` subcommand one step before flaggy would, keeps what flaggy
wrote, and adds the names on top — a wrapper that falls back to flaggy's own
function by the name it installed it under, read off the script rather than
written down a second time. `fish`, `powershell` and `nushell` are left to
flaggy entirely; the names are wired up for zsh and bash.
The names cannot come from the vCenters: a completion runs on every Tab and has
to answer in milliseconds, and three logins take seconds. So they come out of
what gvm last saw — every sweep of the machine list leaves them in the cache
directory, per server and with the time on them, and `--complete-vms` reads that
file and nothing else. A sweep of one server leaves the others' names where they
were, so completion keeps working for a vCenter that is down.
Neither the subcommand nor those two options read `~/.gvmrc`: a Tab key must not
rewrite a file, and reading the configuration seals any password standing in it
in the clear.
Nothing else in gvm reads that cache. Every command resolves the name it was
given against the server itself, because "what gvm saw last time somebody
looked" is the right currency for a Tab key and no currency at all for anything
that acts on a machine. `gvm config` says how old it is, so that a completion
offering a machine deleted last month can be explained.
## Colours
The palette is [mwxcol](https://git.micw.org/mike/mwxcol), copied into
@@ -190,12 +791,38 @@ is a change here. Everything gvm paints goes through that one file: the `P`/`PF`
helpers in `tools.go` through the `C*` functions, the full-screen list through
the escape sequences at the bottom of it.
The list follows the same mapping mwxcol's own fzf theme uses, job for job:
rows in `grey`, the selected row in `white` on a `darker` surface with a
`violet` pointer, the filter's hits in `pink`, counts in `green`, questions in
`yellow`, errors in `red`, headers and the help line in `dark`. A machine that
is powered on is `green`, a suspended one `yellow`, and one that is simply off
is `dark` rather than red — being switched off is not a fault.
The frame follows the mapping mwxcol's own fzf theme uses, job for job: the
selected row on a `darker` surface with a `violet` pointer, the filter's hits in
`pink`, counts in `green`, questions in `yellow`, errors in `red`, headers and
the help line in `dark`.
Every line at the foot of the screen that wants an answer is that one `yellow`,
whatever kind of question it is: the sort legend, both its lines; a yes/no
question and its hint; the label in front of a snapshot name or the `YES` of a
confirmation. They are different kinds of question and one state — gvm is
waiting for a key — and that state is worth learning once, in one place and one
tone, rather than being worked out per screen. What is typed in answer stays
`white`: it is the operator's, not part of the question.
Inside the table and the sheet every colour is a role, not a decoration:
| | |
| --- | --- |
| `white` | the machine's own name, and its guest |
| `violet` | which vCenter — a kind of thing |
| `green` / `dark` / `yellow` / `red` | powered on / off / suspended / anything else |
| `blue` | addresses, paths and dates |
| `orange` | sizes and counts |
| `pink` | names a person gave: snapshots |
| `grey` | present but seldom read: host, guest os, uuids, an event's history |
| `yellow` / `red` | a load past 75 / 90 %, a snapshot past a week / a month, something that wants a look / something broken |
| `yellow` | what is being done to a machine right now: the task column |
A machine that is off is `dark` rather than red — being switched off is not a
fault. Two places lift that tone to `grey`: the selected row, where `dark` would
sit on the `darker` surface, and every value on the sheet, where the labels
beside it are `dark` themselves. Lifted, it is still visibly quieter than the
rest of the palette, so what was dimmed stays dimmed.
Colours are written as true colour (24 bit). In a pipe the `C*` functions leave
them out; the full-screen list needs a terminal anyway.
@@ -219,12 +846,20 @@ running one, so a truncated or wrong-platform download cannot install itself.
./build.sh # all platforms into ./bin
PLATFORMS="linux/amd64" ./build.sh
VERSION=1.1.0 ./build.sh # set the version instead of bumping it
go test ./... # incl. tests against govmomi's simulator
Every run bumps the patch level in `version.txt` and injects it into the
binaries. The files in `./bin` are named the way `--update` expects them in a
release: `gvm-<goos>-<goarch>` on a release tagged with the bare version number.
The automatic bump only ever touches the last number, so `VERSION=` is how a
major or minor step is made — no number of builds reaches 1.0.0 from 0.x. It is
checked to be `MAJOR.MINOR.PATCH` before anything is built: that number ends up
in the binary, in `version.txt` and on the release tag, and `--update` compares
versions number by number, so anything else would compare as older than
everything and quietly stop updates.
## Tests
`go test ./...` runs without touching any real vCenter. The list, the filter,
@@ -243,3 +878,56 @@ refused operation sends nothing, that an unavailable menu entry does not run whe
it is picked anyway, that only the exact machine name passes the confirmation,
and that removing one of two identically named snapshots removes the one that was
picked.
So are the judgements the reports are made of, which are the ones that would go
wrong quietly:
* every reason a machine can be in the issues list, one by one, and the ones
that must *not* put it there — a stopped machine, an acknowledged alarm, a
filesystem with room, this morning's snapshot, a status reported twice
* that a snapshot's size counts each delta once and not once per descendant,
which is what summing whole disk chains does
* that an unknown figure is a dash on screen and `null` in the document, for the
load, the address, the uptime, a datastore that cannot be reached and a
snapshot whose file layout could not be read
* that the ssh target is one argument and never shell code — it is a name the
guest chose for itself
* that `y` names what it copied and that an ssh login uses no local clipboard
tool — the tests say they are a login, which also keeps them off the clipboard
of whoever is running them
* every rule a resize is held to: what a running machine may and may not be
given, that the cores per socket are never changed to make a vCPU count fit,
that a refusal never names 0 vCPUs as a count that would, and — against the
simulator — that a reconfigure actually lands, read back from the server
rather than assumed, and that a command line asking for one possible change
and one impossible one makes neither
* that a `^C` during a login does not take gvm with it, and that the login still
dies of it: the signal is caught for as long as the child has the screen, and
catching is not ignoring — an ignored one would be inherited by the ssh
* that a refresh which half failed still leaves the rows and the view describing
the same list, and that the screen draws — it panicked before
* that every layout of the estate screen fits the terminal it was drawn for, at
nine widths from 60 columns up
* that a page step on that screen does not leap twice when it lands on a cluster
name, and that an unreachable host draws no load
* that the estate screen groups the hosts under their clusters, that a heading
is the sum of what is under it, that every placed machine is charged to
exactly one host, and that Enter comes back with the list filtered to it
* that a ratio says nothing where there is room to spare, and that an unknown
load does not draw the same bar as an idle one
* that the live refresh reads over the session it already has and not a new
login — with the session closed it fails rather than reconnecting — that it
notices a machine stopped behind gvm's back and says so, and that the trend
keeps its scale, is bounded, and is forgotten when a machine goes
* that the column ladder still only ever *drops* columns with the task column in
the table, and that a terminal of eighty still keeps the address
* that the completion cache survives a sweep of one server, forgets a machine
the server has forgotten, is written 0600, and that every option the
completion scripts complete after is an option `gvm.go` actually declares
* that the mailed report carries no escape sequences, and that `--json` stays
readable when a vCenter does not answer
`gvm --version`, the help and the options answered before the flag parser are
checked against each other too: anything the help promises has to be something
gvm answers, and the two options the completion scripts call are deliberately
not in the help.
+340 -14
View File
@@ -47,6 +47,73 @@ func separator() menuItem { return menuItem{} }
func (m menuItem) isSeparator() bool { return m.key == 0 && m.label == "" }
// menuFacts are the lines of the machine's sheet worth repeating above the
// actions: what it is, and whether it is running. They are the facts the choice
// underneath depends on, and having them on the same screen means not having to
// remember them from the sheet one keystroke ago.
//
// In sheet order, which is the order they are shown in.
var menuFacts = []string{"state", "guest", "hostname", "address"}
// factOrder is which of them to keep when there is not room for all four —
// most worth keeping first, the same idea as the table's expendable columns.
//
// The state comes first because every choice below depends on it. The address
// and the hostname come next: they say which machine is about to be powered off,
// which is worth having in front of one. The guest's operating system decides
// nothing here.
var factOrder = []string{"state", "address", "hostname", "guest"}
// keepFacts is as many of the facts as fit, still in sheet order.
//
// Giving them up one at a time rather than all at once is what a short terminal
// gets out of this: below twenty rows the menu and all four facts no longer fit
// on the screen together, and none of them is a worse answer than three.
func keepFacts(info []sheetLine, room int) []sheetLine {
if room >= len(info) {
return info
}
if room <= 0 {
return nil
}
keep := map[string]bool{}
for _, label := range factOrder {
if len(keep) >= room {
break
}
for _, l := range info {
if l.label == label {
keep[label] = true
break
}
}
}
out := make([]sheetLine, 0, len(keep))
for _, l := range info {
if keep[l.label] {
out = append(out, l)
}
}
return out
}
// sheetPick takes named lines out of a sheet, in the order asked for, skipping
// the ones this machine has nothing to say about. The lines come from vmDetail
// rather than being formatted again here, so the menu and the sheet cannot end up
// wording or colouring the same fact differently.
func sheetPick(sheet []sheetLine, labels []string) []sheetLine {
var out []sheetLine
for _, want := range labels {
for _, l := range sheet {
if l.label == want {
out = append(out, l)
break
}
}
}
return out
}
// openMenu reads what the machine currently has and puts the menu on screen.
// The snapshots are read here, once, so the menu knows whether reverting and
// removing are possible at all — and so the picker that follows works from a
@@ -61,13 +128,21 @@ func (b *browser) openMenu() {
return
}
// Anything said on the way through this function is held until the end of
// it. The last thing openMenu does is clear the status line — the menu is a
// fresh screen and whatever was on the list behind it does not belong on it
// — and a warning set before that was being wiped before a single frame was
// drawn. A menu that is quietly less certain than it looks is worse than no
// menu: these two warnings are exactly the ones that say so.
var warn []string
// The row is re-read first. What the menu offers is decided from the
// machine's state, and a state from the last full sweep is old enough to
// matter: it would offer to power off a machine whose guest has meanwhile
// finished shutting down. A failure here is not fatal — the menu is simply
// built from what is known — but it is said.
if err := b.refreshRow(); err != nil {
b.setStatus(colWarn, "showing what was last read: "+err.Error())
warn = append(warn, "showing what was last read: "+err.Error())
}
r = b.current()
if r == nil {
@@ -80,17 +155,36 @@ func (b *browser) openMenu() {
return
}
b.menuSnaps = snaps
b.menu = b.buildMenu(*r, snaps)
// What the machine has, and what of it may be changed while it runs. It is
// not in the sweep — it is four fields of the whole configuration document
// — so it is read here, for this one machine, so that the two hardware
// entries can be greyed out with the reason rather than accepting a number
// vCenter is going to refuse. A failure is not fatal to the rest of the
// menu: those two entries grey themselves out, saying what could not be
// read, and the warning below says it again where it cannot be missed.
b.menuSize, err = sizingOf(r.sess, r.ref)
if err != nil {
warn = append(warn, err.Error())
}
b.menu = b.buildMenu(*r, snaps, b.menuSize)
// vmDetail asks nothing of the network; the snapshots are left out of it
// because the menu only wants the four lines above.
b.menuInfo = sheetPick(vmDetail(*r, nil, ""), menuFacts)
b.menuSel = 0
b.setStatus("", "")
if len(warn) > 0 {
b.setStatus(colWarn, strings.Join(warn, " · "))
}
}
func (b *browser) closeMenu() {
b.menu, b.menuSnaps, b.menuSel = nil, nil, 0
b.menu, b.menuSnaps, b.menuInfo, b.menuSel = nil, nil, nil, 0
}
// buildMenu is the menu for one machine in its current state.
func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
func (b *browser) buildMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
noSnaps, noSnapsHint := "", ""
if len(snaps) == 0 {
noSnaps, noSnapsHint = "the machine has no snapshots", "no snapshots"
@@ -114,6 +208,12 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
{key: 'D', label: "remove ALL snapshots", why: noSnaps, hint: noSnapsHint,
run: func(b *browser, r vmRow) { b.removeAll(r, len(snaps)) }},
separator(),
// What the machine is, between what it has been and what it is doing.
// Both entries end in "..." for the same reason the snapshot ones do:
// they ask for something before anything happens.
sizeItem('c', "change the vCPU count ...", r, sz, sizeCPUs),
sizeItem('m', "change the memory ...", r, sz, sizeMemory),
separator(),
pwr('o', "power on", opPowerOn),
pwr('s', "shut down the guest", opShutdownGuest),
pwr('b', "reboot the guest", opRebootGuest),
@@ -123,6 +223,16 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
}
}
// sizeItem is one of the two hardware entries. What greys it out comes from
// sizeObjection asked its weaker question — not whether some particular number
// can be set, but whether any can right now — because the number has not been
// typed yet when the menu is drawn.
func sizeItem(key rune, label string, r vmRow, sz sizing, k sizeKind) menuItem {
short, long := sizeObjection(r, sz, k, 0)
return menuItem{key: key, label: label, why: long, hint: short,
run: func(b *browser, r vmRow) { b.resize(r, k) }}
}
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
// do the same for anyone who would rather read than remember.
func (b *browser) menuKey(k key) {
@@ -352,6 +462,78 @@ func (b *browser) power(r vmRow, op powerOp) {
b.done(msg)
}
// resize changes what the machine has: the value is typed, checked, confirmed
// and only then sent.
//
// The configuration is read again here rather than taken from the menu that was
// just drawn. It is the same read, a moment later, and the moment matters: the
// menu's grey-out only says a change of this kind is possible at all, and
// between drawing it and answering the question somebody else may have started
// the machine — which turns "give it 4 GB less" from a reconfigure into
// something vSphere will not do.
//
// The question at the end is the plain one, not the page that asks for YES to be
// typed. That page is for losing something: pulling the plug, reverting,
// removing snapshots. Nothing here is lost — a number set wrongly is set back —
// and a confirmation asked for everything is a confirmation nobody reads.
func (b *browser) resize(r vmRow, k sizeKind) {
b.closeMenu()
if r.sess == nil {
b.setStatus(colErr, "no connection to "+r.vc.Name)
return
}
sz, err := sizingOf(r.sess, r.ref)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
if err := checkSize(r, sz, k, 0); err != nil {
b.setStatus(colWarn, err.Error())
return
}
typed, ok := b.input(sizePrompt(r, sz, k))
if !ok || typed == "" {
b.setStatus(colDim, "nothing done")
return
}
want, err := parseSize(k, typed)
if err != nil {
b.setStatus(colWarn, err.Error())
return
}
if err := checkSize(r, sz, k, want); err != nil {
b.setStatus(colWarn, err.Error())
return
}
from, to := k.shown(sz.now(k)), k.shown(want)
if !b.ask(SF("%s on %s: %s → %s?", r.name, r.vc.Name, from, to)) {
b.setStatus(colDim, "nothing done")
return
}
b.working(SF("%s: %s → %s ...", r.name, from, to))
msg, err := runResize(r.sess, r, sz, k, want)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
b.done(msg)
}
// sizePrompt is the label on that line. It carries the current value, so the
// number being replaced is in front of the person replacing it, and for memory
// the unit as well — a field that takes 16 and means gigabytes has to say so
// where it is typed, not in a manual.
func sizePrompt(r vmRow, sz sizing, k sizeKind) string {
if k == sizeCPUs {
return SF("vCPUs for %s (now %d): ", r.name, sz.cpus)
}
return SF("memory for %s in GB (now %s, or 512m): ", r.name, k.shown(sz.memoryMB))
}
// working puts a line on the screen before an operation that will block the loop
// — a vCenter task can take minutes, and a terminal that goes silent for that
// long looks like a hang.
@@ -403,12 +585,22 @@ func (b *browser) refreshRow() error {
if r.sess == nil {
return errf("no connection to %s", r.vc.Name)
}
// The same properties the sweep reads, so a re-read row is the same kind of
// row as its neighbours: one that lost its snapshots or its task on being
// refreshed would quietly disagree with the rest of the table.
var fresh mo.VirtualMachine
vm := object.NewVirtualMachine(r.sess.client.Client, r.ref)
if err := vm.Properties(r.sess.ctx, r.ref, []string{"summary", "guest"}, &fresh); err != nil {
if err := vm.Properties(r.sess.ctx, r.ref, sweepProps, &fresh); err != nil {
return err
}
r.vm = fresh
r.snaps = snapshotsIn(fresh.Snapshot)
r.task = nil
if busy := runningTasks(r.sess, []mo.VirtualMachine{fresh}); len(busy) > 0 {
if t, ok := busy[r.ref]; ok {
r.task = &t
}
}
return nil
}
@@ -462,13 +654,35 @@ func (b *browser) renderMenu() {
return
}
// The actions are what the menu is for; the facts above them are a courtesy.
// On a terminal too short for both, the facts are what goes — a menu whose
// entries have scrolled off the top is worse than one without a header — and
// they go one at a time, least useful first (keepFacts).
//
// The three is the title, the blank line under it, and the blank line under
// the facts; the two at the end is the status line and the help line.
info := keepFacts(b.menuInfo, rows-2-3-len(b.menu))
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
segLine(&sb, cols, seg{colTitle, "Actions — " + r.name},
seg{colDim, SF(" %s, %s, %s", r.vc.Name, r.host, r.powerLong())})
segLine(&sb, cols,
seg{colTitle, "Actions — " + r.name},
seg{colDim, " "},
seg{colWhere, r.vc.Name},
seg{colDim, SF(" · %s · %s", r.vc.Datacenter, r.host)})
segLine(&sb, cols)
used := 2
for _, l := range info {
segLine(&sb, cols,
seg{colLabel, padRight(l.label, labelWidth)},
seg{l.col, l.value})
used++
}
if len(info) > 0 {
segLine(&sb, cols)
used++
}
for i, m := range b.menu {
if m.isSeparator() {
segLine(&sb, cols, seg{colDim, SR("─", min(cols, 46))})
@@ -500,7 +714,8 @@ func (b *browser) renderMenu() {
if b.status != "" {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim, "lowercase asks the guest, uppercase acts at the hypervisor"})
segLine(&sb, cols, seg{colDim,
"of the power pairs, lowercase asks the guest and uppercase the hypervisor"})
}
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
b.write(sb.String())
@@ -536,7 +751,7 @@ func (b *browser) renderPicker() {
if i == p.sel {
pointer, col = "▸ ", colRowSel
}
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.label()})
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.line()})
}
for i := end - p.scroll; i < visible; i++ {
sb.WriteString(scrEOL + "\r\n")
@@ -612,29 +827,140 @@ func (b *browser) renderConfirm() {
b.write(sb.String())
}
// wrap breaks a sentence into lines of at most width, on spaces.
// wrap breaks a sentence into lines of at most width, on spaces — and, where
// there are none to break on, in the middle of the word.
//
// The second half is not a nicety. A datastore path is one long word, and on a
// narrow terminal the interesting end of it is the end; a line that simply runs
// past the edge loses exactly the part being looked up.
func wrap(s string, width int) []string {
if width < 8 {
width = 8
}
var out []string
line := ""
flush := func() {
if line != "" {
out = append(out, line)
line = ""
}
}
for _, w := range strings.Fields(s) {
for len([]rune(w)) > width { // longer than a whole line: chop it
flush()
r := []rune(w)
out = append(out, string(r[:width]))
w = string(r[width:])
}
switch {
case line == "":
line = w
case len([]rune(line))+1+len([]rune(w)) <= width:
line += " " + w
default:
out = append(out, line)
flush()
line = w
}
}
if line != "" {
out = append(out, line)
}
flush()
if len(out) == 0 {
return []string{""}
}
return out
}
// ------------------------------------------------------- the sheet's own keys
//
// The four below change nothing, on the machine or on the vCenter: they read its
// history, copy its address, open it somewhere else. That is why they are on
// letters of the sheet itself rather than in the action menu, which is for the
// things one has to be sure about before pressing.
// showEvents puts the machine's recent history at the foot of its sheet and
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
// machine is one call, and this is another.
func (b *browser) showEvents(r vmRow) {
b.working(SF("reading the events of %s ...", r.name))
lines, err := eventsOf(r)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
b.events, b.eventsOf = lines, r.id()
if len(lines) == 0 {
b.setStatus(colDim, "vCenter has no recent events for "+r.name)
} else {
b.setStatus(colInfo, SF("%s of %s", plural(len(lines), "event"), r.name))
}
b.openDetail() // rebuilt, so the sheet carries them
b.scrollToSection("events")
}
// hasAddress reports whether there is somewhere to connect to, and says so where
// the key was pressed when there is not. A guest that is not talking reports no
// address, and ssh or copy doing nothing at all would read as gvm having hung —
// the greyed-out menu entries these two replaced said as much in their own line.
func (b *browser) hasAddress(r vmRow) bool {
if r.sshTarget() != "" {
return true
}
b.setStatus(colWarn, SF("%s has no address or hostname — its guest is not reporting one", r.name))
return false
}
// sshTo logs in to the guest. The terminal goes back to what it was for as long
// as that lasts (guest.go), and the list is redrawn afterwards.
func (b *browser) sshTo(r vmRow) {
target := r.sshTarget()
argv := sshCommand(b.ssh, target)
err := b.runInTerminal(argv)
switch {
case err == nil:
b.setStatus(colDim, "back from "+target)
case interrupted(err):
// Ctrl-C during a login is somebody changing their mind, not a fault:
// gvm now survives it (holdTerminalSignals) and says so in the colour
// of an ordinary remark.
b.setStatus(colDim, "the login to "+target+" was interrupted")
default:
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
}
}
// copyAddress puts what `h` would connect to where the next paste will find it.
//
// What it says is longer than "copied web01.example" was, and deliberately: a
// clipboard is invisible, so the line has to name what went into it — the
// hostname or the address, since the sheet shows both — and which clipboard it
// is. Where the escape sequence was the only route it also says so, because that
// is the case where it may quietly not have arrived.
func (b *browser) copyAddress(r vmRow) {
target, kind := r.sshTargetIs()
if where := b.toClipboard(target); where != "" {
b.setStatus(colInfo, SF("copied its %s %s to the clipboard (%s)", kind, target, where))
return
}
b.setStatus(colWarn, SF("sent its %s %s to the terminal's own clipboard — it has to allow that (OSC 52)", kind, target))
}
// openVSphere opens the machine's page in the vSphere client, and says the URL
// either way: a workstation with no browser to hand off to still gets the one
// thing that was wanted, and so does anyone running gvm over ssh.
func (b *browser) openVSphere(r vmRow) {
url := vsphereURL(r)
if url == "" {
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
return
}
if err := openURL(url); err != nil {
b.toClipboard(url) // the same two routes as `y`
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
return
}
b.setStatus(colInfo, "opened "+url)
}
+359 -5
View File
@@ -3,10 +3,13 @@ package main
import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
@@ -134,7 +137,7 @@ func TestMenuAvailability(t *testing.T) {
}
someSnaps := []snapEntry{{name: "s1", created: "01.01.2026 00:00"}}
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps)
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps, testSizing())
for _, k := range []rune{'n', 'r', 'd', 'D', 's', 'b', 'S', 'B'} {
if !find(running, k).available() {
t.Errorf("%q not offered for a running machine with Tools: %s", string(k), find(running, k).why)
@@ -144,7 +147,7 @@ func TestMenuAvailability(t *testing.T) {
t.Error("power on is offered for a machine that is already running")
}
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps)
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps, testSizing())
for _, k := range []rune{'s', 'b'} {
if find(noTools, k).available() {
t.Errorf("%q offered without VMware Tools", string(k))
@@ -156,7 +159,7 @@ func TestMenuAvailability(t *testing.T) {
}
}
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps)
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps, testSizing())
if !find(stopped, 'o').available() {
t.Error("power on is not offered for a stopped machine")
}
@@ -167,7 +170,7 @@ func TestMenuAvailability(t *testing.T) {
}
// Without snapshots there is nothing to revert to or remove.
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil)
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil, testSizing())
for _, k := range []rune{'r', 'd', 'D'} {
if find(bare, k).available() {
t.Errorf("%q offered for a machine with no snapshots", string(k))
@@ -366,6 +369,19 @@ func TestWrap(t *testing.T) {
if len(wrap("", 20)) != 1 {
t.Error("wrapping nothing should still give one (empty) line")
}
// A word with nowhere to break — a datastore path — is chopped rather than
// left to run off the edge.
long := strings.Repeat("abcdefghij", 5) // 50 characters, no space
got = wrap("path "+long, 12)
for _, l := range got {
if len([]rune(l)) > 12 {
t.Errorf("an unbreakable word was left %d wide: %q", len([]rune(l)), l)
}
}
if joined := strings.ReplaceAll(strings.Join(got, ""), " ", ""); joined != "path"+long {
t.Errorf("chopping lost something: %q", joined)
}
}
// The menu's own column and the message shown when an entry is picked have to
@@ -379,7 +395,7 @@ func TestMenuHintAndReasonAgree(t *testing.T) {
stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown),
stateRow(types.VirtualMachinePowerStateSuspended, toolsUp),
} {
for _, m := range b.buildMenu(r, nil) {
for _, m := range b.buildMenu(r, nil, testSizing()) {
if m.isSeparator() {
continue
}
@@ -448,3 +464,341 @@ func TestPowerOperationsSettleWhereTheyShould(t *testing.T) {
}
}
}
// The snapshot tree. Which state descends from which is the whole point of a
// snapshot list, so the drawing is checked branch by branch rather than by
// eyeballing it once.
func TestSnapshotTreeIsDrawn(t *testing.T) {
when := time.Date(2026, 9, 1, 2, 0, 0, 0, time.UTC)
snap := func(id, name string, children ...types.VirtualMachineSnapshotTree) types.VirtualMachineSnapshotTree {
return types.VirtualMachineSnapshotTree{
Snapshot: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: id},
Name: name,
CreateTime: when,
ChildSnapshotList: children,
}
}
// root-a
// ├─ child-1
// │ └─ grandchild
// └─ child-2
// root-b
roots := []types.VirtualMachineSnapshotTree{
snap("s1", "root-a",
snap("s2", "child-1", snap("s3", "grandchild")),
snap("s4", "child-2"),
),
snap("s5", "root-b"),
}
current := types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "s3"}
got := flattenSnapshots(roots, current)
want := []string{
"root-a",
"├─ child-1",
"│ └─ grandchild",
"└─ child-2",
"root-b",
}
if len(got) != len(want) {
t.Fatalf("the tree has %d entries, want %d", len(got), len(want))
}
for i, w := range want {
if prefix := got[i].prefix + got[i].name; prefix != w {
t.Errorf("line %d is %q, want %q", i, prefix, w)
}
}
// Parents come before their children, and each entry keeps its own reference
// — that is what the picker hands to the revert.
for i, ref := range []string{"s1", "s2", "s3", "s4", "s5"} {
if got[i].ref.Value != ref {
t.Errorf("entry %d is %s, want %s", i, got[i].ref.Value, ref)
}
}
// The one the machine is running from says so, and only that one.
for _, e := range got {
if marked := strings.Contains(e.line(), "current"); marked != (e.ref.Value == "s3") {
t.Errorf("%s: marked as current = %v", e.name, marked)
}
}
if !strings.Contains(got[2].line(), "01.09.2026") {
t.Errorf("the line carries no date: %q", got[2].line())
}
}
// A single snapshot is a tree of one, and must not be given a branch to hang off.
func TestLoneSnapshotHasNoBranch(t *testing.T) {
one := flattenSnapshots([]types.VirtualMachineSnapshotTree{{
Snapshot: types.ManagedObjectReference{Value: "s1"}, Name: "nightly",
}}, types.ManagedObjectReference{})
if len(one) != 1 {
t.Fatalf("one snapshot flattened to %d entries", len(one))
}
if one[0].prefix != "" {
t.Errorf("a lone snapshot is drawn with the branch %q", one[0].prefix)
}
if flattenSnapshots(nil, types.ManagedObjectReference{}) != nil {
t.Error("no snapshots should flatten to nothing at all")
}
}
// The facts above the choices come out of the sheet, so the two cannot word or
// colour the same fact differently.
func TestMenuFactsComeFromTheSheet(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
sheet := vmDetail(r, nil, "")
info := sheetPick(sheet, menuFacts)
if len(info) != len(menuFacts) {
t.Fatalf("the menu shows %d of the %d facts: %v", len(info), len(menuFacts), info)
}
for i, want := range menuFacts {
if info[i].label != want {
t.Errorf("fact %d is %q, want %q — the order asked for is the order shown", i, info[i].label, want)
}
var from sheetLine
for _, l := range sheet {
if l.label == want {
from = l
}
}
if info[i] != from {
t.Errorf("the menu's %q line is %+v, the sheet's %+v", want, info[i], from)
}
}
}
// A machine that says nothing about its guest gets fewer lines, not lines with a
// label and nothing after them.
func TestMenuFactsSkipWhatIsUnknown(t *testing.T) {
bare := vmRow{
vc: VCenter{Name: "v38"},
name: "half-there",
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{Name: "half-there"},
Runtime: types.VirtualMachineRuntimeInfo{PowerState: types.VirtualMachinePowerStatePoweredOff},
}},
}
for _, l := range sheetPick(vmDetail(bare, nil, ""), menuFacts) {
if strings.TrimSpace(l.value) == "" {
t.Errorf("the %q line is shown with nothing after it", l.label)
}
}
if got := sheetPick(nil, menuFacts); got != nil {
t.Errorf("picking from an empty sheet gave %v", got)
}
}
// On a terminal too short for both, the facts go and the choices stay: a menu
// whose entries have scrolled off the top is worse than one without a header.
func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
t.Setenv("COLUMNS", "100")
// The facts are given up one at a time, least useful first, so what is
// checked here is the one that goes third: with room for three facts the
// hostname is still there, with room for two it is not.
for _, c := range []struct {
rows string
wantFacts bool
}{
{"30", true}, // room for both, all four facts
{"22", true}, // room for three of them, the hostname among them
{"21", false}, // room for two: the state and the address
{"16", false}, // no room at all, so the choices have the screen
} {
t.Setenv("LINES", c.rows)
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
b.menu = b.buildMenu(r, nil, testSizing())
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
frame := renderToPipe(t, b, b.renderMenu)
if got := strings.Contains(stripEscapes(frame), "hostname"); got != c.wantFacts {
t.Errorf("%s rows: facts shown = %v, want %v", c.rows, got, c.wantFacts)
}
// The choices are there either way, and so is the machine's name.
for _, want := range []string{"take a snapshot", "power off", "web01"} {
if !strings.Contains(stripEscapes(frame), want) {
t.Errorf("%s rows: the menu lost %q", c.rows, want)
}
}
}
}
// renderToPipe draws one screen into a pipe and hands back what was written.
func renderToPipe(t *testing.T, b *browser, draw func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
b.tty = w
draw()
w.Close()
out, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
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
}
if strings.Contains(code, "cmd.Wait()") {
continue // an exec.Cmd — a browser being handed a URL (guest.go)
}
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")
}
}
// A menu is allowed to be built from less than it wanted, but never to look as
// certain as one that was. openMenu clears the status line as its last act —
// the menu is a fresh screen — and a warning set on the way there was being
// wiped before a single frame was drawn.
func TestTheMenuKeepsWhatItHadToWarnAbout(t *testing.T) {
// A row with no session: the configuration cannot be read, which is the
// warning this is about.
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
// openMenu leaves early without a session at all, so the warning is checked
// where it is raised: a sizing that could not be read must both grey the
// entries out and leave something on the status line.
if _, err := sizingOf(nil, r.ref); err == nil {
t.Error("a machine with no connection read its configuration anyway")
}
menu := b.buildMenu(r, nil, sizing{})
for _, k := range []rune{'c', 'm'} {
for _, m := range menu {
if m.key != k {
continue
}
if m.available() {
t.Errorf("%q is offered on a machine whose configuration is unknown", string(k))
}
if m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
}
}
// testSizing is a plausible machine's hardware for the menu tests: four vCPUs
// in one socket, eight gigabytes, and none of the three hot-plug settings — the
// way most machines are actually built, and the case where the two hardware
// entries are only offered on a machine that is switched off.
func testSizing() sizing {
return sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
}
+1058 -219
View File
File diff suppressed because it is too large Load Diff
+1101 -17
View File
File diff suppressed because it is too large Load Diff
+26 -8
View File
@@ -13,20 +13,38 @@
#
# Override the platform list to build just one:
# PLATFORMS="linux/amd64" ./build.sh
#
# Set the version instead of bumping it:
# VERSION=1.0.0 ./build.sh
# The automatic bump only ever touches the last number, so no number of builds
# ever reaches 1.0.0 from 0.x — a major or minor step is a decision, and this is
# how it is made.
set -e
cd "$(dirname "$0")"
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64"}
V=$(cat version.txt 2>/dev/null || echo 0.1.0)
if [ -n "$VERSION" ]; then
# Checked rather than trusted: this number goes into the binary, into
# version.txt and onto the release tag, and `gvm --update` compares versions
# number by number — something that is not MAJOR.MINOR.PATCH would compare as
# older than everything and quietly stop updates.
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "VERSION=$VERSION is not MAJOR.MINOR.PATCH" >&2
exit 1
fi
NV="$VERSION"
else
V=$(cat version.txt 2>/dev/null || echo 0.1.0)
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 0.1.9 -> 0.1.10)
MAJOR=${V%%.*}
REST=${V#*.}
MINOR=${REST%%.*}
PATCH=${REST#*.}
PATCH=$((PATCH + 1))
NV="$MAJOR.$MINOR.$PATCH"
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 0.1.9 -> 0.1.10)
MAJOR=${V%%.*}
REST=${V#*.}
MINOR=${REST%%.*}
PATCH=${REST#*.}
PATCH=$((PATCH + 1))
NV="$MAJOR.$MINOR.$PATCH"
fi
# earlier versions built ./gvm in the repo root; drop it so nothing keeps
# running a stale binary from a path that is no longer written
+359
View File
@@ -0,0 +1,359 @@
// complete.go — shell completion, and the inventory cache behind it.
//
// The names one types at gvm are machine names, and they are long, and there
// are hundreds of them on three servers. Completing them is what makes the
// non-interactive half usable — `gvm -v v308 snap -l dbse<tab>` — but it cannot
// be done by asking the vCenters: a shell completion runs on every Tab and has
// to answer in milliseconds, and three logins take seconds.
//
// So it answers out of what gvm last saw. Every sweep of the machine list
// leaves the names behind in the cache directory, per vCenter and with the time
// on them, and `--complete-vms` reads that file and nothing else. The cache is
// therefore always exactly as fresh as the last time somebody looked at the
// list — which is the right currency for a Tab key, and no currency at all for
// anything that acts on a machine. Nothing else in gvm reads this file: every
// command resolves the name it was given against the server itself.
//
// `gvm config` says how old it is, because a completion that quietly offers a
// machine deleted last month is a small mystery worth being able to explain.
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/integrii/flaggy"
)
// completionFlagNames are the options answered before the flag parser, the same
// way the update options are (gvm.go). They are what the generated scripts call
// on every Tab, so they must work on a machine whose configuration is broken —
// and must never print anything but the candidates. Nobody types them, which is
// why they are deliberately not in the help.
var completionFlagNames = []string{"--complete-vms", "--complete-vcenters"}
// isCompletionFlag reports whether this argument is one of them, so that the
// help can be checked against what is actually answered — the same guard the
// update options have (see the tests).
func isCompletionFlag(arg string) bool { return contains(completionFlagNames, arg) }
// completionFlags answers those options and reports whether it did.
func completionFlags() bool {
args := os.Args[1:]
for i, a := range args {
if !contains(completionFlagNames, a) {
continue
}
// The word after the option, skipping the "--" the completion scripts
// put in front of it so that a prefix beginning with a dash cannot be
// taken for an option of gvm's own.
rest := ""
for _, a := range args[i+1:] {
if a == "--" {
continue
}
rest = a
break
}
switch a {
case "--complete-vms":
for _, name := range cachedNames(rest) {
P(name)
}
case "--complete-vcenters":
for _, name := range cachedVCenters() {
P(name)
}
}
return true
}
return false
}
// ------------------------------------------------------------------ the cache
// inventoryPath is where the names are kept: the cache directory, beside the
// update note, and never in the configuration — losing it costs one Tab that
// offers nothing.
func inventoryPath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, selfUpdate.asset, "inventory"), nil
}
// cacheEntry is one machine as the cache remembers it.
type cacheEntry struct {
vc string
when time.Time
name string
}
// saveInventory writes the machines of the servers that answered.
//
// The servers that did not are left exactly as they were: a vCenter that is
// down, or that this run was not asked about (`-v v308`), must not lose its
// machines out of the cache — the point of completion is to work when things
// are not working. Best effort throughout: a cache that cannot be written is
// not worth a word on the screen, let alone an error.
func saveInventory(answered []string, rows []vmRow) {
path, err := inventoryPath()
if err != nil {
return
}
fresh := map[string]bool{}
for _, name := range answered {
fresh[name] = true
}
kept := make([]cacheEntry, 0, len(rows))
for _, e := range loadInventory() {
if !fresh[e.vc] {
kept = append(kept, e)
}
}
now := time.Now()
for _, r := range rows {
kept = append(kept, cacheEntry{vc: r.vc.Name, when: now, name: r.name})
}
var sb strings.Builder
for _, e := range kept {
// One line per machine: the server, when it was read, and the name.
// Tab separated because a machine name may hold a space and never a tab.
sb.WriteString(e.vc + "\t" + e.when.Format(time.RFC3339) + "\t" + e.name + "\n")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
tmp := path + ".new"
if os.WriteFile(tmp, []byte(sb.String()), 0o600) != nil {
return
}
if os.Rename(tmp, path) != nil {
os.Remove(tmp)
}
}
// loadInventory reads it back. A line that does not parse is dropped rather
// than reported: this file is a convenience and a broken one means one Tab
// without an answer.
func loadInventory() []cacheEntry {
path, err := inventoryPath()
if err != nil {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var out []cacheEntry
for _, line := range strings.Split(string(data), "\n") {
f := strings.Split(line, "\t")
if len(f) != 3 || f[0] == "" || f[2] == "" {
continue
}
when, err := time.Parse(time.RFC3339, f[1])
if err != nil {
continue
}
out = append(out, cacheEntry{vc: f[0], when: when, name: f[2]})
}
return out
}
// cachedNames are the machine names that begin with the prefix, once each and
// in order. Once each because the same name on two vCenters is one thing to
// type; in order because a completion list that moves about is a completion
// list nobody reads.
func cachedNames(prefix string) []string {
seen := map[string]bool{}
var out []string
for _, e := range loadInventory() {
if seen[e.name] || !strings.HasPrefix(strings.ToLower(e.name), strings.ToLower(prefix)) {
continue
}
seen[e.name] = true
out = append(out, e.name)
}
sort.Strings(out)
return out
}
// cachedVCenters are the servers the cache has seen, which is what `-v`
// completes against. It comes out of the cache and not out of ~/.gvmrc on
// purpose: reading the configuration would seal a password standing in the
// clear in it, and a Tab key must not rewrite a file.
func cachedVCenters() []string {
seen := map[string]bool{}
var out []string
for _, e := range loadInventory() {
if seen[e.vc] {
continue
}
seen[e.vc] = true
out = append(out, e.vc)
}
sort.Strings(out)
return out
}
// inventoryAge is what `gvm config` says about the cache: how many machines it
// holds and how long ago each server was read.
func inventoryAge() string {
entries := loadInventory()
if len(entries) == 0 {
return "-"
}
newest := map[string]time.Time{}
var order []string
for _, e := range entries {
if _, seen := newest[e.vc]; !seen {
order = append(order, e.vc)
}
if e.when.After(newest[e.vc]) {
newest[e.vc] = e.when
}
}
sort.Strings(order)
parts := make([]string, 0, len(order))
for _, vc := range order {
parts = append(parts, SF("%s %s ago", vc, uptime(time.Since(newest[vc]))))
}
return SF("%s from %s", plural(len(entries), "machine"), strings.Join(parts, ", "))
}
// ----------------------------------------------------------------- the scripts
// vmFlags are the options that take a machine name and vcFlags the ones that
// take a vCenter — the only thing about gvm's own command line that the
// completion has to be told, because it is the only thing flaggy's generated
// script cannot know: it knows every option there is, and nothing about what
// any of them means.
//
// completionOptionsAreReal (see the tests) checks each one against gvm.go, so
// an option renamed there cannot leave a completion quietly offering the wrong
// thing.
var (
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
"--off", "--reset", "--vm"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
// installedFunction is the completion function flaggy's own script installs,
// read off the line where it installs it — "compdef _gvm gvm" in zsh,
// "complete -F _gvm_complete gvm" in bash.
//
// Taken from the script rather than written down here, because the name is
// flaggy's to choose: it builds it out of the parser's name, and a version that
// built it differently would leave the addendum below calling a function that
// does not exist, which in a shell is a completion that silently offers
// nothing.
func installedFunction(script string) string {
for _, line := range strings.Split(script, "\n") {
f := strings.Fields(line)
switch {
case len(f) >= 2 && f[0] == "compdef":
return f[1]
case len(f) >= 3 && f[0] == "complete" && f[1] == "-F":
return f[2]
}
}
return ""
}
// completionRequest recognises `gvm completion <shell>` — the subcommand flaggy
// offers and lists in the help, answered here instead so that the script can
// carry the machine names as well. A shell this does not know is left to
// flaggy, whose own message names the ones it can write.
func completionRequest(args []string) (shell string, ok bool) {
if len(args) < 2 || !strings.EqualFold(args[0], "completion") {
return "", false
}
return strings.ToLower(args[1]), true
}
// completionScript is flaggy's script for that shell with the names put on top:
//
// eval "$(gvm completion zsh)"
// gvm completion bash > /etc/bash_completion.d/gvm
//
// flaggy generates the half that is about gvm's own command line, from the
// parser itself, so no list here can fall behind the options that exist. This
// adds the half that is about the estate: after an option that takes a machine,
// the machines; after -v, the servers. Both go through --complete-vms, which
// reads the cache and never a vCenter.
func completionScript(shell, flaggyScript string) (string, bool) {
names, ok := map[string]func(string) string{"zsh": zshNames, "bash": bashNames}[shell]
if !ok {
return "", false
}
// Without a function of flaggy's to fall back to there is nothing to add
// to: half a completion — machine names and no options — would be worse
// than the whole of flaggy's, which is what this then leaves in place.
delegate := installedFunction(flaggyScript)
if delegate == "" {
return flaggyScript, true
}
return flaggyScript + names(delegate), true
}
func zshNames(delegate string) string {
return strings.Join([]string{
"",
"# gvm: the machines and the servers, from what gvm last saw",
"_gvm_names() {",
" local prev=${words[CURRENT-1]} cur=${words[CURRENT]}",
" case $prev in",
" " + strings.Join(vmFlags, "|") + ")",
" compadd -- ${(f)\"$(gvm --complete-vms -- ${cur} 2>/dev/null)\"}; return;;",
" " + strings.Join(vcFlags, "|") + ")",
" compadd -- ${(f)\"$(gvm --complete-vcenters 2>/dev/null)\"}; return;;",
" esac",
" " + delegate + " \"$@\"",
"}",
"compdef _gvm_names gvm",
"",
}, "\n")
}
func bashNames(delegate string) string {
return strings.Join([]string{
"",
"# gvm: the machines and the servers, from what gvm last saw",
"_gvm_names() {",
" local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]}",
" case $prev in",
" " + strings.Join(vmFlags, "|") + ")",
" COMPREPLY=($(compgen -W \"$(gvm --complete-vms -- \"$cur\" 2>/dev/null)\" -- \"$cur\")); return;;",
" " + strings.Join(vcFlags, "|") + ")",
" COMPREPLY=($(compgen -W \"$(gvm --complete-vcenters 2>/dev/null)\" -- \"$cur\")); return;;",
" esac",
" " + delegate,
"}",
"complete -F _gvm_names gvm",
"",
}, "\n")
}
// flaggyCompletion is flaggy's own script for that shell, out of the parser as
// it stands — every subcommand and every option, without a list here to fall
// behind them. Empty for a shell flaggy does not write, which is the caller's
// signal to let flaggy answer for itself.
func flaggyCompletion(shell string) string {
switch shell {
case "zsh":
return flaggy.GenerateZshCompletion(flaggy.DefaultParser)
case "bash":
return flaggy.GenerateBashCompletion(flaggy.DefaultParser)
}
return ""
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/integrii/flaggy"
)
// cacheHome points the cache directory at a temporary one, so a test never
// reads or writes the cache of the person running it. os.UserCacheDir goes by
// HOME on macOS and by XDG_CACHE_HOME on Linux, so both are set.
func cacheHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache"))
return dir
}
func cacheRows(vc string, names ...string) []vmRow {
var rows []vmRow
for _, n := range names {
rows = append(rows, vmRow{vc: VCenter{Name: vc}, name: n})
}
return rows
}
func TestInventoryCacheRoundTrip(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
if got := cachedNames(""); !slices.Equal(got, []string{"db01", "web01"}) {
t.Errorf("the cache gave back %v", got)
}
if got := cachedVCenters(); !slices.Equal(got, []string{"v308"}) {
t.Errorf("the servers came back as %v", got)
}
if got := cachedNames("web"); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the prefix web matched %v", got)
}
// A shell completes what has been typed so far, whichever case it is in.
if got := cachedNames("WEB"); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the prefix WEB matched %v", got)
}
if got := cachedNames("nothing-like-this"); len(got) != 0 {
t.Errorf("a prefix that matches nothing gave %v", got)
}
}
// A sweep of one server must not lose the others' machines. The point of
// completing out of a cache is that it works when a vCenter is down — or when
// the last command was `gvm -v v308 vm -l`.
func TestASweepOfOneServerKeepsTheOthers(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308", "v108"},
append(cacheRows("v308", "web01"), cacheRows("v108", "old01")...))
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "web02"))
if got := cachedNames(""); !slices.Equal(got, []string{"old01", "web01", "web02"}) {
t.Errorf("after a sweep of one server the cache holds %v", got)
}
if got := cachedVCenters(); !slices.Equal(got, []string{"v108", "v308"}) {
t.Errorf("the servers came back as %v", got)
}
}
// A machine that has gone is gone from the cache of the server it was on.
func TestTheCacheForgetsWhatTheServerHasForgotten(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "temp01"))
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
if got := cachedNames(""); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the cache still holds %v", got)
}
}
// A cache that cannot be read is one Tab without an answer, never an error.
func TestABrokenCacheIsSilent(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
path, err := inventoryPath()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("nonsense\nv308\tnot-a-date\tweb01\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := cachedNames(""); len(got) != 0 {
t.Errorf("a broken cache offered %v", got)
}
if got := inventoryAge(); got != "-" {
t.Errorf("a broken cache is described as %q", got)
}
}
// The file holds machine names read off a vCenter, and the cache directory is
// not private, so it is written the way the configuration is.
func TestTheCacheIsNotWorldReadable(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
path, _ := inventoryPath()
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Errorf("the cache is mode %v", st.Mode().Perm())
}
}
// `gvm config` says how old the cache is, because a completion offering a
// machine deleted last month should be explicable.
func TestConfigSaysHowOldTheCacheIs(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
got := inventoryAge()
if !strings.Contains(got, "2 machines") || !strings.Contains(got, "v308") {
t.Errorf("inventoryAge = %q", got)
}
if !strings.Contains(got, "ago") {
t.Errorf("inventoryAge does not say when: %q", got)
}
}
// The scripts complete against the cache and never against a vCenter: a Tab key
// that logs in three times is a Tab key nobody presses twice.
func TestTheCompletionScriptsAskGvmAndNothingElse(t *testing.T) {
for _, shell := range []string{"zsh", "bash"} {
script, ok := completionScript(shell, "# flaggy's half\n_half() {\n}\ncompdef _half gvm\n")
if !ok {
t.Fatalf("no script for %s", shell)
}
if !strings.Contains(script, "--complete-vms") || !strings.Contains(script, "--complete-vcenters") {
t.Errorf("the %s script does not ask gvm for the names:\n%s", shell, script)
}
// flaggy's half is carried, not replaced: that is the half that knows
// every subcommand and every option.
if !strings.Contains(script, "# flaggy's half") {
t.Errorf("the %s script threw flaggy's own half away", shell)
}
if !strings.Contains(script, "_gvm_names") {
t.Errorf("the %s script does not install itself", shell)
}
if !strings.Contains(script, "_half") {
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
}
if !strings.Contains(script, "_half") {
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
}
}
// A shell neither half knows is left to flaggy, whose own message names the
// ones it can write.
if _, ok := completionScript("klingon", ""); ok {
t.Error("a script was written for a shell nobody has")
}
}
// The addendum falls back to the function flaggy's own script installs, whose
// name is read off the script rather than written down twice. If flaggy ever
// names it differently the wrapper follows it there.
func TestTheAddendumDelegatesToFlaggysOwnFunction(t *testing.T) {
for _, c := range []struct{ script, want string }{
{"_gvm() {\n}\ncompdef _gvm gvm\n", "_gvm"},
{"_gvm_complete() {\n}\ncomplete -F _gvm_complete gvm\n", "_gvm_complete"},
{"_thing() {\n}\ncompdef _some_other_name thing\n", "_some_other_name"},
{"nothing installs anything here\n", ""},
} {
if got := installedFunction(c.script); got != c.want {
t.Errorf("installedFunction found %q, want %q", got, c.want)
}
}
// And on the real thing: flaggy builds the name out of the parser's name,
// which is the only part of the parser this depends on.
flaggy.SetName("gvm")
for _, shell := range []string{"zsh", "bash"} {
generated := flaggyCompletion(shell)
delegate := installedFunction(generated)
if delegate == "" {
t.Fatalf("nothing could be found to delegate to in flaggy's %s script:\n%s", shell, generated)
}
if !strings.Contains(generated, delegate+"()") {
t.Errorf("flaggy's %s script installs %s without defining it", shell, delegate)
}
script, _ := completionScript(shell, generated)
if !strings.Contains(script, delegate) {
t.Errorf("the %s addendum does not fall back to %s", shell, delegate)
}
}
}
// A shell script that is not valid shell is worse than none: the shell says so
// on every Tab. Both are checked with the shell's own parser, where there is one.
func TestTheCompletionScriptsAreValidShell(t *testing.T) {
flaggy.SetName("gvm")
for _, c := range []struct{ shell, flag string }{{"zsh", "-n"}, {"bash", "-n"}} {
if _, err := exec.LookPath(c.shell); err != nil {
t.Logf("no %s here to check with", c.shell)
continue
}
script, ok := completionScript(c.shell, flaggyCompletion(c.shell))
if !ok {
t.Fatalf("no %s script", c.shell)
}
path := filepath.Join(t.TempDir(), "completion."+c.shell)
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(c.shell, c.flag, path).CombinedOutput()
if err != nil {
t.Errorf("the %s script does not parse: %v\n%s\n%s", c.shell, err, out, script)
}
}
}
// `gvm completion <shell>` is flaggy's own subcommand, answered a step earlier.
// It has to be recognised exactly as flaggy would recognise it, or the two
// disagree about what the command line said.
func TestCompletionRequest(t *testing.T) {
for _, c := range []struct {
args []string
shell string
ok bool
}{
{[]string{"completion", "zsh"}, "zsh", true},
{[]string{"completion", "BASH"}, "bash", true},
{[]string{"Completion", "zsh"}, "zsh", true},
{[]string{"completion"}, "", false}, // flaggy asks which shell
{[]string{"vm", "-l"}, "", false},
{nil, "", false},
} {
shell, ok := completionRequest(c.args)
if ok != c.ok || shell != c.shell {
t.Errorf("completionRequest(%v) = %q, %v; want %q, %v", c.args, shell, ok, c.shell, c.ok)
}
}
}
// Every option the names are offered after is an option gvm actually has. The
// two lists cannot be one — a shell script has to name them as strings — so
// this is what keeps them from drifting apart. The subcommands and the rest of
// the options need no such check: flaggy writes those out of the parser itself.
func TestCompletionOptionsAreReal(t *testing.T) {
src, err := os.ReadFile("gvm.go")
if err != nil {
t.Fatal(err)
}
text := string(src)
for _, f := range append(append([]string{}, vmFlags...), vcFlags...) {
if !strings.Contains(text, `"`+strings.TrimLeft(f, "-")+`"`) {
t.Errorf("the completion offers %s, which gvm.go does not declare", f)
}
}
}
// A prefix beginning with a dash is still a prefix: the scripts put "--" in
// front of it so it cannot be taken for an option of gvm's own.
func TestACompletionPrefixMayLookLikeAnOption(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "-odd-name", "web01"))
if got := cachedNames("-odd"); !slices.Equal(got, []string{"-odd-name"}) {
t.Errorf("the prefix -odd matched %v", got)
}
}
+234 -8
View File
@@ -30,6 +30,7 @@ type Config struct {
SMTPHost string // relay to hand it to
SMTPPort string // its port (default 25)
Telemetry string // URL `host -t` posts to; unset turns the posting off
SSH string // the command the sheet's `h` runs; %h is the machine
}
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
@@ -66,6 +67,18 @@ func (v VCenter) missing() []string {
return miss
}
// password is the password to log in with, opened if it was sealed. Asked for
// where it is used rather than when the file is read, so nothing is opened that
// is not needed and a value that will not open is reported against the vCenter it
// belongs to.
func (v VCenter) password() (string, error) {
secret, err := unseal(v.Password)
if err != nil {
return "", errf("%s: %w", v.Name, err)
}
return secret, nil
}
// skipVerify reports whether this server's certificate is to be taken on
// trust. It defaults to off, which is the one behaviour change of the rewrite:
// the old code passed insecure=true to every single connection, so a vCenter
@@ -118,6 +131,16 @@ func (c Config) pick(name string) (VCenter, error) {
return VCenter{}, c.notConfigured(incomplete)
}
// A list where one server is wanted is refused rather than half obeyed.
// The machine listing takes -v v308,v108; a snapshot, a power operation and
// the event log are about one server, and taking the first of a list would
// be picking a production cluster on the operator's behalf.
if strings.Contains(name, ",") {
return VCenter{}, fmt.Errorf("this command works on one vCenter at a time, "+
"and -v was given %d (%s) — a list of servers is for 'gvm vm' and 'gvm vm -l'",
len(splitList(name)), name)
}
if name == "" {
name = c.Default
}
@@ -145,14 +168,28 @@ func (c Config) pick(name string) (VCenter, error) {
}
// targets is what the commands that sweep every server work on: all of them
// when -v was not given, the named one when it was.
// when -v was not given, and otherwise the ones -v named — one, or several
// separated by commas, in the order they were given. An unknown name among
// them is an error rather than a shorter list.
func (c Config) targets(name string) ([]VCenter, error) {
if name != "" {
v, err := c.pick(name)
if err != nil {
return nil, err
var out []VCenter
seen := map[string]bool{}
for _, one := range splitList(name) {
v, err := c.pick(one)
if err != nil {
return nil, err
}
if seen[v.Name] {
continue // named twice; it is still one server and one login
}
seen[v.Name] = true
out = append(out, v)
}
return []VCenter{v}, nil
if len(out) == 0 {
return nil, fmt.Errorf("-v was given nothing to work on (known: %s)", c.names())
}
return out, nil
}
ok, incomplete := c.usable()
if len(ok) == 0 {
@@ -164,6 +201,18 @@ func (c Config) targets(name string) ([]VCenter, error) {
return ok, nil
}
// splitList takes -v apart. Empty pieces are dropped, so a trailing comma or a
// space after one is a typo that costs nothing.
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func (c Config) notConfigured(incomplete []string) error {
if len(incomplete) > 0 {
return fmt.Errorf("no usable vCenter in %s: %s", configFile(), strings.Join(incomplete, "; "))
@@ -180,6 +229,19 @@ func (c Config) smtpPort() int {
return 25
}
// telemetryURL is where the numbers are posted, when they are asked to be.
// Asked for here rather than at each command, so that a missing setting is one
// message and not one per subcommand that grew a -t.
func (c Config) telemetryURL(wanted bool) (string, error) {
if !wanted {
return "", nil
}
if c.Telemetry == "" {
return "", errf("no 'telemetry' url in %s", configFile())
}
return c.Telemetry, nil
}
// mailReady reports whether `log -m` has everything it needs.
func (c Config) mailReady() error {
var miss []string
@@ -221,11 +283,81 @@ func loadConfig() Config {
m := parseConfig(string(data))
applyConfig(&c, m)
warnConfigPerms(path, m)
sealPasswords(path, string(data))
}
applyEnv(&c)
return c
}
// sealPasswords rewrites any password still standing in the clear in the file,
// and says which. Nothing else about the file changes: the key, the spacing, the
// comments, the order and the blank lines are all left exactly as they were, and
// a line that is already sealed or commented out is not touched.
//
// The rewrite goes through a file alongside and a rename, so that a gvm
// interrupted here leaves the configuration whole rather than half of it.
func sealPasswords(path, data string) {
lines := strings.Split(data, "\n")
var done []string
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 {
continue
}
key := strings.ToLower(strings.TrimRight(trimmed[:sep], " \t"))
f := vcenterFieldRe.FindStringSubmatch(key)
if f == nil || f[2] != "password" {
continue
}
// The line is taken apart so that everything but the value can be put
// back: what stood in front of it, and any comment behind it. A note
// somebody wrote next to their password is theirs, not gvm's to delete.
raw := trimmed[sep+1:]
lead := len(raw) - len(strings.TrimLeft(raw, " \t"))
body := raw[lead:]
value := stripInlineComment(strings.TrimRight(body, " \t"))
tail := body[len(value):]
if q := strings.Trim(value, "\"'"); q != value {
value = q // a quoted password; the sealed word needs no quotes
}
if value == "" || sealed(value) {
continue
}
word, err := seal(value)
if err != nil {
PE("could not seal the password of "+f[1], err.Error())
return
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(key):sep] // whatever alignment was there
lines[i] = indent + trimmed[:len(key)] + gap + string(trimmed[sep]) + raw[:lead] + word + tail
done = append(done, f[1])
}
if len(done) == 0 {
return
}
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), configMode); err != nil {
PE("could not seal the passwords in "+path, err.Error())
return
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
PE("could not seal the passwords in "+path, err.Error())
return
}
PO(SF("password of %s sealed in %s", strings.Join(done, ", "), path))
}
// parseConfig reads `key = value` (or `key: value`) lines, ignoring blank ones
// and '#' comments. Keys are lower-cased, values unquoted.
func parseConfig(s string) map[string]string {
@@ -279,6 +411,7 @@ func applyConfig(c *Config, m map[string]string) {
set("smtphost", &c.SMTPHost)
set("smtpport", &c.SMTPPort)
set("telemetry", &c.Telemetry)
set("ssh", &c.SSH)
applyVCenters(c, m)
}
@@ -344,6 +477,7 @@ func applyEnv(c *Config) {
env("GVM_SMTPHOST", &c.SMTPHost)
env("GVM_SMTPPORT", &c.SMTPPort)
env("GVM_TELEMETRY", &c.Telemetry)
env("GVM_SSH", &c.SSH)
applyVCenterEnv(c)
}
@@ -421,6 +555,12 @@ func writeConfigTemplate(path string) {
b.WriteString("# Format: 'key = value' (or 'key: value'); '#' starts a comment.\n")
b.WriteString("# GVM_* environment variables override these settings.\n")
b.WriteString("#\n")
b.WriteString("# A password written here in the clear is sealed on the next run and\n")
b.WriteString("# replaced by a 'gvmenc1:...' word, so it does not stand in this file\n")
b.WriteString("# where a backup or a glance over your shoulder would pick it up.\n")
b.WriteString("# 'gvm config -p <vcenter>' asks for one instead, and then it never\n")
b.WriteString("# touches the disk unsealed at all.\n")
b.WriteString("#\n")
b.WriteString("# One 'vcenter.<name>.*' block per server. <name> is what -v selects.\n")
b.WriteString("# 'insecure = true' skips certificate verification — needed for a vCenter\n")
b.WriteString("# with a self-signed certificate, and the reason it is written down here\n")
@@ -440,13 +580,17 @@ func writeConfigTemplate(path string) {
fmt.Fprintf(&b, "vcenter.%s.insecure = true\n\n", v.Name)
}
b.WriteString("# --- mail for `gvm log -m` ---\n")
b.WriteString("# --- mail for `gvm log -m` and `gvm snap --old -m` ---\n")
b.WriteString("# mailfrom = root@fhi.mpg.de\n")
b.WriteString("# mailto = you@example.com\n")
b.WriteString("# smtphost = m0.fhi-berlin.mpg.de\n")
b.WriteString("# smtpport = 25\n\n")
b.WriteString("# --- where `gvm host -t` posts its numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n")
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
b.WriteString("# --- how the sheet's 'h' logs in to a guest ---\n")
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
b.WriteString("# not written anywhere. Unset means '" + defaultSSH + "'.\n")
b.WriteString("# ssh = ssh -l someone %h\n")
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
PE("could not create "+path, err.Error())
@@ -477,3 +621,85 @@ func contains(list []string, s string) bool {
}
return false
}
// setPassword asks for a vCenter's password and writes it into ~/.gvmrc sealed.
//
// The point of doing it here rather than in an editor: a password typed into the
// file stands there in the clear until the next run of gvm seals it, and by then
// it has been through the editor's swap file and whatever backs the home
// directory up. Typed here it never touches the disk unsealed.
func setPassword(cfg Config, name string) error {
var target VCenter
for _, v := range cfg.VCenters {
if strings.EqualFold(v.Name, name) {
target = v
}
}
if target.Name == "" {
return errf("no vCenter called %q in %s", name, configFile())
}
if err := haveTerminal(); err != nil {
return errf("a password has to be typed, and there is no terminal to type it on (%v)", err)
}
secret := Inputpw(SF("password for %s (%s)", target.Name, target.User))
if secret == "" {
P("nothing done")
return nil
}
if again := Inputpw("again"); again != secret {
return errf("the two did not match — nothing written")
}
word, err := seal(secret)
if err != nil {
return err
}
if err := writeSetting(configFile(), "vcenter."+target.Name+".password", word); err != nil {
return err
}
PO(SF("password of %s sealed in %s", target.Name, configFile()))
return nil
}
// writeSetting replaces one setting in the file and leaves everything else as it
// was, appending it when it is not there yet. The same care as sealPasswords: a
// file alongside and a rename, so an interrupted write leaves the configuration
// whole.
func writeSetting(path, key, value string) error {
data, err := os.ReadFile(path)
if err != nil {
return errf("cannot read %s: %w", path, err)
}
lines := strings.Split(string(data), "\n")
written := false
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 || !strings.EqualFold(strings.TrimRight(trimmed[:sep], " \t"), key) {
continue
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(strings.TrimRight(trimmed[:sep], " \t")):sep]
lines[i] = indent + trimmed[:sep-len(gap)] + gap + string(trimmed[sep]) + " " + value
written = true
break
}
if !written {
lines = append(lines, key+" = "+value)
}
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), configMode); err != nil {
return errf("cannot write %s: %w", path, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return errf("cannot write %s: %w", path, err)
}
return nil
}
+192
View File
@@ -0,0 +1,192 @@
// datastore.go — what the datastores are doing.
//
// The gap next to `gvm host`. A cluster is watched by its processor load and its
// memory, and then it falls over because a datastore filled up — which nothing
// in gvm could show, and which is the one figure a snapshot report (snapold.go)
// makes you want to look at next.
//
// Same shape as hoststat: one line per datastore, the numbers in the palette's
// roles, and -t posts the same figures to the monitoring server.
package main
import (
"sort"
"strings"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
var dsColumns = []printColumn{
{header: "DATASTORE", width: 20},
{header: "TYPE", width: 5},
{header: "CAPACITY", width: 9, right: true},
{header: "FREE", width: 9, right: true},
{header: "USED%", width: 6, right: true},
{header: "PROVISIONED", width: 12, right: true},
{header: "OVER%", width: 6, right: true},
{header: "VM", width: 4, right: true},
{header: "STATUS", width: 7},
{header: "STATE", width: 14},
}
// dsstat prints one line per datastore: how big it is, what is left, what has
// been promised out of it, and how many machines live on it.
//
// Every figure comes out of summary, and vSphere only guarantees those while
// the datastore is accessible. An unreachable datastore therefore reports
// dashes rather than zeroes — a datastore that says 0 B free looks like an
// emergency, and a datastore nobody can reach is a different one.
func dsstat(vc VCenter, telemetry string) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
stores, err := s.datastores("name", "summary", "overallStatus", "vm")
if err != nil {
return err
}
sort.Slice(stores, func(a, b int) bool {
return strings.ToLower(dsName(stores[a])) < strings.ToLower(dsName(stores[b]))
})
var capacity, free int64
printRow(dsColumns, "", nil)
for _, ds := range stores {
sum := ds.Summary
used, usedKnown := dsUsedPercent(sum)
over, overKnown := dsOverPercent(sum)
if sum.Accessible {
capacity += sum.Capacity
free += sum.FreeSpace
}
printRow(dsColumns, "", []cell{
{dsName(ds), cWhite.fg()},
{sum.Type, colAside},
dsSize(sum.Capacity, sum.Accessible),
dsSize(sum.FreeSpace, sum.Accessible),
pctCell(used, usedKnown, loadColor(used, usedKnown)),
dsSize(dsProvisioned(sum), sum.Accessible),
pctCell(over, overKnown, overColor(over, overKnown)),
{Itoa(len(ds.Vm)), colSize},
{string(ds.OverallStatus), statusColor(ds.OverallStatus)},
{dsState(sum), dsStateColor(sum)},
})
if telemetry != "" {
post(telemetry, SF("ds,%s,%d,%d,%.2f,%d,%d,%s",
dsName(ds), sum.Capacity, sum.FreeSpace, used,
dsProvisioned(sum), len(ds.Vm), ds.OverallStatus))
}
}
// One line of estate: the figure somebody asks for immediately after
// reading the table, and the reason the table is worth printing at all.
if capacity > 0 {
P()
PF("%s %s of %s free (%s used)\n",
Cwb(plural(len(stores), "datastore")),
Co(units.ByteSize(free).String()),
Co(units.ByteSize(capacity).String()),
Co(SF("%.0f %%", 100.0-100.0/float64(capacity)*float64(free))))
}
return nil
}
// dsName prefers the summary's name over the entity's: they are two different
// vSphere properties and a datastore that was renamed can answer differently to
// each, the same way a host can (see countOn in host.go).
func dsName(ds mo.Datastore) string {
if ds.Summary.Name != "" {
return ds.Summary.Name
}
return ds.Name
}
// dsProvisioned is what has been promised out of the datastore: what is in use
// plus what thin disks are entitled to grow into. Past the capacity that is a
// promise the datastore cannot keep if every machine takes what it was offered,
// which is why it has a column of its own rather than being folded into "used".
func dsProvisioned(sum types.DatastoreSummary) int64 {
return sum.Capacity - sum.FreeSpace + sum.Uncommitted
}
func dsUsedPercent(sum types.DatastoreSummary) (float64, bool) {
if !sum.Accessible || sum.Capacity <= 0 {
return 0, false
}
return 100.0 - 100.0/float64(sum.Capacity)*float64(sum.FreeSpace), true
}
func dsOverPercent(sum types.DatastoreSummary) (float64, bool) {
if !sum.Accessible || sum.Capacity <= 0 {
return 0, false
}
return 100.0 / float64(sum.Capacity) * float64(dsProvisioned(sum)), true
}
// overColor: thin provisioning past the capacity is ordinary and not a fault,
// so a hundred per cent is a word of warning rather than an alarm; half again
// as much as there is, is an alarm.
func overColor(pct float64, known bool) string {
switch {
case !known:
return colOff
case pct >= 150:
return colFull
case pct >= 100:
return colBusy
}
return colSize
}
// dsSize is a byte figure, or a dash where the datastore cannot vouch for it.
func dsSize(b int64, accessible bool) cell {
if !accessible {
return cell{"-", colOff}
}
return cell{units.ByteSize(b).String(), colSize}
}
// pctCell is a percentage in a narrow column: no sign, because the header has
// one, and a dash where there is no figure rather than a nought.
func pctCell(pct float64, known bool, col string) cell {
if !known {
return cell{"-", colOff}
}
return cell{SF("%.0f", pct), col}
}
// dsState is what vSphere says about the datastore itself, as opposed to the
// alarms rolled up in its status: whether it can be reached at all, and whether
// it is being emptied for removal.
func dsState(sum types.DatastoreSummary) string {
var parts []string
if !sum.Accessible {
parts = append(parts, "inaccessible")
}
switch sum.MaintenanceMode {
case "", string(types.DatastoreSummaryMaintenanceModeStateNormal):
default:
parts = append(parts, string(sum.MaintenanceMode))
}
if len(parts) == 0 {
return "ok"
}
return strings.Join(parts, " ")
}
func dsStateColor(sum types.DatastoreSummary) string {
if !sum.Accessible {
return colFull
}
if sum.MaintenanceMode != "" &&
sum.MaintenanceMode != string(types.DatastoreSummaryMaintenanceModeStateNormal) {
return colBusy
}
return colOK
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
func dsSummary(capacity, free, uncommitted int64, accessible bool) types.DatastoreSummary {
return types.DatastoreSummary{
Name: "LocalDS_0", Type: "VMFS", Capacity: capacity,
FreeSpace: free, Uncommitted: uncommitted, Accessible: accessible,
}
}
// What has been promised out of a datastore is what is in use plus what thin
// disks may still grow into — the figure that says whether the datastore can
// keep its promises, and the reason it is a column of its own.
func TestProvisionedAndUsed(t *testing.T) {
sum := dsSummary(1000, 400, 800, true)
if got := dsProvisioned(sum); got != 1400 {
t.Errorf("provisioned = %d, want 1400", got)
}
used, ok := dsUsedPercent(sum)
if !ok || used != 60 {
t.Errorf("used = %v (known %v), want 60", used, ok)
}
over, ok := dsOverPercent(sum)
if !ok || over != 140 {
t.Errorf("over = %v (known %v), want 140", over, ok)
}
}
// A datastore nobody can reach cannot vouch for its own figures, so it reports
// none. A datastore that says 0 B free looks like an emergency; one that cannot
// be reached is a different one.
func TestAnUnreachableDatastoreReportsNothing(t *testing.T) {
sum := dsSummary(1000, 0, 0, false)
if _, ok := dsUsedPercent(sum); ok {
t.Error("an inaccessible datastore reported a usage figure")
}
if _, ok := dsOverPercent(sum); ok {
t.Error("an inaccessible datastore reported an over-commitment figure")
}
if got := dsSize(1000, false); got.text != "-" || got.col != colOff {
t.Errorf("its capacity is shown as %q", got.text)
}
if dsState(sum) != "inaccessible" || dsStateColor(sum) != colFull {
t.Errorf("its state is %q", dsState(sum))
}
}
// Thin provisioning past the capacity is ordinary practice, not a fault: a word
// of warning at a hundred per cent, an alarm at half again as much.
func TestOverCommitmentColours(t *testing.T) {
for _, c := range []struct {
pct float64
want string
}{{50, colSize}, {99, colSize}, {100, colBusy}, {149, colBusy}, {150, colFull}} {
if got := overColor(c.pct, true); got != c.want {
t.Errorf("%.0f %% over-committed is coloured wrongly", c.pct)
}
}
if overColor(0, false) != colOff {
t.Error("an unknown over-commitment is coloured as a figure")
}
}
func TestPercentCellHasNoSignAndNoNought(t *testing.T) {
if got := pctCell(93.4, true, colSize); got.text != "93" {
t.Errorf("a percentage is shown as %q", got.text)
}
if got := pctCell(0, false, colSize); got.text != "-" {
t.Errorf("an unknown percentage is shown as %q", got.text)
}
}
// A datastore in maintenance is being emptied on purpose: worth saying, not
// worth an alarm.
func TestMaintenanceModeIsSaidButNotAlarmed(t *testing.T) {
sum := dsSummary(1000, 500, 0, true)
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateEnteringMaintenance)
if got := dsState(sum); got != "enteringMaintenance" {
t.Errorf("the state reads %q", got)
}
if dsStateColor(sum) != colBusy {
t.Error("entering maintenance is painted as a fault")
}
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateNormal)
if got := dsState(sum); got != "ok" {
t.Errorf("an ordinary datastore reads %q", got)
}
if dsStateColor(sum) != colOK {
t.Error("an ordinary datastore is not painted as ordinary")
}
}
// The name in the summary and the name of the entity are two different vSphere
// properties, and a renamed datastore can answer differently to each — the same
// trap the host counts fell into.
func TestDatastoreNamePrefersTheSummary(t *testing.T) {
ds := mo.Datastore{Summary: types.DatastoreSummary{Name: "new-name"}}
ds.Name = "old-name"
if got := dsName(ds); got != "new-name" {
t.Errorf("dsName = %q", got)
}
ds.Summary.Name = ""
if got := dsName(ds); got != "old-name" {
t.Errorf("with no summary name, dsName = %q", got)
}
}
+632
View File
@@ -0,0 +1,632 @@
// estate.go — the whole estate on one screen: every host of every server that
// answered, grouped by cluster, with what it carries set against what it has.
//
// The machine list answers "what is this machine doing". This answers the
// question that comes before buying or growing anything and that nothing else
// in gvm answers: *where is there still room*. Since `size` can now give a
// machine four more processors, gvm itself raises that question, and a tool that
// raises a question ought to answer it.
//
// Two kinds of number, and the difference between them is the point:
//
// - What is allocated. Every vCPU and every megabyte the machines on a host
// have been promised, added up — which routinely exceeds the host, and is
// meant to. The ratio is the interesting figure: 2.0x of memory on a host is
// a decision somebody made, 8.0x is one somebody forgot.
// - What is in use. What the host itself reports it is actually doing. A host
// at 4x allocation and 30 % load is fine; the same host at 90 % is not, and
// no allocation figure can tell those apart.
//
// The allocations are added up from the rows the list already holds — no machine
// is read twice for this — and matched to hosts by reference, never by name:
// host.go has the scar from doing that by name, where a host added by address
// and renamed later reported zero machines while running dozens.
package main
import (
"sort"
"strings"
"sync"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// estateProps is what a host has to say about itself. "summary" wholesale, the
// way host.go asks for it: it is one property that carries both the hardware
// and the live figures, and asking for the two paths separately would be two
// reads of the same document.
// Deliberately without "vm": it is an array of every machine reference on the
// host, thousands of them across a large estate, and nothing here reads it. What
// each host carries is added up from the rows the list already holds.
var estateProps = []string{"name", "parent", "summary", "runtime.connectionState",
"runtime.inMaintenanceMode", "overallStatus"}
// estateRow is one line of the screen: either a cluster heading or a host.
type estateRow struct {
heading string // a cluster, or the server itself — set only on headings
host string
vms, on int // machines carried, and how many are running
cores int32 // physical
allocs int32 // vCPUs promised to the machines on it
memPhys int64
allocMB int64 // memory promised to them
cpuPct float64
cpuKnown bool
memPct float64
memKnown bool
note string // maintenance, or a connection state that is not "connected"
bad bool // that note is a fault rather than a state of affairs
}
func (e estateRow) isHeading() bool { return e.heading != "" }
// estate is the screen: the rows in display order and where the cursor is.
type estate struct {
rows []estateRow
sel int
scroll int
failed []string // servers that could not be read for it
}
// openEstate builds it and puts it on screen. It is read fresh every time: the
// screen exists to be looked at when a decision is being made, and a cached
// answer to "where is there room" is the wrong kind of wrong.
func (b *browser) openEstate() {
// What the cursor was on, where this is a second reading of the same screen.
// A refresh that puts the cursor back at the top is a refresh one stops
// pressing.
was := ""
if b.estate != nil && b.estate.sel < len(b.estate.rows) {
was = b.estate.rows[b.estate.sel].host
}
// The old screen stays up while the hosts are read, so ^r does not flash
// the machine list underneath for the half second it takes.
b.working("reading the hosts ...")
e := buildEstate(b.sessions, b.rows)
if len(e.rows) == 0 {
b.setStatus(colErr, "no host could be read"+said(e.failed))
return
}
e.sel = e.firstHost()
for i, r := range e.rows {
if !r.isHeading() && r.host == was {
e.sel = i
break
}
}
b.estate = e
if len(e.failed) > 0 {
b.setStatus(colWarn, "without"+said(e.failed))
return
}
b.setStatus("", "")
}
func said(failed []string) string {
if len(failed) == 0 {
return ""
}
return " " + strings.Join(failed, ", ")
}
func (b *browser) closeEstate() { b.estate = nil }
// buildEstate reads every server that is still connected, in parallel, and adds
// up what the list already knows about their machines.
func buildEstate(sessions []*session, rows []vmRow) *estate {
type result struct {
rows []estateRow
vc string
err error
}
res := make([]result, len(sessions))
var wg sync.WaitGroup
for i, s := range sessions {
if s == nil {
continue
}
wg.Add(1)
go func(i int, s *session) {
defer wg.Done()
res[i].vc = s.vc.Name
res[i].rows, res[i].err = hostsOf(s, rows)
}(i, s)
}
wg.Wait()
e := &estate{}
for _, r := range res {
if r.vc == "" {
continue
}
if r.err != nil {
e.failed = append(e.failed, r.vc)
continue
}
e.rows = append(e.rows, r.rows...)
}
return e
}
// hostsOf is one server's hosts, grouped under their clusters and in a stable
// order: clusters by name, hosts by name within them. A standalone host has a
// compute resource of its own for a parent rather than a cluster, and is
// grouped under the server's own name instead of under a heading that would be
// the host's name repeated.
func hostsOf(s *session, rows []vmRow) ([]estateRow, error) {
hosts, err := s.hosts(estateProps...)
if err != nil {
return nil, err
}
// The cluster names, for the headings. A server with no clusters at all
// costs one empty read, which is cheaper than deciding whether to ask.
var clusters []mo.ClusterComputeResource
if err := s.retrieve("ClusterComputeResource", []string{"name"}, &clusters); err != nil {
return nil, err
}
clusterName := make(map[types.ManagedObjectReference]string, len(clusters))
for _, c := range clusters {
clusterName[c.Reference()] = c.Name
}
alloc := allocationsBy(rows, s.vc.Name)
byCluster := map[string][]estateRow{}
for _, h := range hosts {
row := hostRow(h)
if a, ok := alloc[h.Reference()]; ok {
row.vms, row.on, row.allocs, row.allocMB = a.vms, a.on, a.cpus, a.memMB
}
// A host outside a cluster has a compute resource of its own for a
// parent, whose name is the host's name again — which would make a
// heading that says the same thing as the line under it.
group := "standalone"
if h.Parent != nil {
if name, ok := clusterName[*h.Parent]; ok {
group = name
}
}
byCluster[group] = append(byCluster[group], row)
}
groups := make([]string, 0, len(byCluster))
for g := range byCluster {
groups = append(groups, g)
}
sort.Strings(groups)
var out []estateRow
for _, g := range groups {
hs := byCluster[g]
sort.Slice(hs, func(i, j int) bool { return hs[i].host < hs[j].host })
// The server's name is part of the heading, not just of the title: two
// vCenters may each hold a cluster called "prod", and a screen that is
// about where there is room must not put the two under one total.
head := estateRow{heading: s.vc.Name + " · " + g}
for _, h := range hs {
head.vms, head.on = head.vms+h.vms, head.on+h.on
head.cores, head.allocs = head.cores+h.cores, head.allocs+h.allocs
head.memPhys, head.allocMB = head.memPhys+h.memPhys, head.allocMB+h.allocMB
}
out = append(out, head)
out = append(out, hs...)
}
return out, nil
}
// hostRow is what one host says about itself, without what it carries — that
// is added from the list's own rows. Separate so the rules below can be
// exercised without a server.
func hostRow(h mo.HostSystem) estateRow {
row := estateRow{host: shortHost(h.Name)}
// What it has survives losing touch with it — vCenter keeps the hardware
// summary — but what it is *doing* does not: a host that is not answering
// reports zeroed live figures, which would draw an empty bar and a nought.
// That is the picture of an idle host, and bar() exists to keep the two
// apart.
reachable := h.Runtime.ConnectionState == types.HostSystemConnectionStateConnected
if hw := h.Summary.Hardware; hw != nil {
row.cores = int32(hw.NumCpuCores)
row.memPhys = hw.MemorySize
if reachable {
row.cpuPct, row.cpuKnown = cpuPercent(hw, h.Summary.QuickStats)
}
}
used := int64(h.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
if reachable && row.memPhys > 0 {
row.memPct, row.memKnown = 100/float64(row.memPhys)*float64(used), true
}
// What is wrong with it, if anything, in the words vCenter uses. A host in
// maintenance is not broken and says so in its own colour; one that is not
// connected is the reason its figures are missing.
switch {
case h.Runtime.InMaintenanceMode:
row.note = "maintenance"
case !reachable:
row.note, row.bad = string(h.Runtime.ConnectionState), true
case h.OverallStatus == types.ManagedEntityStatusRed:
row.note, row.bad = "red", true
case h.OverallStatus == types.ManagedEntityStatusYellow:
row.note = "yellow"
}
return row
}
// allocation is what one host's machines have been promised.
type allocation struct {
vms, on int
cpus int32
memMB int64
}
// allocationsBy adds the list's own rows up per host. By reference, not by
// name: see the file comment.
func allocationsBy(rows []vmRow, vc string) map[types.ManagedObjectReference]allocation {
out := map[types.ManagedObjectReference]allocation{}
for _, r := range rows {
if r.vc.Name != vc {
continue
}
ref := r.vm.Summary.Runtime.Host
if ref == nil {
continue // a machine vCenter is not currently placing anywhere
}
a := out[*ref]
a.vms++
if r.running() {
a.on++
}
// Only what is running is charged against a host. A machine that is
// switched off has been promised nothing it is using: counting its
// memory would make a host of parked machines look full when the whole
// point of parking them there was that it is not.
if r.running() {
a.cpus += r.vm.Summary.Config.NumCpu
a.memMB += int64(r.vm.Summary.Config.MemorySizeMB)
}
out[*ref] = a
}
return out
}
// firstHost is where the cursor goes: a heading is not selectable, for the same
// reason a menu separator is not.
func (e *estate) firstHost() int {
for i, r := range e.rows {
if !r.isHeading() {
return i
}
}
return 0
}
// lastHost is the other end, for End and for a page jump that overshoots.
func (e *estate) lastHost() int {
for i := len(e.rows) - 1; i >= 0; i-- {
if !e.rows[i].isHeading() {
return i
}
}
return 0
}
// move steps over the headings. The step is taken once and then walked off by
// ones until it is on a host — it is not taken again.
//
// Repeating it is what the menu does, and the menu is only ever stepped by one,
// where repeating and walking are the same thing. Here they are not: a page of
// five over an estate with a heading every fourth row landed page-up from row
// nine on row one and page-down from row three on row eleven, because the
// second leap cleared the rest of the screen. A page key that jumps to the top
// whenever it lands on a cluster name is worse than one that stops short.
//
// Running out of rows stops at the end of the travel rather than doing nothing:
// from the second host there is no row a whole page above, and "no row" has to
// mean the first one.
func (e *estate) move(step int) {
if step == 0 {
return
}
walk := 1
if step < 0 {
walk = -1
}
for i := e.sel + step; i >= 0 && i < len(e.rows); i += walk {
if !e.rows[i].isHeading() {
e.sel = i
return
}
}
if step < 0 {
e.sel = e.firstHost()
return
}
e.sel = e.lastHost()
}
// ------------------------------------------------------------------- the keys
// estateKey drives the screen. Enter is the only thing here that changes
// anything, and what it changes is the filter: the answer to "what is on this
// host" is the machine list narrowed to it, which gvm already knows how to
// draw.
func (b *browser) estateKey(k key) {
e := b.estate
_, rows := termSize()
page := max(rows-6, 1)
switch k.special {
case keyEsc, keyLeft, keyCtrlE:
b.closeEstate()
case keyUp, keyShiftTab:
e.move(-1)
case keyDown, keyTab:
e.move(1)
case keyPgUp:
e.move(-page)
case keyPgDn:
e.move(page)
case keyHome:
e.move(-len(e.rows))
case keyEnd:
e.move(len(e.rows))
case keyCtrlR:
b.openEstate()
case keyEnter:
b.showHost()
}
}
// showHost narrows the machine list to the host under the cursor and goes back
// to it. The filter is the mechanism because it is the honest one: it matches
// the whole row, so what comes up is what anybody would get by typing the same
// thing, and one keystroke — Esc — undoes it.
func (b *browser) showHost() {
e := b.estate
if e.sel < 0 || e.sel >= len(e.rows) {
return
}
row := e.rows[e.sel]
if row.isHeading() {
return
}
b.closeEstate()
b.filter = row.host
b.refilter()
// The count is the host's own, not the filtered view's. The filter is a
// text match over the whole row and carries no server, so it can also catch
// another vCenter's host of the same name, a host whose name this one is a
// prefix of, and a machine named after a host — and a count taken from it
// would then contradict the number on the screen this came from. Saying
// what the filter is keeps the difference visible where it happens.
b.setStatus(colInfo, SF("%s carries %s — filtered on its name, so ^w and esc still apply",
row.host, plural(row.vms, "machine")))
}
// ---------------------------------------------------------------- the drawing
// bar is a load drawn as one block of text. Ten characters, filled to the
// percentage — the figure is beside it, so this is for the eye running down the
// column rather than for reading a number off.
//
// A load that is not known draws nothing at all, and deliberately not an empty
// trough: a host at one per cent fills none of the ten characters either, and
// "almost idle" and "I cannot see this host" must not be the same picture.
func bar(pct float64, known bool) string {
const width = 10
if !known {
return SR(" ", width)
}
full := int(pct / 100 * width)
full = min(max(full, 0), width)
return strings.Repeat("█", full) + strings.Repeat("·", width-full)
}
// ratio is how much of a host has been promised away: 2.0x means twice what it
// has. Under one it is left blank rather than shown as 0.4x — a host with room
// to spare is the ordinary case, and a column of small numbers saying so is
// noise where the whole point is to find the ones over.
func ratio(promised, has float64) string {
if has <= 0 || promised <= 0 || promised < has {
return ""
}
return SF("%.1fx", promised/has)
}
// estateWidths is how wide this screen's columns are, which is the only thing
// that changes with the terminal: a narrow one gives up the bars and tightens
// the two widest columns rather than letting segLine cut the line at the edge
// and take half the screen's meaning with it.
//
// The three lines that make up the table — the header, a cluster's totals and a
// host — lay their figures out through one function, because three format
// strings kept in step by hand are three format strings that drift.
type estateWidths struct {
host int
mem int
pairs bool // the raw allocated/physical pairs, not only their ratios
bars bool
}
// The two right-hand layouts, as the format strings they are, so that the
// header, the rows and the measurement below cannot disagree about them.
const (
estateBars = " %-10s %4s %-10s %4s"
estatePcts = " %5s %5s"
)
// widthsFor picks the widest layout the terminal can hold whole. Three of them,
// giving up the least useful thing first, the way the machine list's columns do:
//
// - everything, with the load drawn as bars;
// - the same figures with the bars dropped to their percentages;
// - and, on a genuinely narrow terminal, only the two ratios and the two
// percentages — which is still both kinds of number, and both kinds is what
// this screen is for. The raw pairs behind them are detail.
func widthsFor(cols int) estateWidths {
for _, w := range []estateWidths{
{host: 20, mem: 17, pairs: true, bars: true},
{host: 16, mem: 15, pairs: true},
{host: 14},
} {
if cols >= w.width() {
return w
}
}
return estateWidths{host: 10}
}
// width is what a layout needs, measured rather than counted: the first version
// of this was a round number picked by eye, and at exactly 100 columns the wide
// layout is 105 wide and lost the figure on its right-hand end — which is the
// one failure the narrower layouts exist to prevent.
func (w estateWidths) width() int {
n := 2 + w.host + len([]rune(w.figures("", "", "", "", "")))
if w.bars {
return n + len([]rune(SF(estateBars, "", "", "", "")))
}
return n + len([]rune(SF(estatePcts, "", "")))
}
// figures is the middle of every line: the header's, a cluster's totals and a
// host's. One function, because three format strings kept in step by hand are
// three format strings that drift.
func (w estateWidths) figures(onvm, vcpu, vratio, mem, mratio string) string {
out := SF(" %6s", onvm)
if w.pairs {
out += SF(" %9s", vcpu)
}
out += SF(" %6s", vratio)
if w.pairs {
out += SF(" %*s", w.mem, mem)
}
return out + SF(" %6s", mratio)
}
// figuresOf is that for a row, heading or host alike: the numbers are the same
// numbers, summed or not.
func (w estateWidths) figuresOf(r estateRow) string {
return w.figures(
SF("%d/%d", r.on, r.vms),
SF("%d/%d", r.allocs, r.cores),
ratio(float64(r.allocs), float64(r.cores)),
SF("%s/%s", units.ByteSize(r.allocMB*1024*1024), units.ByteSize(r.memPhys)),
ratio(float64(r.allocMB*1024*1024), float64(r.memPhys)))
}
func (b *browser) renderEstate() {
cols, rows := termSize()
e := b.estate
visible := max(rows-5, 1)
if e.sel < e.scroll {
e.scroll = e.sel
}
if e.sel >= e.scroll+visible {
e.scroll = e.sel - visible + 1
}
end := min(e.scroll+visible, len(e.rows))
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
hosts, machines := 0, 0
for _, r := range e.rows {
if !r.isHeading() {
hosts++
machines += r.vms
}
}
segLine(&sb, cols,
seg{colTitle, "Estate"},
seg{colDim, " "},
seg{colWhere, strings.Join(b.answered, ", ")},
seg{colDim, " "},
seg{colInfo, SF("%s · %s", plural(hosts, "host"), plural(machines, "machine"))})
segLine(&sb, cols)
w := widthsFor(cols)
head := SF(" %-*s%s", w.host, "CLUSTER / HOST",
w.figures("ON/VM", "vCPU", "CPU x", "MEM ALLOC", "MEM x"))
if w.bars {
head += SF(estateBars, "CPU LOAD", "%", "MEM USED", "%")
} else {
head += SF(estatePcts, "CPU%", "MEM%")
}
segLine(&sb, cols, seg{colHeader, head})
for i := e.scroll; i < end; i++ {
r := e.rows[i]
if r.isHeading() {
segLine(&sb, cols,
seg{colDim, " "},
seg{colLabel, padRight(truncate(r.heading, w.host), w.host)},
seg{colDim, w.figuresOf(r)})
continue
}
pointer, name := " ", colRow
if i == e.sel {
pointer, name = "▸ ", colRowSel
}
line := []seg{
{colPointer, pointer},
{name, padRight(truncate(r.host, w.host), w.host)},
{colSize, w.figuresOf(r)},
}
if w.bars {
line = append(line,
seg{colDim, " "},
seg{loadColor(r.cpuPct, r.cpuKnown), bar(r.cpuPct, r.cpuKnown)},
seg{loadColor(r.cpuPct, r.cpuKnown), SF(" %4s", loadCell(r.cpuPct, r.cpuKnown))},
seg{colDim, " "},
seg{loadColor(r.memPct, r.memKnown), bar(r.memPct, r.memKnown)},
seg{loadColor(r.memPct, r.memKnown), SF(" %4s", loadCell(r.memPct, r.memKnown))})
} else {
line = append(line,
seg{loadColor(r.cpuPct, r.cpuKnown), SF(" %5s", loadCell(r.cpuPct, r.cpuKnown))},
seg{loadColor(r.memPct, r.memKnown), SF(" %5s", loadCell(r.memPct, r.memKnown))})
}
if r.note != "" {
col := colWarn
if r.bad {
col = colErr
}
line = append(line, seg{colDim, " "}, seg{col, r.note})
}
segLine(&sb, cols, line...)
}
for i := end - e.scroll; i < visible; i++ {
sb.WriteString(scrEOL + "\r\n")
}
if b.status != "" {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim,
"allocated / physical, and the ratio where more is promised than there is"})
}
sb.WriteString(colDim + truncate(estateHelp, cols) + attrOff + scrEOL)
b.parkCursor(&sb, cols, rows)
b.write(sb.String())
}
const estateHelp = "↑/↓ move ⏎ its machines ^r read again esc/^e back ^c quit"
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"strings"
"testing"
)
// Whatever the terminal, the table fits it: the screen is two kinds of number
// set against each other, and a line cut at the right-hand edge takes one of
// the two away.
func TestTheEstateFitsEveryTerminal(t *testing.T) {
e := &estate{rows: []estateRow{
{heading: "v308 · a-cluster-with-a-long-name-indeed"},
{host: "esx-with-a-very-long-name-01", vms: 22, on: 21, cores: 32, allocs: 72,
memPhys: 1 << 38, allocMB: 400000, cpuPct: 41, cpuKnown: true, memPct: 72, memKnown: true},
{host: "esx02", vms: 4, on: 0, note: "notResponding", bad: true},
}}
b := &browser{estate: e, answered: []string{"v308"}}
for _, cols := range []string{"60", "79", "80", "99", "100", "104", "105", "132", "200"} {
t.Setenv("COLUMNS", cols)
t.Setenv("LINES", "12")
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
want := atoiOr(cols)
for _, l := range strings.Split(strings.ReplaceAll(frame, "\r", ""), "\n") {
if n := len([]rune(l)); n > want {
t.Errorf("%s columns: a line ran to %d characters: %q", cols, n, l)
}
}
// And whichever layout it chose, both kinds of number are on it.
if !strings.Contains(frame, "MEM%") && !strings.Contains(frame, "MEM USED") {
t.Errorf("%s columns: the used-memory figure is not on the screen:\n%s", cols, frame)
}
}
}
func atoiOr(s string) int {
n := 0
for _, r := range s {
n = n*10 + int(r-'0')
}
return n
}
+225
View File
@@ -0,0 +1,225 @@
package main
import (
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// The ratio is there to find the hosts that have been promised away, so it says
// nothing at all about the ones that have room — a column of "0.4x" down a
// screen of healthy hosts is noise where the point is to spot the one over.
func TestRatioOnlySpeaksWhenThereIsSomethingToSay(t *testing.T) {
for _, c := range []struct {
promised, has float64
want string
}{
{16, 8, "2.0x"},
{12, 8, "1.5x"},
{8, 8, "1.0x"},
{4, 8, ""}, // room to spare, which is the ordinary case
{0, 8, ""}, // nothing on it at all
{8, 0, ""}, // a host whose hardware could not be read
{8, -1, ""}, // and nonsense from the server does not divide
} {
if got := ratio(c.promised, c.has); got != c.want {
t.Errorf("ratio(%.0f, %.0f) = %q, want %q", c.promised, c.has, got, c.want)
}
}
}
// A host at one per cent fills none of the bar, and so does a host nobody can
// see. The two must not draw the same picture.
func TestBarTellsIdleFromUnknown(t *testing.T) {
idle := bar(1, true)
unknown := bar(0, false)
if idle == unknown {
t.Errorf("idle and unknown both drew %q", idle)
}
if strings.TrimSpace(unknown) != "" {
t.Errorf("an unknown load drew %q", unknown)
}
if !strings.HasPrefix(bar(100, true), "██████████") {
t.Errorf("a full host drew %q", bar(100, true))
}
if strings.Contains(bar(50, true), "···········") {
t.Errorf("half drew %q", bar(50, true))
}
// Nonsense from the server does not run off the end of the bar.
for _, pct := range []float64{-10, 140} {
if n := len([]rune(bar(pct, true))); n != 10 {
t.Errorf("%.0f%% drew %d characters", pct, n)
}
}
}
// What a host carries is added up from the rows the list already holds, and
// matched by reference — never by name, which is the mistake host.go carries a
// comment about. Only running machines are charged to it: a parked machine has
// been promised nothing it is using, and counting it would make a host of
// switched-off machines look full.
func TestAllocationsAreCountedByReferenceAndOnlyWhatRuns(t *testing.T) {
host := types.ManagedObjectReference{Type: "HostSystem", Value: "host-99"}
other := types.ManagedObjectReference{Type: "HostSystem", Value: "host-1"}
on := func(name string, running bool, ref types.ManagedObjectReference) vmRow {
r := testRow(name, running, "10.0.0.5")
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
r.vm.Summary.Runtime.Host = &ref
r.vm.Summary.Config.NumCpu = 4
r.vm.Summary.Config.MemorySizeMB = 8192
return r
}
rows := []vmRow{
on("web01", true, host),
on("web02", true, host),
on("parked", false, host), // counted as a machine, charged for nothing
on("elsewhere", true, other),
}
// A machine vCenter is not placing anywhere at all.
homeless := on("limbo", true, host)
homeless.vm.Summary.Runtime.Host = nil
rows = append(rows, homeless)
got := allocationsBy(rows, "v308")
a := got[host]
if a.vms != 3 || a.on != 2 {
t.Errorf("the host carries %d machines, %d on; want 3 and 2", a.vms, a.on)
}
if a.cpus != 8 {
t.Errorf("%d vCPUs charged to it, want 8 — the parked machine is not one", a.cpus)
}
if a.memMB != 16384 {
t.Errorf("%d MB charged to it, want 16384", a.memMB)
}
if got[other].vms != 1 {
t.Errorf("the other host got %d machines", got[other].vms)
}
// Another server's rows are not this server's, even where a reference
// repeats: references are unique within a vCenter and not across them.
if len(allocationsBy(rows, "v309")) != 0 {
t.Error("rows of one server were charged to another")
}
}
// Headings are not selectable, the way a menu separator is not, and the cursor
// starts on a host rather than on the first line.
func TestEstateCursorSkipsTheHeadings(t *testing.T) {
e := &estate{rows: []estateRow{
{heading: "v308 · prod"},
{host: "esx01"},
{host: "esx02"},
{heading: "v308 · standalone"},
{host: "esx09"},
}}
e.sel = e.firstHost()
if e.sel != 1 {
t.Fatalf("the cursor starts at %d, want the first host", e.sel)
}
e.move(1)
if e.sel != 2 {
t.Errorf("down went to %d", e.sel)
}
e.move(1) // over the heading
if e.sel != 4 || e.rows[e.sel].isHeading() {
t.Errorf("down landed on %d (%+v)", e.sel, e.rows[e.sel])
}
e.move(1) // at the end, it stays put
if e.sel != 4 {
t.Errorf("down past the end moved to %d", e.sel)
}
e.move(-1)
if e.sel != 2 {
t.Errorf("up landed on %d", e.sel)
}
e.move(-10) // past the top, and never onto the heading at 0
if e.sel != 1 {
t.Errorf("up past the top landed on %d", e.sel)
}
}
// A page step is taken once and then walked off by ones. Repeating it — which
// is what the menu does, where a step is only ever 1 and the two are the same
// thing — made a page that landed on a cluster name leap another whole page:
// page-up from the tenth row went to the first, and page-down from the fourth
// to the last.
func TestAPageStepDoesNotLeapTwice(t *testing.T) {
rows := []estateRow{
{heading: "A"}, {host: "h1"}, {host: "h2"}, {host: "h3"},
{heading: "B"}, {host: "h5"}, {host: "h6"}, {host: "h7"},
{heading: "C"}, {host: "h9"}, {host: "h10"}, {host: "h11"},
}
e := &estate{rows: rows, sel: 9}
e.move(-5) // lands on the heading at 4, so the row above it
if e.sel != 3 {
t.Errorf("page up from 9 landed on %d (%q), want 3", e.sel, e.rows[e.sel].host)
}
e = &estate{rows: rows, sel: 3}
e.move(5) // lands on the heading at 8, so the row below it
if e.sel != 9 {
t.Errorf("page down from 3 landed on %d (%q), want 9", e.sel, e.rows[e.sel].host)
}
// A step that lands on a host is not walked at all.
e = &estate{rows: rows, sel: 1}
e.move(2)
if e.sel != 3 {
t.Errorf("a step onto a host landed on %d", e.sel)
}
// And the ends still stop at the ends.
e = &estate{rows: rows, sel: 1}
e.move(-5)
if e.sel != 1 {
t.Errorf("page up from the first host landed on %d", e.sel)
}
e = &estate{rows: rows, sel: 11}
e.move(5)
if e.sel != 11 {
t.Errorf("page down from the last host landed on %d", e.sel)
}
}
// A host nobody can reach reports nothing, cached hardware or not. vCenter
// keeps the hardware summary of a host it has lost touch with but zeroes the
// live figures, which would draw an empty bar and a nought — the picture of an
// idle host, which is the one thing bar() exists to keep separate.
func TestAnUnreachableHostReportsNoLoad(t *testing.T) {
for _, c := range []struct {
state types.HostSystemConnectionState
known bool
}{
{types.HostSystemConnectionStateConnected, true},
{types.HostSystemConnectionStateNotResponding, false},
{types.HostSystemConnectionStateDisconnected, false},
} {
h := mo.HostSystem{
Summary: types.HostListSummary{
Hardware: &types.HostHardwareSummary{NumCpuCores: 32, CpuMhz: 2000,
MemorySize: 1 << 38},
QuickStats: types.HostListSummaryQuickStats{},
},
}
h.Runtime.ConnectionState = c.state
h.Name = "esx01"
row := hostRow(h)
if row.cpuKnown != c.known || row.memKnown != c.known {
t.Errorf("%s: cpu known = %v, memory known = %v, want both %v",
c.state, row.cpuKnown, row.memKnown, c.known)
}
// What it has is known either way: that does not depend on reaching it.
if row.cores != 32 {
t.Errorf("%s: the core count was lost with the connection", c.state)
}
if !c.known && strings.TrimSpace(bar(row.cpuPct, row.cpuKnown)) != "" {
t.Errorf("%s: it drew a load bar anyway", c.state)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
// events.go — one machine's recent history.
//
// `gvm log` is the whole vCenter over the last hour, which is the right shape
// for a mail and the wrong one for the question actually being asked in front
// of a machine's sheet: why is this thing off, who rebooted it, what happened
// at four this morning. vCenter keeps the answer per object, so this asks it
// per object.
//
// It is not part of the sheet's own reading. Opening a machine costs one call
// for its snapshots and nothing else, and it stays that way: the events are
// fetched when they are asked for (the action menu's 'e'), for the one machine
// on screen.
package main
import (
"context"
"strings"
"time"
"github.com/vmware/govmomi/event"
"github.com/vmware/govmomi/vim25/types"
)
// How many events are worth having, and how long to wait for them. The page is
// short on purpose: this answers "what just happened to this machine", and
// anything older than the last couple of dozen lines is a question for `log`.
const (
eventPage = 25
eventWait = 20 * time.Second
)
// eventLine is one event as the sheet shows it.
type eventLine struct {
text string
col string
}
// eventsOf reads the machine's most recent events, oldest first — the order a
// history reads in, so the last line is the latest thing that happened.
func eventsOf(r vmRow) ([]eventLine, error) {
if r.sess == nil {
return nil, errf("no connection to %s", r.vc.Name)
}
// Bounded. The underlying collector waits for vCenter to hand over its
// first page, and this runs in the interactive loop: a server that accepts
// the request and then says nothing would otherwise freeze the screen
// mid-draw with no key being read.
ctx, cancel := context.WithTimeout(r.sess.ctx, eventWait)
defer cancel()
var found []types.BaseEvent
err := event.NewManager(r.sess.client.Client).Events(ctx,
[]types.ManagedObjectReference{r.ref}, eventPage, false, false,
func(_ types.ManagedObjectReference, evs []types.BaseEvent) error {
found = append(found, evs...)
return nil
})
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, errf("%s did not answer within %s", r.vc.Name, eventWait)
}
return nil, errf("%s: cannot read the events of %s: %w", r.vc.Name, r.name, err)
}
out := make([]eventLine, 0, len(found))
for _, e := range found {
out = append(out, eventLineOf(e))
}
return out, nil
}
// eventLineOf is one event: when, and what. The severity decides the colour and
// is otherwise left out — "info" down twenty lines is twenty times four
// characters spent saying nothing.
func eventLineOf(e types.BaseEvent) eventLine {
base := e.GetEvent()
msg := strings.TrimSpace(base.FullFormattedMessage)
if msg == "" {
msg = SF("%T", e)
}
// One line per event: a formatted vSphere message can carry newlines, and a
// value with a newline in it would break the sheet's own line counting.
msg = strings.Join(strings.Fields(msg), " ")
return eventLine{
text: base.CreatedTime.Local().Format("02.01. 15:04") + " " + msg,
col: eventColor(severity(e)),
}
}
// eventColor takes log.go's reading of the severity into the palette. Only the
// two that matter are coloured; the rest is history, and history is grey.
func eventColor(sev string) string {
switch strings.ToLower(sev) {
case "error":
return colFull
case "warning":
return colBusy
}
return colAside
}
-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=
+280
View File
@@ -0,0 +1,280 @@
// guest.go — the three ways out of a machine's sheet.
//
// Everything else in gvm looks at machines. These look them up: log in to one,
// take its address away with you, open it in the vSphere client. They are the
// keystrokes that stop gvm being a viewer you then have to type an address out
// of by hand — and none of them touches the vCenter at all, which is why they
// are letters of the sheet itself rather than entries in the action menu.
package main
import (
"encoding/base64"
"errors"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// sshTarget is what to connect to: the name the guest calls itself, or its
// address. The name is preferred where there is one — it is what is in the known
// hosts file, and an address that came out of VMware Tools may be one of several.
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
// sshTargetIs is the same, plus which of the two it turned out to be. What was
// copied is worth naming — a sheet shows a hostname and an address, and
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
// working that out a second time somewhere else is how two answers drift apart.
func (r vmRow) sshTargetIs() (target, kind string) {
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
return strings.TrimSpace(g.HostName), "hostname"
}
if ip := r.ip(); ip != "-" {
return ip, "address"
}
return "", ""
}
// defaultSSH is what `h` runs when the configuration says nothing.
const defaultSSH = "ssh root@%h"
// sshCommand is the command line to run, as argv: the configured template with
// the target put where %h stands, or appended when it does not stand anywhere.
//
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
// anything else is a second step once the session is up — and having it in the
// default means the common case needs no configuration file at all. A template
// of one's own overrides it entirely, root and all.
//
// The target is its own argument and never goes through a shell. It comes from
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
// and a guest that called itself `; rm -rf ~` would otherwise be running that
// on the operator's workstation. The template is the operator's own line out of
// their own configuration file, so it is split on spaces and no further:
// quoting is not supported, which is a limit worth having here.
func sshCommand(template, target string) []string {
if strings.TrimSpace(template) == "" {
template = defaultSSH
}
fields := strings.Fields(template)
argv := make([]string, 0, len(fields)+1)
placed := false
for _, f := range fields {
if strings.Contains(f, "%h") {
argv = append(argv, strings.ReplaceAll(f, "%h", target))
placed = true
continue
}
argv = append(argv, f)
}
if !placed {
argv = append(argv, target)
}
return argv
}
// vsphereURL is the machine's page in the vSphere client.
//
// The shape is the H5 client's own: the object's reference and the vCenter's
// instance UUID, which is the serverGuid that client puts in every link. The
// UUID cannot be worked out from the configuration — it is asked of the server
// on connecting (session.instanceUUID) — so a machine read over a connection
// that has gone has no URL rather than a wrong one.
func vsphereURL(r vmRow) string {
if r.sess == nil {
return ""
}
return vsphereLink(r.vc.URL, r.ref.Value, r.sess.instanceUUID())
}
// vsphereLink is the link itself, from the three things it is made of — so the
// shape can be checked without a server, which is the only way it can be
// checked at all: a wrong link opens a client that says "object not found",
// which looks like a vCenter problem rather than a gvm one.
func vsphereLink(vcURL, moref, guid string) string {
if vcURL == "" || moref == "" || guid == "" {
return ""
}
return SF("%s/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:%s:%s/summary",
strings.TrimSuffix(vcURL, "/"), moref, guid)
}
// openerCommand is how this operating system opens a URL. Nothing is opened
// where there is no answer rather than something being guessed at, and the
// caller says the URL out loud instead — which is the useful half anyway.
func openerCommand() string {
switch runtime.GOOS {
case "darwin":
return "open"
case "linux":
return "xdg-open"
}
return ""
}
// openURL hands the URL to the desktop and does not wait for it. A browser
// takes seconds to start and prints its own complaints; neither belongs in a
// full-screen list.
func openURL(url string) error {
opener := openerCommand()
if opener == "" {
return errf("no way to open a browser on %s", runtime.GOOS)
}
cmd := exec.Command(opener, url)
cmd.Stdout, cmd.Stderr = nil, nil
if err := cmd.Start(); err != nil {
return errf("cannot run %s: %w", opener, err)
}
go cmd.Wait() // reaped in the background; nothing here waits on a browser
return nil
}
// osc52 is the escape sequence that puts text in the clipboard of the terminal
// that is being looked at, wherever that terminal is running. It is the only way
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
// end of a login copies into the clipboard of a machine nobody is sitting at.
//
// It is also the way a terminal is free to ignore, and several do until they are
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
// is why it is not the only thing tried. See toClipboard.
func osc52(text string) string {
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
}
// toClipboard puts text where the next paste will find it and reports the way it
// got there, named — "pbcopy" — or empty when the escape sequence was the only
// thing on offer. The caller says so on the status line: a copy nobody can see
// happen is one that has to be described, or the only way to find out whether it
// worked is to paste somewhere and look.
//
// Both routes are used, because either alone leaves somebody with nothing: the
// local command always works where there is one, and the sequence is what
// carries the text home from the far end of an ssh login.
func (b *browser) toClipboard(text string) string {
b.write(osc52(text))
argv := clipTool()
if argv == nil {
return ""
}
if err := runClipTool(argv, text); err != nil {
return ""
}
return filepath.Base(argv[0])
}
// clipTool is the command that puts something in this machine's clipboard, where
// this is the machine the person is sitting at. Over an ssh login it is not:
// there the terminal's own sequence is the only route that ends up where the
// person can paste it, and a local clipboard would be the wrong machine's.
func clipTool() []string {
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
return nil
}
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
if runtime.GOOS == "darwin" {
candidates = [][]string{{"pbcopy"}}
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
// A Linux console or a machine with no session to speak of: there is
// nothing for xclip to hand the text to, and it would sit there waiting.
return nil
}
for _, c := range candidates {
if path, err := exec.LookPath(c[0]); err == nil {
return append([]string{path}, c[1:]...)
}
}
return nil
}
// runClipTool feeds the text to it on standard input, which is how all of them
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
// hostname into a pasted hostname and a return.
func runClipTool(argv []string, text string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
// runInTerminal gives the terminal back, runs a command in it, and takes it
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
// and the keyboard.
//
// The keystroke reader is rebuilt on the way back in: the one that was running
// is reading a file descriptor that closing the terminal has taken away from
// it, and its goroutine ends when that read fails.
func (b *browser) runInTerminal(argv []string) error {
if len(argv) == 0 {
return errf("nothing to run")
}
b.close() // clears the screen, puts the cursor back, hands the tty back
release := holdTerminalSignals()
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
err := cmd.Run()
release()
if err != nil && !interrupted(err) {
// Something to read: the message would be wiped by the next frame, so
// the screen is held until somebody has seen it.
PF("\n%s %v\n", Crb(argv[0]+":"), err)
PF("%s", Cd("press enter to come back to gvm "))
os.Stdin.Read(make([]byte, 1))
}
// Coming back in. If the terminal cannot be taken again there is nothing
// left to draw on, so it is said here, in the ordinary terminal that is
// still on screen — and the loop ends on its own: the reader that was
// running is reading a closed descriptor and reports that as a Ctrl-C.
if oerr := b.open(); oerr != nil {
PE("cannot take the terminal back", oerr.Error())
return oerr
}
return err
}
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
// the list one was going back to was not.
//
// They are caught rather than ignored, and the difference matters: exec resets a
// caught signal to its default in the child, while an ignored one is inherited.
// An ssh that cannot be interrupted while it hangs on a machine that is not
// answering would be worse than what this fixes.
//
// The returned func puts them back the way they were, which is gvm's own raw
// mode reading Ctrl-C as a key like any other.
func holdTerminalSignals() func() {
// Buffered and never read: the signal package sends without blocking and
// drops what does not fit, which is the whole intent — these are being
// swallowed, not handled.
ch := make(chan os.Signal, 4)
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
return func() { signal.Stop(ch) }
}
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
// Ctrl-\) rather than because something went wrong. Nothing is held on the
// screen for it: the person who pressed it knows what happened and wants to be
// back in the list, not reading that ssh got a signal.
func interrupted(err error) bool {
var exit *exec.ExitError
if !errors.As(err, &exit) {
return false
}
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
}
// A shell between gvm and the signal reports it as its own exit status
// instead, in the shells' 128+signal spelling.
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
}
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The name the guest calls itself is what is in the known hosts file; the
// address is the fallback, and a machine whose guest says nothing has neither.
func TestSSHTarget(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := r.sshTarget(); got != "web01.example" {
t.Errorf("sshTarget = %q, want the hostname", got)
}
r.vm.Guest.HostName = ""
if got := r.sshTarget(); got != "10.0.0.5" {
t.Errorf("with no hostname, sshTarget = %q", got)
}
r.vm.Guest = nil
if got := r.sshTarget(); got != "" {
t.Errorf("with no guest information at all, sshTarget = %q", got)
}
}
func TestSSHCommand(t *testing.T) {
for _, c := range []struct {
template string
want []string
}{
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
{"mosh %h", []string{"mosh", "web01"}},
{"ssh root@%h", []string{"ssh", "root@web01"}},
} {
if got := sshCommand(c.template, "web01"); !slices.Equal(got, c.want) {
t.Errorf("sshCommand(%q) = %v, want %v", c.template, got, c.want)
}
}
}
// A clipboard is invisible, so what the status line says about it has to be
// exact: which of the two the machine gave up — the hostname or the address —
// and which clipboard it went into.
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
// Pretending to be an ssh login does two things: it is the case where the
// escape sequence is the only route, and it keeps the tests off the
// clipboard of whoever is running them.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
for _, c := range []struct {
what string
row func() vmRow
want []string
}{
{"a guest that reports its name", func() vmRow {
return testRow("web01", true, "10.0.0.5")
}, []string{"hostname", "web01.example"}},
{"a guest that reports only an address", func() vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.HostName = ""
return r
}, []string{"address", "10.0.0.5"}},
} {
r := c.row()
b := &browser{rows: []vmRow{r}, view: []int{0}}
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = pw
b.copyAddress(r)
pw.Close()
sent, _ := io.ReadAll(pr)
pr.Close()
for _, want := range c.want {
if !strings.Contains(b.status, want) {
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
}
}
// And the sequence carried the same string, base64 and all.
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
if !strings.Contains(string(sent), payload) {
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
}
}
}
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
// far end of a login copies into the clipboard of a machine nobody is sitting
// at, and the terminal's own sequence is the only route home.
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
t.Setenv("SSH_CONNECTION", "")
t.Setenv("SSH_TTY", "/dev/ttys004")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
}
// Whatever the tool is, it takes the text on standard input and gets it verbatim
// — no trailing newline, which in a clipboard turns a pasted hostname into a
// pasted hostname and a return.
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
out := filepath.Join(t.TempDir(), "clipboard")
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
t.Fatalf("runClipTool: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(got) != "web01.example" {
t.Errorf("the clipboard would get %q", got)
}
}
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
// reads but a SIGINT to the whole foreground process group, and gvm is in that
// group. While a child has the screen the signal has to be caught and dropped:
// were it not, this test would kill the test binary rather than fail.
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
release := holdTerminalSignals()
defer release()
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatalf("cannot send myself a %v: %v", sig, err)
}
}
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
time.Sleep(50 * time.Millisecond)
// And the child must still die of it, which is why the signal is caught and
// not ignored: exec resets a caught signal to its default in the child,
// while an ignored one is inherited — signal.Ignore here would leave an ssh
// that cannot be interrupted while it hangs on a machine that is not
// answering.
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
}
}
// And a child that died of that keystroke is told apart from one that failed, so
// the screen is not held with "signal: interrupt" over something somebody meant
// to do.
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
for _, c := range []struct {
script string
want bool
}{
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
{"exit 1", false}, // a remote command that failed
{"exit 255", false}, // ssh's own "could not connect"
{"exit 0", false}, // nothing wrong at all
} {
err := exec.Command("sh", "-c", c.script).Run()
if got := interrupted(err); got != c.want {
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
}
}
// Something that never got as far as a child at all is not an interruption.
if interrupted(errf("nothing to run")) {
t.Error("a plain error was taken for a Ctrl-C")
}
}
// The target is one argument and never a piece of a shell command. It comes
// from the guest — a name the guest chose for itself — so a machine that called
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
// as a command that runs.
func TestTheTargetIsNeverShellCode(t *testing.T) {
nasty := "; rm -rf ~"
got := sshCommand("ssh -l root %h", nasty)
if len(got) != 4 || got[3] != nasty {
t.Fatalf("the target was taken apart: %v", got)
}
for _, arg := range got[:3] {
if strings.Contains(arg, "rm") {
t.Errorf("the target leaked into %q", arg)
}
}
}
// The link is the H5 client's own shape: the object's reference and the
// vCenter's instance UUID, which is the serverGuid that client wants.
func TestVsphereLink(t *testing.T) {
got := vsphereLink("https://v308.example/", "vm-42", "6ff1a05e-1111")
want := "https://v308.example/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:vm-42:6ff1a05e-1111/summary"
if got != want {
t.Errorf("vsphereLink =\n %s\nwant\n %s", got, want)
}
// A missing piece gives no link rather than a wrong one: a link that opens
// a client saying "object not found" looks like a vCenter fault.
for _, c := range [][3]string{
{"", "vm-42", "guid"}, {"https://v308.example", "", "guid"}, {"https://v308.example", "vm-42", ""},
} {
if got := vsphereLink(c[0], c[1], c[2]); got != "" {
t.Errorf("vsphereLink(%q, %q, %q) = %q, want nothing", c[0], c[1], c[2], got)
}
}
}
// A machine that was read over a connection that has gone has no link.
func TestVsphereURLNeedsTheConnection(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := vsphereURL(r); got != "" {
t.Errorf("a row with no session produced %q", got)
}
}
// The clipboard is the terminal's, not the machine's: gvm is run over ssh as
// often as not, and pbcopy would then copy into a clipboard nobody is looking
// at. This is the escape sequence that asks the terminal itself.
func TestOsc52CarriesTheTextItself(t *testing.T) {
got := osc52("10.0.0.5")
if !strings.HasPrefix(got, "\x1b]52;c;") || !strings.HasSuffix(got, "\a") {
t.Fatalf("osc52 = %q", got)
}
payload := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]52;c;"), "\a")
back, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("the payload is not base64: %v", err)
}
if string(back) != "10.0.0.5" {
t.Errorf("the clipboard would get %q", back)
}
}
// The sheet says why a key cannot do anything rather than swallowing it, and the
// two that need somewhere to connect to say exactly that.
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest = nil
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
b := &browser{rows: []vmRow{r}, view: []int{0}}
for _, k := range []rune{'h', 'y'} {
b.setStatus("", "")
b.detailRune(k)
if !strings.Contains(b.status, "no address") {
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
}
}
// With an address there is nothing to object to. Only the check is asked
// here — what follows it is an ssh session and a clipboard.
b.rows[0] = testRow("web01", true, "10.0.0.5")
if !b.hasAddress(b.rows[0]) {
t.Errorf("a machine with an address was refused: %s", b.status)
}
}
// Every letter in the menu reaches exactly one entry, or one of them is
// unreachable — and the snapshot half must not have taken a letter the
// power half already uses.
func TestMenuLettersAreDistinct(t *testing.T) {
b := &browser{}
seen := map[rune]string{}
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.isSeparator() {
continue
}
if other, ok := seen[m.key]; ok {
t.Errorf("%q is the letter for both %q and %q", string(m.key), other, m.label)
}
seen[m.key] = m.label
}
}
+188 -27
View File
@@ -16,14 +16,38 @@ package main
import (
"os"
"strings"
"github.com/integrii/flaggy"
)
// helpTail is what `gvm -h` prints after flaggy's own blocks.
//
// The two update options are answered before the parser exists (see updateFlags),
// so flaggy has never heard of them and would leave them out of its Flags block.
// They are spelled out here instead: a self-updating program whose help does not
// mention how is a program nobody updates. --version needs no line, flaggy lists
// that one itself, and the background refresh flag deliberately has none.
//
// Bare `gvm` browses instead of printing this help, so the help says that too —
// otherwise the one command a newcomer types is the one that does something
// unannounced.
var helpTail = strings.Join([]string{
" Updating:",
" --update Fetch the newest release and replace this binary",
" --check-update Look for a newer release, change nothing",
"",
" Shell completion:",
" gvm completion zsh The completion script, machine names included",
"",
" Run 'gvm' with no subcommand to browse the machines interactively.",
}, "\n")
// version is a var, not a const, so build.sh can inject the current build
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces.
var version = "0.1.0"
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.2.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -32,6 +56,16 @@ func main() {
if updateFlags() {
return
}
// The two options the completion scripts call on every Tab, answered here
// for the same reason: they have to work where the configuration does not,
// they must print nothing but the candidates, and they must not read
// ~/.gvmrc — reading it seals a password standing in it in the clear, and a
// Tab key has no business rewriting a file. (`gvm completion <shell>`, which
// is what writes those scripts, is answered in run(): it needs the flag
// parser to have been built first.)
if completionFlags() {
return
}
err := run()
updateNote() // after the output: a footer, not a headline
@@ -46,15 +80,11 @@ func run() error {
flaggy.SetName("gvm")
flaggy.SetDescription("VMware command line helper (mwx'2026)")
flaggy.SetVersion(version)
// Bare `gvm` browses instead of printing this help, so the help itself has
// to say so — otherwise the one command a newcomer types is the one that
// does something unannounced.
flaggy.DefaultParser.AdditionalHelpAppend =
"\n Run 'gvm' with no subcommand to browse the machines interactively."
flaggy.DefaultParser.AdditionalHelpAppend = helpTail // see its comment above
var vcname string
var yes bool
flaggy.String(&vcname, "v", "vcenter", "vCenter to work on (default: 'default' from ~/.gvmrc)")
flaggy.String(&vcname, "v", "vcenter", "vCenter to work on, or several separated by commas for the machine list (default: 'default' from ~/.gvmrc)")
flaggy.Bool(&yes, "y", "yes", "Answer the confirmation of a destructive command in advance (for cron)")
var vmList, vmInteractive bool
@@ -64,11 +94,19 @@ 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, vmIssues, vmJSON bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, snaps, old, why, size, cpus, vc, host, ip")
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
subVM.Bool(&vmIssues, "", "issues", "Only the machines with something wrong with them (^w in the list)")
subVM.Bool(&vmJSON, "", "json", "Print the listing as a JSON document instead of a table")
// 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
// one key next to a harmless one.
var snapLs, snapNew_, snapRm, snapRmAll, snapRevertTo, snapName string
var snapOld, snapMail bool
snapDays := snapOldDays
subSnap := flaggy.NewSubcommand("snap")
subSnap.Description = "Snapshot commands"
subSnap.String(&snapLs, "l", "list", "List the snapshots of <vm>")
@@ -77,6 +115,12 @@ func run() error {
subSnap.String(&snapRm, "r", "remove", "Remove one snapshot of <vm>, named with -s")
subSnap.String(&snapRevertTo, "", "revert", "Revert <vm> to the snapshot named with -s (destroys everything since)")
subSnap.String(&snapRmAll, "", "removeall", "Remove all snapshots of <vm>")
// The age report reads every server at once, like `vm -l` and unlike the
// rest of this subcommand: housekeeping is a question about the estate, not
// about one vCenter. -v still narrows it.
subSnap.Bool(&snapOld, "", "old", "Report the snapshots older than -d days, on every vCenter")
subSnap.Int(&snapDays, "d", "days", "How old is old, for --old")
subSnap.Bool(&snapMail, "m", "mail", "Mail that report as well")
var pwOn, pwShutdown, pwReboot, pwOff, pwReset string
subPower := flaggy.NewSubcommand("power")
@@ -87,12 +131,31 @@ func run() error {
subPower.String(&pwOff, "", "off", "Power off <vm> at the hypervisor — hard, like pulling the plug")
subPower.String(&pwReset, "", "reset", "Reset <vm> at the hypervisor — hard, like the reset button")
// The machine is a flag of its own here, not the value of the action flag as
// it is for power and snap: this command takes a machine *and* a number, and
// only one of the two can be the value of "--cpus".
// szCPUs and szMemory, not sizeCPUs and sizeMemory: those two are the
// sizeKind constants, and locals of the same name would shadow them for the
// rest of this function — where the next person to write parseSize(sizeCPUs,
// ...) would get a type error with no obvious cause.
var szVM, szCPUs, szMemory string
subSize := flaggy.NewSubcommand("size")
subSize.Description = "Show or change a machine's vCPUs and memory"
subSize.String(&szVM, "", "vm", "The machine to show or change")
subSize.String(&szCPUs, "c", "cpus", "Set its vCPU count")
subSize.String(&szMemory, "m", "memory", "Set its memory, in GB (or 512m for MB)")
var hostCount, hostTelemetry bool
subHost := flaggy.NewSubcommand("host")
subHost.Description = "Host commands"
subHost.Bool(&hostCount, "c", "count", "Only the machine counts per host")
subHost.Bool(&hostTelemetry, "t", "telemetry", "Also post the numbers to the monitoring server")
var dsTelemetry bool
subDS := flaggy.NewSubcommand("ds")
subDS.Description = "Datastore commands"
subDS.Bool(&dsTelemetry, "t", "telemetry", "Also post the numbers to the monitoring server")
var logShow, logMail bool
logMinutes := 60
subLog := flaggy.NewSubcommand("log")
@@ -101,16 +164,36 @@ func run() error {
subLog.Bool(&logMail, "m", "mail", "Mail the log as well")
subLog.Int(&logMinutes, "t", "time", "How many minutes back to look")
var cfgPassword string
subConfig := flaggy.NewSubcommand("config")
subConfig.Description = "Show the effective configuration"
subConfig.String(&cfgPassword, "p", "password", "Set the password of <vcenter>, asked for and stored sealed")
flaggy.AttachSubcommand(subVM, 1)
flaggy.AttachSubcommand(subSnap, 1)
flaggy.AttachSubcommand(subPower, 1)
flaggy.AttachSubcommand(subSize, 1)
flaggy.AttachSubcommand(subHost, 1)
flaggy.AttachSubcommand(subDS, 1)
flaggy.AttachSubcommand(subLog, 1)
flaggy.AttachSubcommand(subConfig, 1)
// `gvm completion <shell>` is flaggy's own subcommand — it is in the help
// because flaggy puts it there, and flaggy would answer it inside Parse.
// It is answered here first, one step earlier, so that the script it writes
// can carry the machine names as well (complete.go). A shell this does not
// know falls through to flaggy, whose message names the ones it can write.
//
// Before Parse and before loadConfig, deliberately: a Tab key must not read
// ~/.gvmrc, because reading it seals any password standing in it in the
// clear, and a completion has no business rewriting a file.
if shell, ok := completionRequest(os.Args[1:]); ok {
if script, ok := completionScript(shell, flaggyCompletion(shell)); ok {
PF("%s", script)
return nil
}
}
flaggy.Parse()
cfg := loadConfig()
@@ -121,12 +204,26 @@ func run() error {
if err != nil {
return err
}
if vmList { // -l prints; anything else browses
return lsvm(targets, vmMatch)
// --json and --issues are ways of printing, so they mean -l without
// having to be told twice: browsing a machine list as JSON is not a
// thing, and a full-screen list has ^w for the other one.
if vmList || vmJSON || vmIssues {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch)
return browseVMs(targets, vmMatch, cfg.SSH)
case subSnap.Used:
if snapOld {
targets, err := cfg.targets(vcname)
if err != nil {
return err
}
if snapDays < 0 {
return errf("-d wants a number of days, not %d", snapDays)
}
return snapOldReport(cfg, targets, snapDays, snapMail)
}
vc, err := cfg.pick(vcname)
if err != nil {
return err
@@ -183,6 +280,16 @@ func run() error {
return errf("one power operation at a time, not %d", len(given))
}
case subSize.Used:
vc, err := cfg.pick(vcname)
if err != nil {
return err
}
if szVM == "" {
return errf("size needs a machine: gvm size --vm <machine> [-c <vcpus>] [-m <memory>]")
}
return sizeCLI(vc, szVM, szCPUs, szMemory, yes)
case subHost.Used:
vc, err := cfg.pick(vcname)
if err != nil {
@@ -191,15 +298,23 @@ func run() error {
if hostCount {
return vmstat(vc)
}
telemetry := ""
if hostTelemetry {
if cfg.Telemetry == "" {
return errf("no 'telemetry' url in %s", configFile())
}
telemetry = cfg.Telemetry
telemetry, err := cfg.telemetryURL(hostTelemetry)
if err != nil {
return err
}
return hoststat(vc, telemetry)
case subDS.Used:
vc, err := cfg.pick(vcname)
if err != nil {
return err
}
telemetry, err := cfg.telemetryURL(dsTelemetry)
if err != nil {
return err
}
return dsstat(vc, telemetry)
case subLog.Used:
if !logShow && !logMail {
flaggy.ShowHelpAndExit("")
@@ -214,6 +329,9 @@ func run() error {
return vmlog(cfg, vc, logMinutes, logMail)
case subConfig.Used:
if cfgPassword != "" {
return setPassword(cfg, cfgPassword)
}
return showConfig(cfg)
}
@@ -228,7 +346,7 @@ func run() error {
if err != nil {
return err
}
return browseVMs(targets, "")
return browseVMs(targets, "", cfg.SSH)
}
// showConfig prints what gvm made of ~/.gvmrc and the environment. Passwords
@@ -247,7 +365,7 @@ func showConfig(cfg Config) error {
mark = Cgb(" (default)") + mark
}
PF("%-6s %s%s\n", Cwb(v.Name), v.URL, mark)
PF(" user %s, datacenter %s, password set\n", v.User, v.Datacenter)
PF(" user %s, datacenter %s, %s\n", v.User, v.Datacenter, passwordState(v))
}
for _, bad := range incomplete {
PF("%s %s\n", Crb("unusable"), bad)
@@ -259,10 +377,25 @@ func showConfig(cfg Config) error {
P()
PF("mail %s -> %s via %s:%d\n", orNone(cfg.MailFrom), orNone(cfg.MailTo), orNone(cfg.SMTPHost), cfg.smtpPort())
PF("telemetry %s\n", orNone(cfg.Telemetry))
PF("ssh %s\n", orNone(strings.Join(sshCommand(cfg.SSH, "<machine>"), " ")))
PF("completion %s\n", inventoryAge())
PF("version %s\n", version)
return nil
}
// passwordState says what the password is without saying what it is. A sealed one
// is opened and thrown away, because this is the command run after setting things
// up and "it is sealed" is worth nothing if it does not open.
func passwordState(v VCenter) string {
if !sealed(v.Password) {
return Cyb("password in the clear — it is sealed on the next run")
}
if _, err := v.password(); err != nil {
return Crb("password sealed but it does not open — set it again with 'gvm config -p " + v.Name + "'")
}
return "password sealed"
}
func orNone(s string) string {
if s == "" {
return "-"
@@ -270,14 +403,45 @@ func orNone(s string) string {
return s
}
// updateFlags answers the self-update options and reports whether it did. They
// are spelled with dashes and handled before the flag parser, because they are
// what has to work when there is no configuration to read yet.
// updateFlags answers the self-update options and reports whether it did.
//
// They are spelled with dashes and answered here, before the flag parser exists,
// because they are what has to work when there is no configuration to read yet —
// and because `--version` is what the freshly downloaded binary is probed with,
// on a machine that may never have run gvm.
//
// Every argument is looked at, not only the first: asking for an update is
// absolute, so `gvm -v v308 --update` means the same as `gvm --update` rather
// than quietly meaning nothing, which is what checking os.Args[1] alone did.
func updateFlags() bool {
if len(os.Args) < 2 {
arg, ok := updateFlagIn(os.Args[1:])
if !ok {
return false
}
switch os.Args[1] {
updateFlag(arg)
return true
}
// updateFlagIn finds the first update option among the arguments, wherever it
// stands.
func updateFlagIn(args []string) (string, bool) {
for _, a := range args {
if isUpdateFlag(a) {
return a, true
}
}
return "", false
}
// updateFlagNames are the options answered before the parser. Kept as data so
// that what the scan recognises can be checked against what the help promises,
// without having to carry out an update to find out.
var updateFlagNames = []string{"--version", "--update", "--check-update", updateRefreshFlag}
func isUpdateFlag(arg string) bool { return contains(updateFlagNames, arg) }
func updateFlag(arg string) {
switch arg {
case "--version":
PF("gvm %s\n", version)
case "--update":
@@ -292,10 +456,7 @@ func updateFlags() bool {
}
case updateRefreshFlag: // the background run, not in the help
selfUpdate.refresh()
default:
return false
}
return true
}
// updateNote prints the once-a-day hint, when there is one. It costs nothing:
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// The options answered before the flag parser. Because flaggy never sees them, a
// new one could be handled and never documented — which is how `gvm -h` came to
// leave out --update and --check-update in the first place.
func TestHelpMentionsEveryUpdateOptionItAnswers(t *testing.T) {
for _, f := range updateFlagNames {
switch f {
case "--version":
continue // flaggy prints a line for its own version flag
case updateRefreshFlag:
if strings.Contains(helpTail, f) {
t.Errorf("%s is the background call and has no business in the help", f)
}
continue
}
if !strings.Contains(helpTail, f) {
t.Errorf("gvm answers %s but the help does not mention it", f)
}
}
// The completion subcommand is documented too — flaggy lists it as well,
// but not that the script it writes carries machine names. The two options
// the scripts themselves call are not documented, the same way the
// background refresh is not: nobody types --complete-vms.
if !strings.Contains(helpTail, "completion") {
t.Error("gvm answers the completion subcommand but the help does not mention it")
}
for _, f := range []string{"--complete-vms", "--complete-vcenters"} {
if strings.Contains(helpTail, f) {
t.Errorf("%s is called by the completion script and has no business in the help", f)
}
}
// And nothing is promised that is not answered.
for _, line := range strings.Split(helpTail, "\n") {
for _, word := range strings.Fields(line) {
if !strings.HasPrefix(word, "--") {
continue
}
if !isUpdateFlag(word) && !isCompletionFlag(word) {
t.Errorf("the help offers %s, which nothing answers", word)
}
}
}
}
func TestIsUpdateFlag(t *testing.T) {
for _, yes := range []string{"--version", "--update", "--check-update", updateRefreshFlag} {
if !isUpdateFlag(yes) {
t.Errorf("%s is not recognised", yes)
}
}
for _, no := range []string{"", "-v", "--vcenter", "vm", "update", "--updates", "--UPDATE", "-u"} {
if isUpdateFlag(no) {
t.Errorf("%q is taken for an update option", no)
}
}
}
// An update is asked for wherever the option stands: checking only the first
// argument meant `gvm -v v308 --update` quietly did nothing of the sort.
func TestUpdateOptionIsFoundAnywhere(t *testing.T) {
for _, c := range []struct {
args []string
want string
}{
{[]string{"--update"}, "--update"},
{[]string{"-v", "v308", "--update"}, "--update"},
{[]string{"--check-update", "-v", "v308"}, "--check-update"},
{[]string{"vm", "-l"}, ""},
{[]string{}, ""},
{[]string{"snap", "-n", "a-machine"}, ""},
} {
got, ok := updateFlagIn(c.args)
if (c.want != "") != ok {
t.Errorf("%v: found = %v, want %v", c.args, ok, c.want != "")
}
if got != c.want {
t.Errorf("%v: found %q, want %q", c.args, got, c.want)
}
}
}
// The version compiled in and the version last built have to belong to the same
// line of development. Only the first two numbers are compared: build.sh bumps
// the last one on every build and does not touch the source, so demanding they
// match exactly would fail after every build — while a source that still said
// 0.1 after the move to 1.0 is exactly the mistake worth catching.
func TestCompiledVersionMatchesTheLineOfDevelopment(t *testing.T) {
built, err := os.ReadFile("version.txt")
if err != nil {
t.Skipf("no version.txt to compare against: %v", err)
}
line := func(v string) string {
p := strings.Split(strings.TrimSpace(v), ".")
if len(p) < 2 {
t.Fatalf("%q is not a MAJOR.MINOR.PATCH version", v)
}
return p[0] + "." + p[1]
}
if got, want := line(version), line(string(built)); got != want {
t.Errorf("the source says %s (%s), version.txt says %s (%s)",
version, got, strings.TrimSpace(string(built)), want)
}
// And it has to be a version at all, or --update would compare it as older
// than everything and stop updating.
if !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(version) {
t.Errorf("the compiled version %q is not MAJOR.MINOR.PATCH", version)
}
}
+34 -8
View File
@@ -9,9 +9,23 @@
#
# The file holds vCenter passwords, so it wants to be mode 0600 — gvm creates it
# that way and complains when it finds it readable by others.
#
# A password written here in the clear is sealed on the next run of gvm and
# replaced by a "gvmenc1:..." word, so it does not stand in this file where a
# backup, a synced home directory or an editor's swap file would pick it up.
# "gvm config -p v308" asks for one instead and writes it sealed straight away,
# which is the way to set one without it ever being on disk in the clear.
#
# What that is: the password is not in plain sight. What it is not: a vault.
# The key is compiled into gvm and is the same in every copy, so whoever holds
# this file *and* a gvm binary can open the value. The 0600 is what keeps other
# users out.
# The vCenter used when -v is not given. `gvm vm -l` ignores it and asks every
# configured server; every other command works on exactly one.
# The vCenter used when -v is not given. `gvm` and `gvm vm -l` ignore it and ask
# every configured server; the rest work on exactly one.
#
# -v takes a list for the commands that sweep — `-v v308,v108` — and refuses one
# for the commands that act on a single machine.
default = v308
# --- one 'vcenter.<name>.*' block per server ---
@@ -27,7 +41,7 @@ default = v308
vcenter.v308.url = https://v308.fhi.mpg.de/
vcenter.v308.user = administrator@v308.fhi.mpg.de
vcenter.v308.password = <password>
vcenter.v308.password = <password> # sealed on the next run
vcenter.v308.datacenter = PPB
vcenter.v308.insecure = true
@@ -43,22 +57,34 @@ vcenter.v38.password = <password>
vcenter.v38.datacenter = FEL
vcenter.v38.insecure = true
# --- mail, for `gvm log -m` ---
# Without these, `gvm log -m` says so before it queries anything.
# --- mail, for `gvm log -m` and `gvm snap --old -m` ---
# Without these, both say so before they query anything.
mailfrom = root@fhi.mpg.de
mailto = mw@pstbx.org
smtphost = m0.fhi-berlin.mpg.de
smtpport = 25
# --- telemetry, for `gvm host -t` ---
# Where the per-host numbers are posted. Unset (or without -t) nothing is sent.
# --- telemetry, for `gvm host -t` and `gvm ds -t` ---
# Where the per-host and per-datastore numbers are posted. Unset (or without
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# --- ssh, for the sheet's 'h' ---
# The command that logs in to a machine's guest from its sheet. "%h" is where
# the guest's own hostname — or its address, when it reports no name — is put;
# it is appended when %h is not written anywhere. Unset means "ssh root@%h",
# which is what one logs in to these machines as; a line here replaces it whole,
# root and all.
#
# The target is always one argument and never goes through a shell: it is a name
# the guest chose for itself, and gvm does not run it as a command.
# ssh = ssh -l someone %h
# --- the same settings from the environment ---
# Every setting above has an environment spelling that wins over the file:
#
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
# GVM_TELEMETRY
# GVM_TELEMETRY, GVM_SSH
# GVM_VCENTER_<NAME>_<FIELD>, e.g. GVM_VCENTER_V308_PASSWORD
#
# which is the way to keep a password out of a file altogether — under cron,
+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
+210
View File
@@ -0,0 +1,210 @@
// issues.go — the machines that want looking at.
//
// A list of two hundred machines is read by running the eye down it, which is
// exactly the wrong way to find the three that are broken: a lost VMware Tools,
// a filesystem at 97 %, a snapshot from March, a machine sitting on a question
// nobody has answered. Every one of those facts is already in the inventory
// sweep and none of them is visible in a table sorted by name.
//
// So this is not a new question put to the vCenters — it is a filter over the
// answer they have already given (^i in the list, `vm -l --issues` on the
// command line), and each machine carries the reason it is in the list.
//
// What counts as an issue is deliberately narrow. A list that cries wolf is one
// nobody opens, so a machine that is switched off is not an issue, a machine
// without VMware Tools is only worth a word while it is running, and a snapshot
// is only old once it has stopped being anybody's afternoon.
package main
import (
"strings"
"github.com/vmware/govmomi/vim25/types"
)
// When a guest filesystem is worth naming. Ninety per cent is where a disk
// stops having room for a surprise; ninety-five is where it stops having room.
// Small partitions sit legitimately close to full — /boot on a Debian is a
// perennial 92 % — which is why the mount point is always named with the figure
// rather than the machine merely being flagged.
const (
fsWarnPct = 90.0
fsBadPct = 95.0
)
// issue is one reason a machine is in the list. bad separates "this is broken"
// from "this wants a look" — the colours of the two are the palette's red and
// yellow, and the order they are reported in is worst first, because the column
// they end up in is the one that gets truncated.
type issue struct {
text string
bad bool
}
// issueList is everything gvm has to say against this machine, worst first.
//
// Everything here is read off the row as the sweep left it. Nothing in this
// function may go to the network: it is called for every machine in the
// inventory, for the table, for the filter and for the report.
func (r vmRow) issueList() []issue {
var bad, warn []issue
add := func(isBad bool, format string, a ...any) {
i := issue{text: SF(format, a...), bad: isBad}
if isBad {
bad = append(bad, i)
return
}
warn = append(warn, i)
}
rt := r.vm.Summary.Runtime
// vCenter cannot see the machine properly. Everything below this line is a
// statement about a machine vSphere is in touch with, so this comes first.
switch rt.ConnectionState {
case types.VirtualMachineConnectionStateConnected, "":
default:
add(true, "%s", string(rt.ConnectionState))
}
// A machine stopped on a question is stopped until somebody answers it, and
// nothing in the ordinary table says so.
if rt.Question != nil {
add(true, "waiting for an answer in vCenter")
}
// Delta disks left behind by a snapshot removal that did not finish. The
// machine runs perfectly well and grows quietly until the datastore is full.
if rt.ConsolidationNeeded {
add(true, "disks need consolidating")
}
// What vCenter itself is complaining about. Its own alarms are the best
// answer to "is something wrong", so they are passed on rather than
// second-guessed — by the name a person gave the alarm, never by its number.
alarms := 0
for _, a := range r.vm.TriggeredAlarmState {
if a.Acknowledged != nil && *a.Acknowledged {
continue // somebody has seen it and said so
}
switch a.OverallStatus {
case types.ManagedEntityStatusRed:
add(true, "alarm: %s", r.alarmLabel(a.Alarm))
alarms++
case types.ManagedEntityStatusYellow:
add(false, "alarm: %s", r.alarmLabel(a.Alarm))
alarms++
}
}
// The overall status is the rollup of those alarms. It is only worth a line
// of its own when no alarm came with it — otherwise the same fact would be
// reported twice, once with a reason and once without.
if alarms == 0 {
switch r.vm.Summary.OverallStatus {
case types.ManagedEntityStatusRed:
add(true, "vCenter says red")
case types.ManagedEntityStatusYellow:
add(false, "vCenter says yellow")
}
}
if r.running() {
if !r.toolsRunning() {
add(false, "no VMware Tools")
}
for _, d := range r.fullDisks() {
add(d.pct >= fsBadPct, "%s %.0f %% full", d.path, d.pct)
}
}
// An old snapshot is the one issue here that is nobody's fault and
// everybody's job. The table's colours change at a week; the report only
// names one once it is a month old, so this list stays worth reading.
if e, ok := r.oldest(); ok {
if days := e.days(); days >= snapOldDays {
add(false, "snapshot %s is %s old", e.name, plural(days, "day"))
}
}
return append(bad, warn...)
}
// fullDisk is one guest filesystem that is nearly full.
type fullDisk struct {
path string
pct float64
}
// fullDisks are the guest's filesystems worth naming. The figures come from
// VMware Tools, so a machine without it simply has none — which is not the same
// as having none that are full, and is why the absence of Tools is its own line.
func (r vmRow) fullDisks() []fullDisk {
g := r.vm.Guest
if g == nil {
return nil
}
var out []fullDisk
for _, d := range g.Disk {
if d.Capacity <= 0 {
continue
}
pct := 100.0 - 100.0/float64(d.Capacity)*float64(d.FreeSpace)
if pct >= fsWarnPct {
out = append(out, fullDisk{path: d.DiskPath, pct: pct})
}
}
return out
}
// alarmLabel is the alarm's own name, or its reference when the names could not
// be read. "alarm-14 is red" is not something anybody can act on, but it is
// still better than not saying that something is.
func (r vmRow) alarmLabel(ref types.ManagedObjectReference) string {
if r.sess != nil {
if name := r.sess.alarms[ref]; name != "" {
return name
}
}
return ref.Value
}
// issues is the reasons as plain text, worst first.
func (r vmRow) issues() []string {
list := r.issueList()
out := make([]string, 0, len(list))
for _, i := range list {
out = append(out, i.text)
}
return out
}
func (r vmRow) hasIssues() bool { return len(r.issueList()) > 0 }
// issueCell is the WHY column: every reason, worst first, in one line for the
// column to truncate from the right. Truncation is why the order matters.
func (r vmRow) issueCell() string { return strings.Join(r.issues(), " · ") }
// issueColor paints the row's worst reason: red where something is broken,
// yellow where something wants a look.
func (r vmRow) issueColor() string {
for _, i := range r.issueList() {
if i.bad {
return colFull
}
}
if len(r.issueList()) > 0 {
return colBusy
}
return colOff
}
// withIssues is the filter itself.
func withIssues(rows []vmRow) []vmRow {
out := make([]vmRow, 0, len(rows))
for _, r := range rows {
if r.hasIssues() {
out = append(out, r)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// aged builds a snapshot entry that was taken so many days ago.
func aged(name string, days int) snapEntry {
when := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
return snapEntry{
ref: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "snapshot-" + name},
name: name,
when: when,
created: when.Local().Format("02.01.2006 15:04"),
}
}
// A machine with nothing wrong with it says nothing. This is the one that
// matters: the whole point of the filter is that it is short.
func TestAHealthyMachineHasNoIssues(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := r.issues(); len(got) > 0 {
t.Errorf("a healthy machine reported %v", got)
}
if r.hasIssues() {
t.Error("a healthy machine is in the issues list")
}
if r.issueColor() != colOff {
t.Error("a healthy machine's reason is coloured as though it had one")
}
}
// A machine that is switched off is not a fault, and the things that are only
// true of a running machine are not held against a stopped one.
func TestAStoppedMachineIsNotAnIssue(t *testing.T) {
r := testRow("web01", false, "10.0.0.5")
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100, FreeSpace: 1}}
if got := r.issues(); len(got) > 0 {
t.Errorf("a stopped machine reported %v", got)
}
}
func TestIssuesFound(t *testing.T) {
for _, c := range []struct {
what string
bend func(*vmRow)
want string
bad bool
}{
{"disconnected", func(r *vmRow) {
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateDisconnected
}, "disconnected", true},
{"orphaned", func(r *vmRow) {
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateOrphaned
}, "orphaned", true},
{"a question", func(r *vmRow) {
r.vm.Summary.Runtime.Question = &types.VirtualMachineQuestionInfo{Id: "1"}
}, "waiting for an answer", true},
{"consolidation", func(r *vmRow) {
r.vm.Summary.Runtime.ConsolidationNeeded = true
}, "consolidating", true},
{"a red status", func(r *vmRow) {
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
}, "vCenter says red", true},
{"a yellow status", func(r *vmRow) {
r.vm.Summary.OverallStatus = types.ManagedEntityStatusYellow
}, "vCenter says yellow", false},
{"no tools", func(r *vmRow) {
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{ToolsRunningStatus: "guestToolsNotRunning"}
}, "no VMware Tools", false},
{"a full filesystem", func(r *vmRow) {
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/var", Capacity: 100 << 30, FreeSpace: 3 << 30}}
}, "/var 97 % full", true},
{"a nearly full filesystem", func(r *vmRow) {
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/boot", Capacity: 100 << 30, FreeSpace: 8 << 30}}
}, "/boot 92 % full", false},
{"an old snapshot", func(r *vmRow) {
r.snaps = []snapEntry{aged("before-patch", 63)}
}, "before-patch is 63 days old", false},
} {
r := testRow("web01", true, "10.0.0.5")
c.bend(&r)
list := r.issueList()
found := false
for _, i := range list {
if strings.Contains(i.text, c.want) {
found = true
if i.bad != c.bad {
t.Errorf("%s: bad = %v, want %v (%q)", c.what, i.bad, c.bad, i.text)
}
}
}
if !found {
t.Errorf("%s: nothing said %q, only %v", c.what, c.want, r.issues())
}
if !r.hasIssues() {
t.Errorf("%s: the machine is not in the issues list", c.what)
}
}
}
// A filesystem that is merely fairly full is nobody's business.
func TestAFilesystemWithRoomIsNotReported(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100 << 30, FreeSpace: 20 << 30}}
if got := r.issues(); len(got) > 0 {
t.Errorf("a filesystem at 80 %% reported %v", got)
}
}
// A snapshot is only old once it has stopped being somebody's afternoon. The
// table colours it yellow after a week; the list of things to answer for waits
// for a month, or it fills up with this morning's work.
func TestOnlyAMonthOldSnapshotIsAnIssue(t *testing.T) {
for _, c := range []struct {
days int
want bool
}{{2, false}, {snapStaleDays + 1, false}, {snapOldDays, true}, {90, true}} {
r := testRow("web01", true, "10.0.0.5")
r.snaps = []snapEntry{aged("s", c.days)}
if got := r.hasIssues(); got != c.want {
t.Errorf("a snapshot of %d days: reported = %v, want %v (%v)",
c.days, got, c.want, r.issues())
}
}
}
// vCenter's own alarms are passed on by the name somebody gave them, and an
// alarm that has been acknowledged has been dealt with by a person already.
func TestAlarms(t *testing.T) {
ref := types.ManagedObjectReference{Type: "Alarm", Value: "alarm-14"}
yes := true
r := testRow("web01", true, "10.0.0.5")
r.vm.TriggeredAlarmState = []types.AlarmState{
{Alarm: ref, OverallStatus: types.ManagedEntityStatusRed},
}
// Without the names, the reference is still said: it is little use, but it
// is not silence.
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "alarm-14") {
t.Errorf("an alarm with no name resolved reported %q", got)
}
r.sess = &session{alarms: map[types.ManagedObjectReference]string{ref: "Host memory usage"}}
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "Host memory usage") {
t.Errorf("the alarm's name was not used: %q", got)
}
r.vm.TriggeredAlarmState[0].Acknowledged = &yes
if got := r.issues(); len(got) > 0 {
t.Errorf("an acknowledged alarm still reported %v", got)
}
}
// The rolled-up status is not reported next to the alarm it is the rollup of:
// the same fact twice, once with a reason and once without.
func TestTheStatusIsNotReportedTwice(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
r.vm.TriggeredAlarmState = []types.AlarmState{{
Alarm: types.ManagedObjectReference{Type: "Alarm", Value: "alarm-1"},
OverallStatus: types.ManagedEntityStatusRed,
}}
if got := r.issues(); len(got) != 1 {
t.Errorf("a red machine with one alarm reported %d things: %v", len(got), got)
}
if got := strings.Join(r.issues(), " "); strings.Contains(got, "says red") {
t.Errorf("the rollup was reported beside its own alarm: %q", got)
}
}
// Worst first, because the column they end up in is truncated from the right.
func TestTheWorstReasonComesFirst(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // a word of warning
r.vm.Summary.Runtime.ConsolidationNeeded = true // broken
list := r.issueList()
if len(list) < 2 {
t.Fatalf("expected both reasons, got %v", r.issues())
}
if !list[0].bad {
t.Errorf("the reasons came out warning first: %v", r.issues())
}
if r.issueColor() != colFull {
t.Error("a machine with something broken is not painted as broken")
}
// Only a warning: yellow, not red.
r.vm.Summary.Runtime.ConsolidationNeeded = false
if r.issueColor() != colBusy {
t.Error("a machine with only a warning is painted as broken")
}
if cell := r.issueCell(); !strings.Contains(cell, "no VMware Tools") {
t.Errorf("the reason column says %q", cell)
}
}
func TestWithIssuesKeepsOnlyTheOnesToAnswerFor(t *testing.T) {
good := testRow("web01", true, "10.0.0.5")
bad := testRow("db01", true, "10.0.0.6")
bad.vm.Summary.Runtime.ConsolidationNeeded = true
got := withIssues([]vmRow{good, bad})
if len(got) != 1 || got[0].name != "db01" {
t.Errorf("the filter kept %d machines: %v", len(got), got)
}
}
+180
View File
@@ -0,0 +1,180 @@
// jsonout.go — the machine listing as a document.
//
// `gvm vm -l` is meant to be read; this is the same sweep meant to be parsed —
// by a monitoring check, a report, a spreadsheet. So the shape here is a
// promise, and two decisions follow from that.
//
// It is one object and not an array of machines, because a listing that leaves
// out a vCenter which did not answer is worse than no listing at all: a script
// handed a bare array cannot tell an empty cluster from an unreachable one. The
// servers that answered and the ones that did not are part of the document.
//
// And a figure that is not known is null, never zero. A stopped machine has no
// processor load, a machine whose guest is silent has no address, and a
// spreadsheet that averages a column of zeroes reports a fleet that is idle.
package main
import (
"encoding/json"
"os"
"time"
)
// jsonListing is the whole document.
type jsonListing struct {
Generated string `json:"generated"`
Answered []string `json:"answered"`
Failed []string `json:"failed"`
Count int `json:"count"`
Machines []jsonMachine `json:"machines"`
}
// jsonMachine is one machine. The names are the ones the table's headers stand
// for, spelled out: a document is read by somebody who cannot see the header.
type jsonMachine struct {
Name string `json:"name"`
VCenter string `json:"vcenter"`
Datacenter string `json:"datacenter"`
Host string `json:"host"`
Power string `json:"power"`
Connection string `json:"connection,omitempty"`
Status string `json:"status,omitempty"` // vCenter's own green/yellow/red
Template bool `json:"template"`
Address string `json:"address,omitempty"`
Hostname string `json:"hostname,omitempty"`
Guest string `json:"guest,omitempty"`
ToolsRunning bool `json:"tools_running"`
CPUs int32 `json:"cpus"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryMB int32 `json:"memory_mb"`
MemoryPercent *float64 `json:"memory_percent"`
UptimeSeconds *int32 `json:"uptime_seconds"`
CommittedBytes *int64 `json:"committed_bytes"`
UncommittedBytes *int64 `json:"uncommitted_bytes"`
Snapshots []jsonSnapshot `json:"snapshots"`
OldestSnapshotAt *string `json:"oldest_snapshot_at"`
OldestSnapshotDays *int `json:"oldest_snapshot_days"`
Task *jsonTask `json:"task"`
Issues []string `json:"issues"`
UUID string `json:"uuid,omitempty"`
Instance string `json:"instance_uuid,omitempty"`
Moref string `json:"moref"`
}
type jsonSnapshot struct {
Name string `json:"name"`
Created string `json:"created"`
Days int `json:"days"`
Current bool `json:"current"`
Depth int `json:"depth"`
}
type jsonTask struct {
What string `json:"what"`
Queued bool `json:"queued"`
Progress int32 `json:"progress"`
Since string `json:"since,omitempty"`
}
// printJSON writes the document. Indented, because the first reader of it is
// always a person finding out what the keys are called.
func printJSON(found sweep, rows []vmRow) error {
doc := jsonListing{
Generated: time.Now().Format(time.RFC3339),
Answered: found.answered,
Failed: found.failed,
Count: len(rows),
Machines: make([]jsonMachine, 0, len(rows)),
}
if doc.Answered == nil {
doc.Answered = []string{}
}
if doc.Failed == nil {
doc.Failed = []string{}
}
for _, r := range rows {
doc.Machines = append(doc.Machines, jsonOf(r))
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(doc); err != nil {
return errf("cannot write the listing: %w", err)
}
return nil
}
// jsonOf is one row as a document entry.
func jsonOf(r vmRow) jsonMachine {
sum := r.vm.Summary
cfg := sum.Config
rt := sum.Runtime
m := jsonMachine{
Name: r.name,
VCenter: r.vc.Name,
Datacenter: r.vc.Datacenter,
Host: r.host,
Power: string(r.power()),
Connection: string(rt.ConnectionState),
Status: string(sum.OverallStatus),
Template: cfg.Template,
Guest: r.guestOS(),
ToolsRunning: r.toolsRunning(),
CPUs: cfg.NumCpu,
MemoryMB: cfg.MemorySizeMB,
Issues: r.issues(),
UUID: cfg.Uuid,
Instance: cfg.InstanceUuid,
Moref: r.ref.Value,
Snapshots: []jsonSnapshot{},
}
if m.Issues == nil {
m.Issues = []string{}
}
if ip := r.ip(); ip != "-" {
m.Address = ip
}
if g := r.vm.Guest; g != nil {
m.Hostname = g.HostName
}
if pct, ok := r.cpuLoad(); ok {
m.CPUPercent = &pct
}
if pct, ok := r.memLoad(); ok {
m.MemoryPercent = &pct
}
if r.running() && sum.QuickStats.UptimeSeconds > 0 {
up := sum.QuickStats.UptimeSeconds
m.UptimeSeconds = &up
}
if st := sum.Storage; st != nil {
committed, uncommitted := st.Committed, st.Uncommitted
m.CommittedBytes, m.UncommittedBytes = &committed, &uncommitted
}
for _, e := range r.snaps {
m.Snapshots = append(m.Snapshots, jsonSnapshot{
Name: e.name, Created: e.when.Format(time.RFC3339),
Days: e.days(), Current: e.current, Depth: e.depth,
})
}
if e, ok := r.oldest(); ok {
at, days := e.when.Format(time.RFC3339), e.days()
m.OldestSnapshotAt, m.OldestSnapshotDays = &at, &days
}
if t := r.task; t != nil {
jt := jsonTask{What: t.what, Queued: t.queued, Progress: t.progress}
if !t.since.IsZero() {
jt.Since = t.since.Format(time.RFC3339)
}
m.Task = &jt
}
return m
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/types"
)
// jsonDoc runs the encoder over a listing and reads it back the way whatever is
// on the other end of the pipe would.
func jsonDoc(t *testing.T, found sweep, rows []vmRow) map[string]any {
t.Helper()
out := captureStdout(t, func() {
if err := printJSON(found, rows); err != nil {
t.Fatal(err)
}
})
var doc map[string]any
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("what came out is not JSON: %v\n%s", err, out)
}
return doc
}
// A figure that is not known is null and never nought. A stopped machine has no
// processor load, and a spreadsheet that averages a column of zeroes reports a
// fleet that is idle.
func TestJSONLeavesTheUnknownNull(t *testing.T) {
on := testRow("web01", true, "10.0.0.5")
off := testRow("db01", false, "")
off.ref = types.ManagedObjectReference{Value: "vm-43"}
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{on, off})
machines := doc["machines"].([]any)
if len(machines) != 2 {
t.Fatalf("the document holds %d machines", len(machines))
}
running := machines[0].(map[string]any)
stopped := machines[1].(map[string]any)
if running["cpu_percent"] == nil {
t.Error("a running machine has no processor load in the document")
}
for _, key := range []string{"cpu_percent", "memory_percent", "uptime_seconds"} {
if stopped[key] != nil {
t.Errorf("a stopped machine reports %s = %v, want null", key, stopped[key])
}
}
if stopped["address"] != nil {
t.Errorf("a machine with no address reports address = %v", stopped["address"])
}
}
// The servers that answered and the ones that did not are part of the document.
// A script handed a bare list of machines cannot tell an empty cluster from an
// unreachable one, which is the difference that matters.
func TestJSONNamesTheServersThatDidNotAnswer(t *testing.T) {
doc := jsonDoc(t, sweep{
answered: []string{"v308"},
failed: []string{"v108: login failed"},
}, nil)
if got := doc["answered"].([]any); len(got) != 1 || got[0] != "v308" {
t.Errorf("answered = %v", got)
}
got := doc["failed"].([]any)
if len(got) != 1 || !strings.Contains(got[0].(string), "v108") {
t.Errorf("failed = %v", got)
}
}
// Both lists are always there, empty rather than absent: a reader that has to
// tell null from [] is a reader that will get it wrong once.
func TestJSONAlwaysHasBothServerLists(t *testing.T) {
doc := jsonDoc(t, sweep{}, nil)
for _, key := range []string{"answered", "failed", "machines"} {
if doc[key] == nil {
t.Errorf("%s is null in an empty listing", key)
}
}
if doc["count"] != float64(0) {
t.Errorf("count = %v", doc["count"])
}
}
// What the table shows in colour and what the document says in words is the
// same judgement, made in one place.
func TestJSONCarriesTheIssuesAndTheSnapshots(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true
r.snaps = []snapEntry{aged("before-patch", 63), aged("hotfix", 2)}
r.task = &runningTask{what: "clone", progress: 40}
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{r})
m := doc["machines"].([]any)[0].(map[string]any)
issues := m["issues"].([]any)
if len(issues) == 0 || !strings.Contains(issues[0].(string), "consolidating") {
t.Errorf("issues = %v", issues)
}
if got := m["snapshots"].([]any); len(got) != 2 {
t.Errorf("the document holds %d snapshots", len(got))
}
if got := m["oldest_snapshot_days"]; got != float64(63) {
t.Errorf("oldest_snapshot_days = %v", got)
}
task := m["task"].(map[string]any)
if task["what"] != "clone" || task["progress"] != float64(40) {
t.Errorf("task = %v", task)
}
// And a machine with none of those says so, rather than leaving the reader
// to guess whether the key was simply left out.
quiet := testRow("db01", true, "10.0.0.6")
doc = jsonDoc(t, sweep{}, []vmRow{quiet})
m = doc["machines"].([]any)[0].(map[string]any)
if got := m["issues"].([]any); len(got) != 0 {
t.Errorf("a healthy machine reports issues = %v", got)
}
if m["task"] != nil {
t.Errorf("an idle machine reports task = %v", m["task"])
}
if m["oldest_snapshot_at"] != nil {
t.Errorf("a machine with no snapshots reports oldest_snapshot_at = %v", m["oldest_snapshot_at"])
}
}
// The document is one object, not a bare array: that is what leaves room for
// the servers, and it is the promise a script is written against.
func TestJSONIsOneDocument(t *testing.T) {
out := captureStdout(t, func() {
if err := printJSON(sweep{}, nil); err != nil {
t.Fatal(err)
}
})
if !strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Errorf("the document begins %q", strings.SplitN(out, "\n", 2)[0])
}
}
+452
View File
@@ -0,0 +1,452 @@
// live.go — the list that keeps itself up to date.
//
// Everything else in the interactive half happens because somebody pressed a
// key. This is the part that happens because time passed: `^l` turns it on and
// the list re-reads itself every few seconds, which turns gvm from something one
// looks at into something one leaves open on a second screen.
//
// Three things come with it, and they are here rather than in browse.go because
// they only mean anything together:
//
// 1. The refresh itself, over the connections that are already open. `^r` logs
// in again — that is how a session that has died is recovered — and doing
// that every ten seconds would be three logins a minute for nothing.
// 2. What changed. A table says what is; after a sweep it can say what just
// became, which is the one thing no column can hold: "db01 off · web01 +1
// snapshot". It is the reason to leave the thing open at all.
// 3. The trend column: each machine's recent CPU load as one column of text.
// The samples cannot live on the rows, which every sweep throws away, so
// the browser keeps them and writes the drawing back onto the rows.
//
// Nothing here acts on a machine. A refresh that could start or stop something
// would be a timer with the power to do it, and the one thing a screen left
// open unattended must not have is that.
package main
import (
"strings"
"sync"
"time"
)
// How often the list re-reads itself. Faster while vCenter is doing something:
// a clone's progress that moves once every ten seconds is a figure one waits
// for, and one that moves every two is a thing one watches finish.
//
// Neither is configurable yet. The sweep is a few properties of every machine
// over a connection that is already up, which is cheap enough that ten seconds
// is not a number worth tuning per site — and a setting nobody needs is a
// setting to keep working for ever.
const (
liveEvery = 10 * time.Second
liveBusy = 2 * time.Second
)
// trendLen is how many sweeps the trend column remembers, and therefore how
// wide the column is: a history longer than the drawing would be arithmetic
// nobody sees.
//
// Six rather than eight. The difference is two characters of table, and two
// characters is what decides whether this column is on the screen at all on a
// terminal of 120 with a task column in it — which is the ordinary case it was
// built for. Six sweeps is a minute of history at the quiet interval.
const trendLen = 6
// toggleLive turns it on and off, and says which — a mode that changes what the
// screen does on its own has to announce itself, or a list that moves under
// somebody's hands looks like a fault.
//
// Turning it on refreshes at once rather than in ten seconds' time. The
// keystroke is a request for the current state, not for a subscription that
// begins later.
func (b *browser) toggleLive() {
b.live = !b.live
if !b.live {
b.setStatus(colDim, "live off — ^r to reload by hand")
return
}
b.liveNext, b.liveGap = time.Now(), b.liveInterval()
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
}
// liveReady reports whether a tick may happen now. Only the list and a
// machine's sheet are refreshed underneath somebody: the menu decides what it
// offers from the state it was drawn with, the picker holds a list of snapshots
// that is being chosen from, and both the editor and a question are half-typed
// answers. Redrawing any of those from under a hand is worse than being ten
// seconds out of date.
//
// The estate screen is left out for a different reason: it reads the hosts
// itself, which is work the machine list does not do, and a timer that did it
// every ten seconds would be paying for a screen somebody is reading rather
// than watching. ^r reads it again.
func (b *browser) liveReady() bool {
return b.live && b.menu == nil && b.pick == nil && b.confirm == nil &&
b.edit == nil && b.prompt == nil && b.estate == nil
}
// liveIn is how long until the next tick — the remaining time, not the whole
// interval, so that somebody arrowing through the list steadily cannot postpone
// the refresh for ever.
func (b *browser) liveIn() time.Duration {
d := time.Until(b.liveNext)
if d < 0 {
return 0
}
return d
}
// liveInterval is the gap after this tick: short while anything at all is being
// done on the cluster, so the task column moves while one watches it.
func (b *browser) liveInterval() time.Duration {
for _, r := range b.rows {
if r.task != nil {
return liveBusy
}
}
return liveEvery
}
// liveTick is one refresh. What it must not do is move the screen: the cursor
// stays on the machine it was on, and the viewport stays where it was — a list
// that jumps to put the selection on the last visible line every ten seconds is
// unreadable, and that is what the ordinary refilter would do, since it is
// written for a filter being typed, where going back to the top is right.
func (b *browser) liveTick() {
was := b.rows
scroll := b.scroll
// The error is information, not a reason to stop: resweep may have replaced
// the rows and still have something to report — one server of three did not
// answer — and the rows it left are a list, just a partly older one.
//
// What must not happen is returning here with the rows replaced and the
// view not rebuilt. The view holds indexes into the rows, and the drawing
// follows it without asking: leaving the two disagreeing showed every row
// as a different machine, put the cursor on one the operator was not
// looking at, and panicked outright as soon as the new list was shorter.
err := b.resweep()
b.applySort() // which refilters, so the view describes the rows again
b.scroll = min(scroll, max(len(b.view)-1, 0))
b.sample()
if b.detail != nil {
// The sheet is rebuilt from the machine as it is now, at the line it was
// being read at. It closes itself where that machine has gone.
keep := b.dscroll
b.openDetail()
b.dscroll = keep
}
// (4) The next tick is timed from here, not from before the sweep: a sweep
// that takes longer than the interval would otherwise leave no idle time at
// all, and liveIn would hand nextWithin a zero deadline that races every
// keystroke against an already expired timer.
b.liveGap = b.liveInterval()
b.liveNext = time.Now().Add(b.liveGap)
if err != nil {
b.setStatus(colWarn, "live: "+err.Error())
b.saidLive = ""
return
}
// The changed line is live mode's own, and a quiet tick clears it — but it
// clears nothing else. A message somebody produced by pressing a key is
// theirs: "copied its hostname web01.example" or the reason a snapshot was
// refused must not vanish because ten seconds passed and nothing happened
// on the cluster. Every keystroke already clears the status; a timer has no
// business doing it.
what := changesBetween(was, b.rows)
if what != "" {
b.setStatus(colInfo, what)
b.saidLive = what
return
}
if b.status == b.saidLive {
b.setStatus("", "")
}
b.saidLive = ""
}
// resweep re-reads every machine over the sessions that are already open, in
// parallel across the servers the way the first sweep is.
//
// A server that stops answering costs its own machines, not the screen: its rows
// are kept as they were and it is named in the error. Dropping them would empty
// half a list because one of three vCenters was restarting.
func (b *browser) resweep() error {
type result struct {
rows []vmRow
err error
}
res := make([]result, len(b.sessions))
var wg sync.WaitGroup
for i, s := range b.sessions {
if s == nil {
continue
}
wg.Add(1)
go func(i int, s *session) {
defer wg.Done()
res[i].rows, res[i].err = sweepOne(s.vc, s)
}(i, s)
}
wg.Wait()
previous := b.rows
var rows []vmRow
var failed, answered []string
for i, s := range b.sessions {
if s == nil {
continue
}
if res[i].err != nil {
failed = append(failed, s.vc.Name)
rows = append(rows, rowsOfVC(previous, s.vc)...)
continue
}
answered = append(answered, s.vc.Name)
rows = append(rows, res[i].rows...)
}
if len(rows) == 0 {
if len(failed) == 0 {
return errf("no connection left to re-read the machines over")
}
return errf("no machine could be re-read (%s)", strings.Join(failed, ", "))
}
b.rows = rows
// The title is made of these two, and a refresh that leaves them alone puts
// "412 machines on v308, v309" above a status line saying v309 did not
// answer. One of the two is then a lie, and the status line is the one that
// the next keystroke clears.
b.answered, b.lost = answered, failed
if len(failed) > 0 {
return errf("%s did not answer; showing what was last read of it", strings.Join(failed, ", "))
}
return nil
}
// rowsOfVC keeps one server's rows across a sweep it did not survive, so a
// vCenter that stops answering for a moment does not empty its half of the list.
func rowsOfVC(rows []vmRow, vc VCenter) []vmRow {
var out []vmRow
for _, r := range rows {
if r.vc.Name == vc.Name {
out = append(out, r)
}
}
return out
}
// ------------------------------------------------------------- what changed
// changesBetween is what moved between two sweeps, as one line. A table shows
// what is; this is the only thing on the screen that says what just became, and
// it is why the list is worth leaving open.
//
// Deliberately short: four things and a count of the rest. A line that has to be
// read carefully is one nobody reads at all, and the table underneath it holds
// the detail of every one of them.
func changesBetween(was, now []vmRow) string {
// Indexes, not copies. A vmRow carries the machine's whole property
// document — summary, guest, snapshot tree — and this runs every two
// seconds on a list of hundreds to compare four scalars.
before := make(map[string]int, len(was))
for i := range was {
before[was[i].id()] = i
}
seen := make(map[string]bool, len(now))
name := namer(was, now)
var said []string
for i := range now {
r := &now[i]
seen[r.id()] = true
at, had := before[r.id()]
if !had {
said = append(said, name(*r)+" is new")
continue
}
said = append(said, changesOf(&was[at], r, name)...)
}
for i := range was {
if !seen[was[i].id()] {
said = append(said, name(was[i])+" is gone")
}
}
if len(said) == 0 {
return ""
}
if len(said) > 4 {
return strings.Join(said[:4], " · ") + SF(" · and %d more", len(said)-4)
}
return strings.Join(said, " · ")
}
// namer says how to call a machine on that line. Its name, ordinarily — but a
// name is not what makes a machine that machine, and two vCenters may each hold
// a "web01" (see vmRow.id). Where they do, the server goes in front, and only
// there: "v309 web01 off" is the truth and "web01 off" is a coin toss, while
// putting the server in front of every name would spend the width on the
// ordinary case to pay for the rare one.
//
// The table underneath has a column for this, which is why it is worth so
// little width up here and so much certainty.
func namer(was, now []vmRow) func(vmRow) string {
// Both sweeps, because the lines that say a machine has gone are built out
// of the old one: deciding ambiguity from the new rows alone left "web01 is
// gone" unqualified in exactly the case where one of two web01s went.
//
// Counted by identity rather than by server, so two machines of one name on
// one vCenter — which vSphere allows, in different folders — are ambiguous
// too. The server in front does not separate those two; it does say which
// server to go and look on, which is more than the bare name does.
ids := make(map[string]map[string]bool)
for _, rows := range [][]vmRow{was, now} {
for i := range rows {
n := rows[i].name
if ids[n] == nil {
ids[n] = make(map[string]bool, 1)
}
ids[n][rows[i].id()] = true
}
}
return func(r vmRow) string {
if len(ids[r.name]) > 1 {
return r.vc.Name + " " + r.name
}
return r.name
}
}
// changesOf is what happened to one machine. Only things somebody would want to
// be told: the load moving is what the trend column is for, and a line that
// reported it would never say anything else.
func changesOf(old, now *vmRow, name func(vmRow) string) []string {
var said []string
who := name(*now)
if old.power() != now.power() {
said = append(said, who+" "+now.powerShort())
}
if d := len(now.snaps) - len(old.snaps); d != 0 {
said = append(said, SF("%s %+d snapshot", who, d))
}
switch {
case old.task == nil && now.task != nil:
said = append(said, who+" "+now.task.what)
case old.task != nil && now.task == nil:
said = append(said, who+" "+old.task.what+" done")
}
// What vCenter is complaining about, by the count: the reasons themselves
// are a column away (^w) and several of them at once would fill this line
// on their own.
if o, n := old.issues(), now.issues(); len(o) != len(n) {
switch {
case len(n) == 0:
said = append(said, who+" is clear")
case len(n) > len(o):
said = append(said, who+": "+n[0])
}
}
return said
}
// ------------------------------------------------------------ the trend column
// sample adds this sweep's load to what is remembered of each machine and draws
// it onto the row. Machines that have gone are forgotten here, which is the one
// place that can: a map of every machine ever seen would grow all day.
func (b *browser) sample() {
if b.hist == nil {
b.hist = make(map[string][]float64, len(b.rows))
}
seen := make(map[string]bool, len(b.rows))
for i := range b.rows {
r := &b.rows[i]
id := r.id()
seen[id] = true
// A machine that is not running has no load rather than a load of zero
// (cpuLoad says which), and a zero sampled off a stopped machine would
// draw a floor that never happened.
//
// What it had before goes with it. Keeping it left a machine that was
// switched off ten minutes ago showing a busy history beside a CPU%
// of "-": a shape that was true once, next to a figure saying there is
// nothing to be true about.
if pct, ok := r.cpuLoad(); ok {
h := append(b.hist[id], pct)
if len(h) > trendLen {
h = h[len(h)-trendLen:]
}
b.hist[id] = h
} else {
delete(b.hist, id)
}
r.trend = sparkline(b.hist[id])
}
for id := range b.hist {
if !seen[id] {
delete(b.hist, id)
}
}
}
// sparkBlocks are eight levels in one character each, which is what makes a
// history fit in a column.
var sparkBlocks = []rune("▁▂▃▄▅▆▇█")
// sparkline draws percentages as one piece of text.
//
// The scale is fixed at 0 to 100 and not fitted to the samples. A line that
// scales itself to what it holds makes a machine idling between 1 and 2 per
// cent look exactly like one swinging between 40 and 80 — the shape would be
// the news and the size would be invisible, which is the opposite of what a
// glance down a column is for.
//
// One sample draws nothing: a single block is not a trend, and a column that
// appears full of them the moment gvm starts would be eight characters of width
// spent on saying "hello".
func sparkline(samples []float64) string {
if len(samples) < 2 {
return ""
}
out := make([]rune, 0, len(samples))
for _, pct := range samples {
i := int(pct / 100 * float64(len(sparkBlocks)))
out = append(out, sparkBlocks[min(max(i, 0), len(sparkBlocks)-1)])
}
return string(out)
}
// trendColumn is the third column that is not always there, for the same reason
// the other two are not (see taskColumn): it holds something that is only
// sometimes true — a machine has a history once it has been swept twice — and a
// column of eight dashes down two hundred rows is width spent on nothing.
//
// It goes early on a narrow terminal — everything else in the table is a fact
// about a machine and this is a shape — but not first: the guest's operating
// system is the least read column there is, and eight characters of where a
// machine has just been are worth more than "Ubuntu Linux (64-bit)".
var trendColumn = browseColumn{header: "CPU~", width: trendLen, expendable: 2,
cell: func(r vmRow) string { return r.trend },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }}
// anyTrend reports whether anything has a history to draw yet.
func anyTrend(rows []vmRow) bool {
for _, r := range rows {
if r.trend != "" {
return true
}
}
return false
}
+487
View File
@@ -0,0 +1,487 @@
package main
import (
"os"
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The trend is drawn on a fixed scale, 0 to 100, and not fitted to what it
// holds. A line that scales itself makes a machine idling between 1 and 2 per
// cent look exactly like one swinging between 40 and 80 — the shape would be
// the news and the size invisible, which is the opposite of what a glance down
// a column is for.
func TestSparklineKeepsItsScale(t *testing.T) {
idle := sparkline([]float64{1, 2, 1, 2})
busy := sparkline([]float64{40, 80, 40, 80})
if idle == busy {
t.Errorf("idling and swinging drew the same line: %q", idle)
}
if strings.Trim(idle, "▁") != "" {
t.Errorf("a machine at 1-2%% is not drawn at the floor: %q", idle)
}
// The ends of the scale, and nothing outside it: a percentage over 100 is a
// figure vCenter has been known to hand out, and it must not index past the
// blocks.
for _, c := range []struct {
pct float64
want rune
}{{0, '▁'}, {50, '▅'}, {100, '█'}, {140, '█'}, {-5, '▁'}} {
got := sparkline([]float64{c.pct, c.pct})
if []rune(got)[0] != c.want {
t.Errorf("%.0f%% drew %q, want %q", c.pct, got, string(c.want))
}
}
// One sample is not a trend, and a column full of single blocks the moment
// gvm starts would be width spent on saying hello.
if got := sparkline([]float64{50}); got != "" {
t.Errorf("one sample drew %q", got)
}
if got := sparkline(nil); got != "" {
t.Errorf("no samples drew %q", got)
}
}
// The history is kept per machine, bounded, and forgotten when the machine goes
// — a map of every machine ever seen would grow all day.
func TestSampleRemembersAndForgets(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
// web01 is the running one testBrowser makes, so it is the one with a load.
for i := 0; i < trendLen+5; i++ {
b.sample()
}
var id string
for _, r := range b.rows {
if r.running() {
id = r.id()
}
}
if id == "" {
t.Fatal("no running machine to sample")
}
if got := len(b.hist[id]); got != trendLen {
t.Errorf("the history holds %d samples, want it bounded at %d", got, trendLen)
}
if !anyTrend(b.rows) {
t.Error("nothing was drawn after a dozen sweeps")
}
// A machine that is not running has no load rather than a load of zero, so
// nothing is remembered of it: a sampled zero would draw a floor that never
// happened.
for _, r := range b.rows {
if !r.running() && len(b.hist[r.id()]) != 0 {
t.Errorf("%s is off and has %d samples", r.name, len(b.hist[r.id()]))
}
}
// And the machine going takes its history with it.
b.rows = b.rows[:0]
b.sample()
if len(b.hist) != 0 {
t.Errorf("%d histories outlived their machines", len(b.hist))
}
}
// The trend column is there only once there is something in it, the way the
// task and reason columns are: eight dashes down two hundred rows would be
// width spent on nothing.
func TestTheTrendColumnComesWithTheHistory(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
has := func() bool {
for _, c := range b.columns() {
if c.header == "CPU~" {
return true
}
}
return false
}
if has() {
t.Error("the trend column is there before anything was sampled")
}
b.sample()
if has() {
t.Error("the trend column is there after one sweep, which is not a trend")
}
b.sample()
if !has() {
t.Error("the trend column is missing after two sweeps")
}
// It is the first thing a narrow terminal gives up: everything else in the
// table is a fact about a machine, and this is a shape.
wide := fitColumnsOf(b.columns(), 200)
if wide[len(wide)-1].header == "CPU~" && len(wide) < 2 {
t.Fatal("nothing to compare")
}
narrow := fitColumnsOf(b.columns(), 100)
for _, c := range narrow {
if c.header == "CPU~" {
t.Error("a hundred columns kept the trend and gave up facts for it")
}
}
}
// What changed between two sweeps is the one thing no column can hold. Each
// kind of change has to be named, and the line has to stay short enough to read
// at a glance.
func TestChangesBetweenSweeps(t *testing.T) {
on := func(name string) vmRow {
r := testRow(name, true, "10.0.0.5")
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
return r
}
// Nothing moved.
web := on("web01")
if got := changesBetween([]vmRow{web}, []vmRow{web}); got != "" {
t.Errorf("a sweep with nothing in it said %q", got)
}
// Powered off behind gvm's back.
off := web
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
if got := changesBetween([]vmRow{web}, []vmRow{off}); !strings.Contains(got, "web01 off") {
t.Errorf("a machine that stopped said %q", got)
}
// A snapshot appearing and one going.
snapped := web
snapped.snaps = []snapEntry{{name: "s1"}}
if got := changesBetween([]vmRow{web}, []vmRow{snapped}); !strings.Contains(got, "+1 snapshot") {
t.Errorf("a new snapshot said %q", got)
}
if got := changesBetween([]vmRow{snapped}, []vmRow{web}); !strings.Contains(got, "-1 snapshot") {
t.Errorf("a removed snapshot said %q", got)
}
// A task starting and finishing.
busy := web
busy.task = &runningTask{what: "clone", progress: 40}
if got := changesBetween([]vmRow{web}, []vmRow{busy}); !strings.Contains(got, "web01 clone") {
t.Errorf("a task starting said %q", got)
}
if got := changesBetween([]vmRow{busy}, []vmRow{web}); !strings.Contains(got, "clone done") {
t.Errorf("a task finishing said %q", got)
}
// Machines coming and going.
if got := changesBetween([]vmRow{web}, []vmRow{web, on("db01")}); !strings.Contains(got, "db01 is new") {
t.Errorf("a new machine said %q", got)
}
if got := changesBetween([]vmRow{web, on("db01")}, []vmRow{web}); !strings.Contains(got, "db01 is gone") {
t.Errorf("a machine that went said %q", got)
}
// And the line stays short: four things, then a count.
var many []vmRow
for _, n := range []string{"a", "b", "c", "d", "e", "f"} {
many = append(many, on(n))
}
got := changesBetween(nil, many)
if !strings.Contains(got, "and 2 more") {
t.Errorf("six changes said %q, which does not end in a count", got)
}
if n := strings.Count(got, " · "); n > 4 {
t.Errorf("the line runs to %d pieces: %q", n, got)
}
}
// A tick may not happen underneath a menu, a picker, a confirmation or
// something half-typed: redrawing any of those from under a hand is worse than
// being ten seconds out of date.
func TestLiveHoldsStillForEveryScreenThatAsksSomething(t *testing.T) {
b := testBrowser("web01")
b.live = true
if !b.liveReady() {
t.Fatal("live mode does not tick on the plain list")
}
for _, c := range []struct {
what string
set func()
undo func()
}{
{"a menu", func() { b.menu = []menuItem{{key: 'n'}} }, func() { b.menu = nil }},
{"a picker", func() { b.pick = &picker{} }, func() { b.pick = nil }},
{"a confirmation", func() { b.confirm = &confirmation{} }, func() { b.confirm = nil }},
{"an editor", func() { b.edit = &editor{} }, func() { b.edit = nil }},
{"a question", func() { b.prompt = &prompt{} }, func() { b.prompt = nil }},
} {
c.set()
if b.liveReady() {
t.Errorf("live mode ticks with %s on screen", c.what)
}
c.undo()
}
// The sheet is refreshed, though: watching a machine's memory is a reason
// to have it open.
b.detail = []sheetLine{{label: "cpu", value: "4 vCPU"}}
if !b.liveReady() {
t.Error("live mode does not refresh an open sheet")
}
// And off is off.
b.live = false
if b.liveReady() {
t.Error("live mode ticks while it is switched off")
}
}
// It looks more often while vCenter is doing something: a clone's progress that
// moves every ten seconds is a figure one waits for, and one that moves every
// two is a thing one watches finish.
func TestLiveLooksFasterWhileSomethingIsRunning(t *testing.T) {
b := testBrowser("web01", "db01")
if got := b.liveInterval(); got != liveEvery {
t.Errorf("a quiet cluster is swept every %s, want %s", got, liveEvery)
}
b.rows[1].task = &runningTask{what: "clone", progress: 10}
if got := b.liveInterval(); got != liveBusy {
t.Errorf("a busy cluster is swept every %s, want %s", got, liveBusy)
}
}
// Two machines of one name are two machines, and the changed line has to say
// which of them stopped. A name is not what makes a machine that machine —
// vmRow.id carries the comment — and "web01 off" across three vCenters is a
// coin toss. The server goes in front only where the name is ambiguous: paying
// the width on every line for the rare case would be the wrong trade, and the
// table underneath has a column for it.
func TestTheChangedLineSaysWhichServerWhenItHasTo(t *testing.T) {
twice := func(vc string) vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
return r
}
stopped := func(r vmRow) vmRow {
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
return r
}
a, c := twice("v308"), twice("v309")
got := changesBetween([]vmRow{a, c}, []vmRow{a, stopped(c)})
if !strings.Contains(got, "v309 web01 off") {
t.Errorf("with a web01 on each of two servers it said %q", got)
}
// And one of that name is not dressed up with a server it does not need.
got = changesBetween([]vmRow{a}, []vmRow{stopped(a)})
if got != "web01 off" {
t.Errorf("an unambiguous machine said %q", got)
}
}
// A tick may clear its own line and nothing else. A message somebody produced
// by pressing a key is theirs: the address they just copied, or the reason a
// change was refused, must not vanish because ten seconds passed with nothing
// happening on the cluster.
func TestAQuietTickClearsOnlyItsOwnLine(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
// What a tick that found something leaves behind, then a quiet one.
b.setStatus(colInfo, "db01 off")
b.saidLive = "db01 off"
if b.status == b.saidLive {
b.setStatus("", "")
}
if b.status != "" {
t.Errorf("a quiet tick kept its own stale line: %q", b.status)
}
// And somebody else's message, which it must leave alone.
b.setStatus(colInfo, "copied its hostname web01.example to the clipboard (pbcopy)")
b.saidLive = "db01 off"
if b.status == b.saidLive {
b.setStatus("", "")
}
if !strings.Contains(b.status, "copied") {
t.Errorf("a quiet tick wiped a message it did not write: %q", b.status)
}
}
// A refresh that half failed still leaves the rows and the view describing the
// same list. Returning early with the rows replaced and the view not rebuilt
// showed every row as a different machine, put the cursor on one nobody was
// looking at, and panicked outright as soon as the new list was shorter — in
// renderList, which follows the view without asking.
func TestAHalfFailedRefreshLeavesTheScreenConsistent(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "20")
b := testBrowser("web01", "db01", "app07", "mail02")
b.applySort()
b.sel = len(b.view) - 1
b.live = true
// No sessions: resweep fails outright, which is the harshest version of the
// same path. The rows it could not re-read stay, and the screen still draws.
b.liveTick()
if len(b.view) != len(b.rows) {
t.Errorf("the view describes %d rows of %d", len(b.view), len(b.rows))
}
for _, i := range b.view {
if i < 0 || i >= len(b.rows) {
t.Fatalf("the view points at row %d of %d", i, len(b.rows))
}
}
_ = stripEscapes(renderToPipe(t, b, b.renderList)) // panicked before the fix
// And the tick is scheduled from after the sweep, not from before it: a
// sweep slower than the interval would otherwise leave no idle time at all
// and race every keystroke against an expired timer.
if d := b.liveIn(); d <= 0 {
t.Errorf("the next tick is already due (%s) the moment this one finished", d)
}
if b.liveGap == 0 {
t.Error("the title has no interval to show")
}
}
// A machine that stops stops drawing. Keeping its history left a busy shape
// beside a CPU% of "-": true once, and next to a figure saying there is
// nothing to be true about.
func TestTheTrendGoesWhenTheMachineStops(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
var at int
for i, r := range b.rows {
if r.running() {
at = i
}
}
b.sample()
b.sample()
if b.rows[at].trend == "" {
t.Fatal("a running machine drew nothing after two sweeps")
}
b.rows[at].vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
b.sample()
if got := b.rows[at].trend; got != "" {
t.Errorf("a machine that was switched off still draws %q", got)
}
if n := len(b.hist[b.rows[at].id()]); n != 0 {
t.Errorf("%d samples outlived the machine being switched off", n)
}
}
// The lines that say a machine has gone are built from the old sweep, so
// ambiguity has to be judged over both. Deciding it from the new rows alone
// left "web01 is gone" unqualified in exactly the case the function exists for.
func TestAmbiguityIsJudgedOverBothSweeps(t *testing.T) {
at := func(vc string) vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
return r
}
a, c := at("v308"), at("v309")
got := changesBetween([]vmRow{a, c}, []vmRow{a})
if !strings.Contains(got, "v309 web01 is gone") {
t.Errorf("one of two web01s was deleted and it said %q", got)
}
}
// A refresh that reached nothing at all leaves the title alone, and should:
// the rows on screen are still the ones that server gave, so a title naming it
// is describing them correctly. It is the *partial* failure that must move the
// title, and that one needs a server to answer — see TestSimLiveRefresh.
func TestARefreshThatReachedNothingKeepsTheRowsAndTheirTitle(t *testing.T) {
b := testBrowser("web01")
b.applySort()
b.answered = []string{"v308"}
b.liveTick()
if len(b.rows) != 1 || len(b.answered) != 1 {
t.Errorf("a failed refresh left %d rows titled %v", len(b.rows), b.answered)
}
if !strings.Contains(b.status, "live:") {
t.Errorf("it did not say the refresh failed: %q", b.status)
}
}
// The keystroke reader has to be able to stop waiting, without that breaking
// the one thing it must never break: a control sequence arrives in one burst,
// and a deadline expiring in the middle of "ESC [ A" would turn one arrow key
// into an Esc and a stray letter in the filter.
func TestNextWithinStopsWaitingButNotMidSequence(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
kr := newKeyReader(r)
// Nothing to read: it gives up and says so.
start := time.Now()
if _, ok := kr.nextWithin(50 * time.Millisecond); ok {
t.Error("a key was reported with nothing sent")
}
if waited := time.Since(start); waited < 40*time.Millisecond {
t.Errorf("it gave up after %s, before it was asked to", waited)
}
// A key that is there is decoded as usual.
w.WriteString("q")
k, ok := kr.nextWithin(time.Second)
if !ok || k.special != keyRune || k.r != 'q' {
t.Errorf("nextWithin gave %+v, %v", k, ok)
}
// An arrow key survives it whole, with what follows still intact.
w.WriteString("\x1b[Ax")
k, ok = kr.nextWithin(time.Second)
if !ok || k.special != keyUp {
t.Errorf("the arrow came back as %+v, %v", k, ok)
}
if next := kr.next(); next.special != keyRune || next.r != 'x' {
t.Errorf("what followed the arrow came back as %+v", next)
}
// ^l is the toggle, and nothing else had it.
w.WriteString("\x0c")
if k, ok = kr.nextWithin(time.Second); !ok || k.special != keyCtrlL {
t.Errorf("^l came back as %+v, %v", k, ok)
}
}
// Turning it on asks at once rather than in ten seconds' time, and both states
// say which they are: a list that moves on its own with nothing to explain it
// reads as a fault.
func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
b := testBrowser("web01")
b.toggleLive()
if !b.live {
t.Fatal("^l did not turn live mode on")
}
if !strings.Contains(b.status, "live on") {
t.Errorf("turning it on said %q", b.status)
}
if d := b.liveIn(); d > time.Second {
t.Errorf("the first refresh is %s away, want it now", d)
}
b.toggleLive()
if b.live {
t.Fatal("^l did not turn live mode off")
}
if !strings.Contains(b.status, "live off") {
t.Errorf("turning it off said %q", b.status)
}
}
+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 {
+129
View File
@@ -0,0 +1,129 @@
// 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
}
// plainRow is one row with nothing in it but the values — the same cells and
// the same widths as printRow writes to the screen, without the colour.
//
// For output that leaves the machine: the mail a report sends is read in a mail
// client, where an escape sequence is not a colour but four stray characters,
// and fatih/color's answer to "is this a terminal" is about this process's
// stdout and says nothing about where a mail is going.
func plainRow(cols []printColumn, cells []cell) string {
out := make([]string, 0, len(cols))
for i, c := range cols {
text := c.header
if cells != nil {
if i >= len(cells) {
break
}
text = cells[i].text
}
out = append(out, pad(text, c.width, c.right))
}
return strings.TrimRight(strings.Join(out, " "), " ")
}
+460
View File
@@ -0,0 +1,460 @@
// resize.go — changing what a machine has: vCPUs and memory.
//
// This is the third kind of thing gvm does to a machine, after its power and
// its snapshots, and it is the one with the most ways to be refused. vSphere
// will not take just any number:
//
// 1. A running machine can only grow, and only where it was built to. CPU and
// memory hot-add are per-machine settings somebody turned on when the
// machine was made; without them the machine has to be powered off first.
// Memory can never shrink while it runs — there is no hot-remove for it at
// all — and vCPUs only where hot-remove is on as well.
// 2. The vCPU count has to be a multiple of the cores per socket. gvm does not
// quietly change the socket topology to make a number fit: software is
// licensed per socket, and a tool that turns 2 sockets into 4 to satisfy an
// odd vCPU count would be writing somebody an invoice.
// 3. Memory is a whole number of megabytes, in multiples of four.
//
// All of that is asked before anything is sent, in the same shape the power
// operations use: an objection in two lengths, one short enough for the menu's
// own column and one to be read on its own, both out of one function so the
// menu and the message cannot disagree.
package main
import (
"strconv"
"strings"
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// reconfigWait is how long gvm watches a reconfigure before it stops watching.
// The task itself is usually over in a second — the machine is not copied or
// moved, only its configuration is written — so this is a limit on a frozen
// terminal, not on the operation. Like every other wait here, vCenter carries
// on regardless.
const reconfigWait = 5 * time.Minute
// sizeKind is which of the two is being changed. They are separate operations
// with separate rules, not two fields of one: memory can never shrink while a
// machine runs and vCPUs sometimes can, and a menu entry that greys out for
// both reasons at once could not say why.
type sizeKind int
const (
sizeCPUs sizeKind = iota
sizeMemory
)
func (k sizeKind) what() string {
if k == sizeCPUs {
return "vCPU count"
}
return "memory"
}
// sizing is what a machine has and what may be changed while it runs. It comes
// from `config`, which the inventory sweep deliberately does not read — it is
// the whole configuration of a machine, and this wants four fields of it — so
// it is asked for one machine at a time, when somebody is about to change it.
type sizing struct {
cpus int32
coresPerSocket int32
memoryMB int32
cpuHotAdd bool
cpuHotRemove bool
memoryHotAdd bool
known bool // false when the configuration could not be read at all
}
// sizingProps are those four fields and the two figures they are about. Named
// paths rather than "config": the whole configuration of a machine is a large
// document, and this is a menu being drawn.
var sizingProps = []string{
"config.hardware.numCPU",
"config.hardware.numCoresPerSocket",
"config.hardware.memoryMB",
"config.cpuHotAddEnabled",
"config.cpuHotRemoveEnabled",
"config.memoryHotAddEnabled",
}
// sizingOf reads them. A machine whose configuration cannot be read gets a
// sizing that says it knows nothing, and sizeObjection turns that into a
// refusal of its own: not knowing what a machine has is a reason to leave it
// alone, not a reason to send a number and hope.
func sizingOf(s *session, ref types.ManagedObjectReference) (sizing, error) {
if s == nil {
return sizing{}, errf("no connection to read the configuration over")
}
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, sizingProps, &mvm); err != nil {
return sizing{}, errf("%s: cannot read the configuration of %s: %w",
s.vc.Name, vmName(s, ref), err)
}
return sizingFrom(mvm.Config), nil
}
// sizingFrom is the same from the property itself, so the rules below can be
// exercised without a server.
func sizingFrom(cfg *types.VirtualMachineConfigInfo) sizing {
if cfg == nil {
return sizing{}
}
sz := sizing{
cpus: cfg.Hardware.NumCPU,
memoryMB: cfg.Hardware.MemoryMB,
known: true,
}
// The cores per socket and the three flags are pointers: unset means the
// machine predates the setting or vCenter did not send it. Unset is one
// core per socket, which divides into everything, and off.
sz.coresPerSocket = 1
if cfg.Hardware.NumCoresPerSocket != nil {
sz.coresPerSocket = *cfg.Hardware.NumCoresPerSocket
}
if cfg.CpuHotAddEnabled != nil {
sz.cpuHotAdd = *cfg.CpuHotAddEnabled
}
if cfg.CpuHotRemoveEnabled != nil {
sz.cpuHotRemove = *cfg.CpuHotRemoveEnabled
}
if cfg.MemoryHotAddEnabled != nil {
sz.memoryHotAdd = *cfg.MemoryHotAddEnabled
}
return sz
}
// now is the current value of whichever of the two this is, and shown is it in
// the words the sheet uses — "4 vCPU", "8.0GB" — so a message about a change
// and the line above it read as the same figure.
func (sz sizing) now(k sizeKind) int32 {
if k == sizeCPUs {
return sz.cpus
}
return sz.memoryMB
}
func (k sizeKind) shown(v int32) string {
if k == sizeCPUs {
return SF("%d vCPU", v)
}
return units.ByteSize(int64(v) * 1024 * 1024).String()
}
// ------------------------------------------------------------- what was typed
// parseSize turns what somebody typed into the number vSphere wants: a vCPU
// count, or a memory size in megabytes.
//
// Memory is read as gigabytes, because that is the unit the sheet shows and the
// unit anybody says out loud — "give it 16" is never sixteen megabytes. An
// explicit unit overrides that, so 512m is still sayable, and a fraction is
// taken where it lands on a whole megabyte: 1.5g is 1536 MB.
func parseSize(k sizeKind, text string) (int32, error) {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return 0, errf("nothing typed")
}
if k == sizeCPUs {
n, err := strconv.Atoi(t)
if err != nil {
return 0, errf("%q is not a number of vCPUs", text)
}
if n < 1 {
return 0, errf("a machine has at least one vCPU, not %d", n)
}
if n > maxCPUs {
return 0, errf("%d vCPUs is past anything vSphere builds — a typo?", n)
}
return int32(n), nil
}
unit := "g"
for _, suffix := range []string{"mb", "gb", "m", "g"} {
if strings.HasSuffix(t, suffix) {
unit, t = suffix[:1], strings.TrimSpace(strings.TrimSuffix(t, suffix))
break
}
}
v, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, errf("%q is not an amount of memory", text)
}
mb := v
if unit == "g" {
mb = v * 1024
}
if mb < 4 {
return 0, errf("%s is less memory than a machine can have", text)
}
if mb > maxMemoryMB {
return 0, errf("%s is more memory than vSphere takes — a typo?", text)
}
if mb != float64(int64(mb)) {
return 0, errf("%s is not a whole number of megabytes", text)
}
return int32(mb), nil
}
// The two ceilings are not vSphere's exact maxima, which move with every
// release and with the hardware version of the machine. They are there to catch
// a finger that stayed on a key: past these, a number is a typo rather than an
// intention, and everything below them is left to the server to accept or
// refuse with its own reasons.
const (
maxCPUs = 1024
maxMemoryMB = 32 * 1024 * 1024 // 32 TB
)
// -------------------------------------------------------------- the objection
// sizeObjection says why this machine cannot be given that, in two lengths, the
// same as powerObjection. Both the menu and the message come from here.
//
// A want of 0 asks the weaker question the menu asks while it is being drawn:
// not "can it have six" but "is there any number at all it could be given right
// now" — which is what decides whether the entry is offered or greyed out.
func sizeObjection(r vmRow, sz sizing, k sizeKind, want int32) (short, long string) {
// A machine whose configuration could not be read is not one to change. It
// is tempting to send it anyway and let vCenter be the authority on what it
// takes — but gvm would not know what the machine has now, so it could
// neither check the socket rule nor put an honest "from" in the question it
// asks. "8.0GB, up from something I could not read" is not a confirmation.
if !sz.known {
return "configuration unread", SF("what %s has could not be read, so there is nothing "+
"to change it from — try again, or change it in the vSphere client", r.name)
}
running := r.running()
if want == 0 {
switch {
case !running:
return "", ""
case k == sizeCPUs && !sz.cpuHotAdd && !sz.cpuHotRemove:
return "needs it off", SF("%s is running and has neither CPU hot-add nor hot-remove — "+
"power it off to change the vCPU count", r.name)
case k == sizeMemory && !sz.memoryHotAdd:
return "needs it off", SF("%s is running and has no memory hot-add — "+
"power it off to change its memory", r.name)
}
return "", ""
}
now := sz.now(k)
if want == now {
return "unchanged", SF("%s already has %s", r.name, k.shown(now))
}
if k == sizeCPUs {
// The socket topology is the machine's, not gvm's to adjust: a vCPU
// count that does not divide into it is refused with the two counts
// that do, rather than made to fit by changing the number of sockets.
if per := sz.coresPerSocket; per > 1 && want%per != 0 {
below, above := want-want%per, want-want%per+per
// Below a single socket there is no lower count to offer: zero
// processors is not a machine, and parseSize refuses it anyway.
// Naming it would be offering an answer gvm will not take.
fits := SF("%d or %d", below, above)
if below < per {
fits = SF("%d", above)
}
return "not a whole socket", SF("%s has %d cores per socket, so its vCPUs come in "+
"multiples of %d — %s, not %d", r.name, per, per, fits, want)
}
switch {
case running && want > now && !sz.cpuHotAdd:
return "no CPU hot-add", SF("%s is running and CPU hot-add is off — "+
"power it off to give it more than %d vCPU", r.name, now)
case running && want < now && !sz.cpuHotRemove:
return "no CPU hot-remove", SF("%s is running and CPU hot-remove is off — "+
"power it off to take vCPUs away from it", r.name)
}
return "", ""
}
switch {
case want%4 != 0:
return "not a multiple of 4 MB", SF("memory is set in multiples of 4 MB, and %d MB is not one", want)
case running && want < now:
// Not a flag anybody can turn on: vSphere has no memory hot-remove.
return "cannot shrink while on", SF("%s is running, and memory can never be taken away from "+
"a running machine — power it off first", r.name)
case running && !sz.memoryHotAdd:
return "no memory hot-add", SF("%s is running and memory hot-add is off — "+
"power it off to change its memory", r.name)
}
return "", ""
}
// checkSize is that objection as an error, or nil. Asked before the change is
// offered and again immediately before it is sent: between a menu being drawn
// and a confirmation being answered, somebody else may have powered the machine
// on.
func checkSize(r vmRow, sz sizing, k sizeKind, want int32) error {
if _, long := sizeObjection(r, sz, k, want); long != "" {
return errf("%s", long)
}
return nil
}
// hotly reports whether this change is going to happen underneath a running
// guest, which is worth saying afterwards: an operating system does not
// necessarily notice on its own that it has been given another four processors.
func hotly(r vmRow, k sizeKind, sz sizing, want int32) bool {
return r.running() && want > sz.now(k)
}
// ------------------------------------------------------------------ doing it
// runResize sends the change and waits for it. One task per call even when both
// numbers move, because the two are separate operations everywhere else in gvm
// and a single message that half worked would be the worst of both.
func runResize(s *session, r vmRow, sz sizing, k sizeKind, want int32) (message string, err error) {
if err := checkSize(r, sz, k, want); err != nil {
return "", err
}
spec := types.VirtualMachineConfigSpec{}
if k == sizeCPUs {
spec.NumCPUs = want
} else {
spec.MemoryMB = int64(want)
}
vm := object.NewVirtualMachine(s.client.Client, r.ref)
task, err := vm.Reconfigure(s.ctx, spec)
if err != nil {
return "", errf("%s: cannot change the %s of %s: %w", s.vc.Name, k.what(), r.name, err)
}
what := SF("%s of %s", k.what(), r.name)
if err := waitTask(s.ctx, task, reconfigWait, what); err != nil {
return "", err
}
msg := SF("%s: %s → %s", r.name, k.shown(sz.now(k)), k.shown(want))
if hotly(r, k, sz, want) {
msg += " — added while it runs; the guest may have to bring it online"
}
return msg, nil
}
// ---------------------------------------------------------- the command line
// sizeCLI is `gvm size`: with no number it says what the machine has, and with
// one it changes it. The two live in one command because "what has it got" is
// the question one asks immediately before "give it more", and having to
// remember two spellings of the same noun to ask both is a small tax.
func sizeCLI(vc VCenter, vmname, cpus, memory string, yes bool) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
vm, err := s.vm(vmname)
if err != nil {
return err
}
// Read as fresh as the operation is going to be: what is offered and what is
// refused both depend on whether the machine is running right now.
var mvm mo.VirtualMachine
if err := vm.Properties(s.ctx, vm.Reference(), append([]string{"summary", "guest"}, sizingProps...), &mvm); err != nil {
return errf("%s: cannot read %s: %w", vc.Name, vm.Name(), err)
}
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: vm.Name(), vm: mvm}
sz := sizingFrom(mvm.Config)
if cpus == "" && memory == "" {
sizeShow(r, sz)
return nil
}
// Both are read and checked before either is sent. A command line that sets
// the vCPUs and then refuses the memory for not being a multiple of 4 MB has
// half happened, and half of what somebody asked for is the one outcome
// nobody wanted — it is why `power` refuses two operations at once rather
// than carrying out the first.
//
// Checking both against the machine as it is now is sound because neither
// change can make the other impossible: what a resize is refused for is the
// power state, the hot-plug settings and the socket topology, and none of
// the three is touched here.
var changes []struct {
kind sizeKind
want int32
}
for _, c := range []struct {
kind sizeKind
text string
}{{sizeCPUs, cpus}, {sizeMemory, memory}} {
if c.text == "" {
continue
}
want, err := parseSize(c.kind, c.text)
if err != nil {
return err
}
if err := checkSize(r, sz, c.kind, want); err != nil {
return err
}
changes = append(changes, struct {
kind sizeKind
want int32
}{c.kind, want})
}
for _, c := range changes {
ok, err := confirm(SF("%s on %s: %s → %s?", r.name, vc.Name,
c.kind.shown(sz.now(c.kind)), c.kind.shown(c.want)), yes)
if err != nil || !ok {
return err
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
return err
}
PO(msg)
}
return nil
}
// sizeShow prints what the machine has and what could be changed without
// stopping it — the second being the thing one actually wants to know before
// planning the work, and the thing no listing anywhere else says.
func sizeShow(r vmRow, sz sizing) {
if !sz.known {
PE("the configuration of " + r.name + " could not be read")
return
}
PF("%-14s %s\n", "machine", r.name+" ("+r.powerLong()+")")
PF("%-14s %s\n", "vCPU", SF("%d, in %d per socket", sz.cpus, sz.coresPerSocket))
PF("%-14s %s\n", "memory", sizeMemory.shown(sz.memoryMB))
PF("%-14s %s\n", "while it runs", liveChanges(sz))
}
// liveChanges is that last line: which of the three hot-plug settings this
// machine was built with, said as what they let one do rather than as the names
// of the flags.
func liveChanges(sz sizing) string {
var can []string
if sz.cpuHotAdd {
can = append(can, "add vCPUs")
}
if sz.cpuHotRemove {
can = append(can, "remove vCPUs")
}
if sz.memoryHotAdd {
can = append(can, "add memory")
}
if len(can) == 0 {
return "nothing — it has to be powered off to be changed"
}
return strings.Join(can, ", ")
}
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// sizedRow is a machine in a given power state, for the rules below. Only the
// power state is read off it: everything else a resize depends on comes from
// the sizing, which is read separately from the machine's configuration.
func sizedRow(state types.VirtualMachinePowerState) vmRow {
return vmRow{
name: "web01",
vc: VCenter{Name: "v308"},
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Runtime: types.VirtualMachineRuntimeInfo{PowerState: state},
}},
}
}
// What somebody types has to become the number vSphere wants, or a refusal that
// says which part of it was not a number.
func TestParseSize(t *testing.T) {
for _, c := range []struct {
kind sizeKind
text string
want int32
bad string // a piece of the error, when it is meant to be refused
}{
{kind: sizeCPUs, text: "4", want: 4},
{kind: sizeCPUs, text: " 16 ", want: 16},
{kind: sizeCPUs, text: "0", bad: "at least one"},
{kind: sizeCPUs, text: "-2", bad: "at least one"},
{kind: sizeCPUs, text: "4.5", bad: "not a number"},
{kind: sizeCPUs, text: "eight", bad: "not a number"},
{kind: sizeCPUs, text: "99999", bad: "typo"},
// Memory is gigabytes unless it says otherwise: nobody means 16 MB.
{kind: sizeMemory, text: "16", want: 16384},
{kind: sizeMemory, text: "8g", want: 8192},
{kind: sizeMemory, text: "8GB", want: 8192},
{kind: sizeMemory, text: "512m", want: 512},
{kind: sizeMemory, text: "512mb", want: 512},
{kind: sizeMemory, text: "1.5g", want: 1536},
{kind: sizeMemory, text: "0", bad: "less memory"},
{kind: sizeMemory, text: "0.0001g", bad: "less memory"},
{kind: sizeMemory, text: "4.5m", bad: "whole number"},
{kind: sizeMemory, text: "lots", bad: "not an amount"},
{kind: sizeMemory, text: "99999g", bad: "typo"},
} {
got, err := parseSize(c.kind, c.text)
switch {
case c.bad != "":
if err == nil {
t.Errorf("parseSize(%q) = %d, want a refusal", c.text, got)
} else if !strings.Contains(err.Error(), c.bad) {
t.Errorf("parseSize(%q) refused with %q, which does not mention %q", c.text, err, c.bad)
}
case err != nil:
t.Errorf("parseSize(%q): %v", c.text, err)
case got != c.want:
t.Errorf("parseSize(%q) = %d, want %d", c.text, got, c.want)
}
}
}
// The rules a running machine is held to. This is the heart of the feature: what
// vSphere will refuse has to be refused here first, with the reason, rather than
// sent and bounced with a stack of SOAP.
func TestSizeObjectionOnARunningMachine(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true,
cpuHotAdd: true, cpuHotRemove: true, memoryHotAdd: true}
for _, c := range []struct {
what string
row vmRow
sz sizing
kind sizeKind
want int32
bad string // a piece of the objection, or "" when it must be allowed
}{
// Powered off, anything goes.
{"more vCPUs, machine off", off, plain, sizeCPUs, 8, ""},
{"fewer vCPUs, machine off", off, plain, sizeCPUs, 2, ""},
{"more memory, machine off", off, plain, sizeMemory, 16384, ""},
{"less memory, machine off", off, plain, sizeMemory, 4096, ""},
// Running, without the settings that would allow it.
{"more vCPUs, no hot-add", on, plain, sizeCPUs, 8, "hot-add is off"},
{"fewer vCPUs, no hot-remove", on, plain, sizeCPUs, 2, "hot-remove is off"},
{"more memory, no hot-add", on, plain, sizeMemory, 16384, "hot-add is off"},
// Running, with them.
{"more vCPUs, hot-add on", on, hot, sizeCPUs, 8, ""},
{"fewer vCPUs, hot-remove on", on, hot, sizeCPUs, 2, ""},
{"more memory, hot-add on", on, hot, sizeMemory, 16384, ""},
// The one no setting can allow: vSphere has no memory hot-remove.
{"less memory, hot-add on", on, hot, sizeMemory, 4096, "never be taken away"},
// Nothing to do.
{"the same vCPUs", on, hot, sizeCPUs, 4, "already"},
{"the same memory", on, hot, sizeMemory, 8192, "already"},
// Memory is whole multiples of four megabytes.
{"memory of 10 MB", off, plain, sizeMemory, 10, "multiples of 4 MB"},
// Not knowing what a machine has is a reason to leave it alone: there
// would be nothing honest to put on the left of the arrow.
{"unknown configuration", on, sizing{}, sizeCPUs, 64, "could not be read"},
{"unknown configuration, menu time", on, sizing{}, sizeMemory, 0, "could not be read"},
} {
short, long := sizeObjection(c.row, c.sz, c.kind, c.want)
switch {
case c.bad == "":
if long != "" {
t.Errorf("%s: refused with %q", c.what, long)
}
case long == "":
t.Errorf("%s: allowed, want a refusal mentioning %q", c.what, c.bad)
case !strings.Contains(long, c.bad):
t.Errorf("%s: refused with %q, which does not mention %q", c.what, long, c.bad)
}
// Both lengths or neither: the menu column and the message come from
// here together, and one without the other greys an entry out with
// nothing to explain it.
if (short == "") != (long == "") {
t.Errorf("%s: short %q and long %q disagree about whether there is an objection",
c.what, short, long)
}
}
}
// The socket topology is the machine's own. A vCPU count that does not divide
// into it is refused with the two that do, rather than made to fit by changing
// the number of sockets underneath somebody's per-socket licence.
func TestSizeKeepsTheSocketTopology(t *testing.T) {
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
sz := sizing{cpus: 8, coresPerSocket: 4, memoryMB: 8192, known: true}
_, long := sizeObjection(off, sz, sizeCPUs, 6)
if long == "" {
t.Fatal("6 vCPUs was allowed on a machine with 4 cores per socket")
}
for _, want := range []string{"4 cores per socket", "4 or 8"} {
if !strings.Contains(long, want) {
t.Errorf("the refusal does not say %q: %q", want, long)
}
}
if _, long := sizeObjection(off, sz, sizeCPUs, 12); long != "" {
t.Errorf("12 vCPUs is three whole sockets and was refused: %q", long)
}
// Below one whole socket there is no lower count to offer. Naming 0 would
// be offering an answer parseSize itself refuses.
for _, want := range []int32{1, 2, 3} {
_, long := sizeObjection(off, sz, sizeCPUs, want)
if long == "" {
t.Errorf("%d vCPUs was allowed with 4 cores per socket", want)
continue
}
if !strings.Contains(long, "— 4, not") {
t.Errorf("asking for %d does not offer 4 on its own: %q", want, long)
}
}
// The same across the topologies machines are actually built with. The
// counts sit after the dash, so that is where a zero is looked for:
// "0 or 4" would be the bug and "10 or 12" is a perfectly good answer, and
// a test that cannot tell them apart fails the day this loop is widened.
for _, per := range []int32{2, 4, 8} {
sz := sizing{cpus: 8, coresPerSocket: per, memoryMB: 8192, known: true}
for want := int32(1); want <= 20; want++ {
_, long := sizeObjection(off, sz, sizeCPUs, want)
switch {
case want%per == 0:
if long != "" && !strings.Contains(long, "already") {
t.Errorf("%d vCPUs is whole sockets of %d and was refused: %q", want, per, long)
}
case long == "":
t.Errorf("%d vCPUs was allowed with %d cores per socket", want, per)
case strings.Contains(long, "— 0"):
t.Errorf("%d vCPUs with %d per socket offers none at all: %q", want, per, long)
}
}
}
}
// A machine whose configuration could not be read is refused rather than sent:
// gvm would not know what it has, so it could put nothing honest on the left of
// the arrow — "0B → 8.0GB" is not a confirmation, it is a wrong number.
func TestAnUnreadableConfigurationIsRefused(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
for _, k := range []sizeKind{sizeCPUs, sizeMemory} {
if err := checkSize(on, sizing{}, k, 8); err == nil {
t.Errorf("the %s was changed on a machine whose configuration is unknown", k.what())
}
// And the menu says the same thing before any number is typed.
short, long := sizeObjection(on, sizing{}, k, 0)
if short == "" || long == "" {
t.Errorf("the %s entry is offered on a machine whose configuration is unknown", k.what())
}
}
}
// A machine whose configuration says nothing about the hot-plug settings is a
// machine without them, and one core per socket divides into everything — the
// fields are pointers, and unset must not read as zero cores per socket.
func TestSizingFromAnEmptyConfiguration(t *testing.T) {
if sz := sizingFrom(nil); sz.known {
t.Error("a machine with no configuration claimed to know its sizing")
}
sz := sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 4, MemoryMB: 8192},
})
if !sz.known {
t.Fatal("a configuration that was read is said to be unknown")
}
if sz.coresPerSocket != 1 {
t.Errorf("cores per socket = %d, want 1 where the server did not say", sz.coresPerSocket)
}
if sz.cpuHotAdd || sz.cpuHotRemove || sz.memoryHotAdd {
t.Error("a setting the server did not send was taken for on")
}
yes := true
four := int32(4)
sz = sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 8, MemoryMB: 16384, NumCoresPerSocket: &four},
CpuHotAddEnabled: &yes,
MemoryHotAddEnabled: &yes,
})
if sz.coresPerSocket != 4 || !sz.cpuHotAdd || !sz.memoryHotAdd || sz.cpuHotRemove {
t.Errorf("the settings did not come through: %+v", sz)
}
}
// The two entries are greyed out with the reason on a machine that cannot take
// the change at all, and offered on one that can — which is the weaker question
// the menu asks, before any number has been typed.
func TestTheMenuOffersTheHardwareEntries(t *testing.T) {
b := &browser{}
find := func(items []menuItem, k rune) menuItem {
t.Helper()
for _, m := range items {
if m.key == k {
return m
}
}
t.Fatalf("no menu entry %q", string(k))
return menuItem{}
}
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := plain
hot.cpuHotAdd, hot.memoryHotAdd = true, true
running := b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, plain)
for _, k := range []rune{'c', 'm'} {
m := find(running, k)
if m.available() {
t.Errorf("%q is offered on a running machine with no hot-plug", string(k))
}
if m.hint == "" || m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
for _, c := range []struct {
what string
menu []menuItem
}{
{"a machine that is off", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOff), nil, plain)},
{"a running machine with hot-add", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, hot)},
} {
for _, k := range []rune{'c', 'm'} {
if m := find(c.menu, k); !m.available() {
t.Errorf("%q is not offered on %s: %s", string(k), c.what, m.why)
}
}
}
}
// What the messages call the two figures is what the sheet calls them, so a
// change and the line it changes read as the same number.
func TestSizeIsShownTheWayTheSheetShowsIt(t *testing.T) {
if got := sizeCPUs.shown(4); got != "4 vCPU" {
t.Errorf("vCPUs shown as %q", got)
}
if got := sizeMemory.shown(8192); got != "8.0GB" {
t.Errorf("memory shown as %q, want the sheet's own spelling", got)
}
r := vmRow{vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{MemorySizeMB: 8192}}}}
if sheet, msg := r.memory(), sizeMemory.shown(8192); sheet != msg {
t.Errorf("the sheet says %q and a resize says %q", sheet, msg)
}
}
+126
View File
@@ -0,0 +1,126 @@
// seal.go — the passwords in ~/.gvmrc, not in plain sight.
//
// A sealed value looks like this, and the rest of the file stays as it was:
//
// vcenter.v308.password = gvmenc1:Lb2h…
//
// Only the value is sealed, never the file: urls, users, datacenters and the mail
// settings stay readable and the file stays editable by hand, comments and all.
// AES-256-GCM, the key derived per value with HKDF from a random salt, all of it
// packed into one base64 word.
//
// What this is and is not, plainly. FILEKEY is compiled into gvm and is the same
// in every copy of it, so whoever holds ~/.gvmrc *and* a gvm binary can open the
// value; prising the key out is an afternoon's work, not a cluster's. This is not
// a vault and it is not meant to be one. What it buys is that the password no
// longer stands in the clear in a backup, in a home directory that syncs
// somewhere, in an editor's swap file, or on a screen someone else is looking
// at — which is what was asked for. The file stays 0600 for the rest.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
)
// FILEKEY is what the values in ~/.gvmrc are sealed under: thirty-two random
// bytes, the same in every build so that a file written by one gvm opens in the
// next. There is nothing to guess here and so no reason to slow a guesser down —
// HKDF, not argon2, and a value opens in microseconds.
//
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
// Values written by earlier builds then no longer open, and gvm says so and names
// the vCenter whose password has to be entered again.
var FILEKEY = "8Vb0MUm04VP/aOZTTSGcqdN9NbNC6CETAhSXu1hwbIk="
const (
// sealTag marks a sealed value and leaves room to tell it apart from whatever
// a later version writes, should the scheme ever have to change.
sealTag = "gvmenc1:"
saltLen = 16
keyLen = 32
sealInfo = "gvmrc password"
)
// sealed reports whether a value is one, which is how gvm knows a password in the
// file still stands in the clear and wants sealing.
func sealed(value string) bool { return strings.HasPrefix(value, sealTag) }
// seal turns a password into the word that goes in the file.
func seal(secret string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", errf("cannot seal the password: %w", err)
}
gcm, err := sealGCM(salt)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", errf("cannot seal the password: %w", err)
}
// salt, nonce and the sealed bytes travel together: opening it needs all
// three and nothing else, so one word in the file is the whole story.
blob := append(salt, nonce...)
blob = gcm.Seal(blob, nonce, []byte(secret), nil)
return sealTag + base64.StdEncoding.EncodeToString(blob), nil
}
// unseal turns it back. A value that is not sealed comes back unchanged: that is
// how a password typed straight into the file, or handed over in the environment,
// keeps working.
func unseal(value string) (string, error) {
if !sealed(value) {
return value, nil
}
blob, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, sealTag))
if err != nil {
return "", errf("the sealed password is not readable: %w", err)
}
gcm, err := sealGCM(nil)
if err != nil {
return "", err
}
if len(blob) < saltLen+gcm.NonceSize() {
return "", errf("the sealed password is too short to be one")
}
salt, rest := blob[:saltLen], blob[saltLen:]
nonce, box := rest[:gcm.NonceSize()], rest[gcm.NonceSize():]
gcm, err = sealGCM(salt)
if err != nil {
return "", err
}
secret, err := gcm.Open(nil, nonce, box, nil)
if err != nil {
return "", errf("the sealed password does not open — it was sealed by a gvm " +
"built with another key, or it has been altered; enter it again")
}
return string(secret), nil
}
// sealGCM derives the key for one value and wraps it. A nil salt is allowed so a
// caller may ask for the nonce size before it knows the salt.
func sealGCM(salt []byte) (cipher.AEAD, error) {
root, err := base64.StdEncoding.DecodeString(FILEKEY)
if err != nil || len(root) == 0 {
return nil, errf("this gvm was built without a usable key for sealing passwords")
}
key, err := hkdf.Key(sha256.New, root, salt, sealInfo, keyLen)
if err != nil {
return nil, errf("cannot derive the key: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errf("cannot derive the key: %w", err)
}
return cipher.NewGCM(block)
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSealRoundTrip(t *testing.T) {
for _, secret := range []string{
"hunter2",
"",
"mit Leerzeichen und Ümläuten",
"a/b+c=d", // the characters base64 uses, to be sure nothing is confused
"gvmenc1:nearly", // a password that looks like a sealed value
strings.Repeat("x", 500),
} {
word, err := seal(secret)
if err != nil {
t.Fatalf("%q: %v", secret, err)
}
if !sealed(word) {
t.Errorf("%q sealed to something unmarked: %q", secret, word)
}
if secret != "" && strings.Contains(word, secret) {
t.Errorf("%q is readable in its own sealed form: %q", secret, word)
}
back, err := unseal(word)
if err != nil {
t.Fatalf("%q: %v", secret, err)
}
if back != secret {
t.Errorf("came back as %q, want %q", back, secret)
}
}
}
// A fresh salt and nonce each time, so two machines with the same password do not
// show the same word in the file — which would say they share one.
func TestSealIsDifferentEveryTime(t *testing.T) {
a, _ := seal("gleich")
b, _ := seal("gleich")
if a == b {
t.Error("the same password sealed twice gave the same word")
}
}
// A value that is not sealed is handed back as it is: that is how a password
// typed straight into the file, or given in the environment, keeps working.
func TestUnsealLeavesPlainValuesAlone(t *testing.T) {
for _, plain := range []string{"hunter2", "", "gvmenc", "gvmenc1"} {
got, err := unseal(plain)
if err != nil {
t.Errorf("%q: %v", plain, err)
}
if got != plain {
t.Errorf("%q came back as %q", plain, got)
}
}
}
// Something that says it is sealed and is not must be an error, never an empty
// password — that would reach vCenter and look like the wrong one.
func TestBrokenSealIsAnError(t *testing.T) {
good, _ := seal("hunter2")
for _, c := range []struct{ value, note string }{
{sealTag + "not base64 at all!!", "not base64"},
{sealTag, "nothing after the tag"},
{sealTag + "c2hvcnQ=", "too short to hold a salt"},
{good[:len(good)-4] + "AAAA", "altered"},
} {
got, err := unseal(c.value)
if err == nil {
t.Errorf("%s: opened to %q instead of failing", c.note, got)
}
if got != "" {
t.Errorf("%s: gave back %q as well as an error", c.note, got)
}
}
// And the message says what to do about it.
if _, err := unseal(good[:len(good)-4] + "AAAA"); err == nil ||
!strings.Contains(err.Error(), "enter it again") {
t.Errorf("the message does not say what to do: %v", err)
}
}
// The vCenter's own accessor names itself in the error, so a file with three
// servers says which one is the trouble.
func TestVCenterPasswordNamesItself(t *testing.T) {
word, _ := seal("hunter2")
v := VCenter{Name: "v308", Password: word}
if got, err := v.password(); err != nil || got != "hunter2" {
t.Errorf("password() gave %q, %v", got, err)
}
broken := VCenter{Name: "v308", Password: sealTag + "rubbish"}
_, err := broken.password()
if err == nil {
t.Fatal("a broken seal came back without an error")
}
if !strings.Contains(err.Error(), "v308") {
t.Errorf("the error does not name the vCenter: %v", err)
}
}
// The file rewriting. Everything but the password itself has to survive.
func TestSealPasswordsRewritesOnlyTheSecret(t *testing.T) {
const before = `# my configuration
default = v308
vcenter.v308.url = https://v308.example/
vcenter.v308.user = administrator@v308
vcenter.v308.password = hunter2 # the password
vcenter.v308.datacenter = PPB
vcenter.v108.password = "with spaces"
vcenter.v108.user = admin
# vcenter.old.password = leave-me-alone
mailto = me@example.org
`
dir := t.TempDir()
path := filepath.Join(dir, ".gvmrc")
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
t.Fatal(err)
}
quiet(t)
sealPasswords(path, before)
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(after)
// The secrets are gone and the rest is untouched, line for line.
for _, gone := range []string{"= hunter2", "with spaces"} {
if strings.Contains(got, gone) {
t.Errorf("%q still stands in the clear:\n%s", gone, got)
}
}
for _, kept := range []string{
"# my configuration", "default = v308",
"vcenter.v308.url = https://v308.example/",
"vcenter.v308.user = administrator@v308",
"vcenter.v308.datacenter = PPB",
"# the password", // the note beside it is the user's
"# vcenter.old.password = leave-me-alone", // a commented-out line is not a setting
"vcenter.v108.user = admin",
"mailto = me@example.org",
} {
if !strings.Contains(got, kept) {
t.Errorf("the rewrite lost %q:\n%s", kept, got)
}
}
if strings.Count(got, "\n") != strings.Count(before, "\n") {
t.Errorf("the number of lines changed:\n%s", got)
}
// Both passwords open again, and to what they were.
cfg := Config{}
applyConfig(&cfg, parseConfig(got))
want := map[string]string{"v308": "hunter2", "v108": "with spaces"}
for _, v := range cfg.VCenters {
if !sealed(v.Password) {
t.Errorf("%s was not sealed", v.Name)
continue
}
if secret, err := v.password(); err != nil || secret != want[v.Name] {
t.Errorf("%s opens to %q, %v — want %q", v.Name, secret, err, want[v.Name])
}
}
// And a second pass changes nothing at all.
sealPasswords(path, got)
again, _ := os.ReadFile(path)
if string(again) != got {
t.Errorf("sealing twice changed the file the second time:\n%s", string(again))
}
// The file it writes is still readable by nobody else.
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Errorf("the rewritten file is mode %04o", fi.Mode().Perm())
}
}
func TestWriteSettingReplacesOrAppends(t *testing.T) {
const before = `# top
vcenter.v308.user = admin
vcenter.v308.password = old
mailto = me@example.org
`
dir := t.TempDir()
path := filepath.Join(dir, ".gvmrc")
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
t.Fatal(err)
}
if err := writeSetting(path, "vcenter.v308.password", "new"); err != nil {
t.Fatal(err)
}
got := readFile(t, path)
if !strings.Contains(got, "vcenter.v308.password = new") {
t.Errorf("the setting was not replaced:\n%s", got)
}
if strings.Contains(got, "= old") {
t.Errorf("the old value is still there:\n%s", got)
}
for _, kept := range []string{"# top", "vcenter.v308.user = admin", "mailto = me@example.org"} {
if !strings.Contains(got, kept) {
t.Errorf("writing lost %q:\n%s", kept, got)
}
}
// A setting that is not there yet is added rather than lost.
if err := writeSetting(path, "vcenter.v108.password", "brandnew"); err != nil {
t.Fatal(err)
}
if got := readFile(t, path); !strings.Contains(got, "vcenter.v108.password = brandnew") {
t.Errorf("a new setting was not added:\n%s", got)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// What `gvm config` says about a password, without saying the password.
func TestPasswordState(t *testing.T) {
word, _ := seal("hunter2")
for _, c := range []struct {
v VCenter
want string
note string
}{
{VCenter{Name: "a", Password: word}, "password sealed", "a sealed one"},
{VCenter{Name: "b", Password: "hunter2"}, "in the clear", "one still in the clear"},
{VCenter{Name: "c", Password: sealTag + "rubbish"}, "does not open", "one that will not open"},
} {
got := stripEscapes(passwordState(c.v))
if !strings.Contains(got, c.want) {
t.Errorf("%s: %q does not say %q", c.note, got, c.want)
}
if strings.Contains(got, "hunter2") {
t.Errorf("%s: the password itself is in the output: %q", c.note, got)
}
}
}
// The claim of this whole file: what goes on the wire is the *opened* password,
// never the sealed word out of ~/.gvmrc.
//
// Checked at loginURL rather than against a server, because govmomi's simulator
// accepts any non-empty password by default — a login that succeeds there proves
// nothing at all about which password was sent.
func TestLoginURLCarriesTheOpenedPassword(t *testing.T) {
const secret = "the-real-one"
word, err := seal(secret)
if err != nil {
t.Fatal(err)
}
if strings.Contains(word, secret) {
t.Fatal("the sealed word contains the password, so this would prove nothing")
}
v := VCenter{
Name: "v308", URL: "https://v308.example/",
User: "administrator@v308", Password: word,
}
u, err := loginURL(v)
if err != nil {
t.Fatalf("loginURL: %v", err)
}
got, ok := u.User.Password()
if !ok {
t.Fatal("the url carries no password at all")
}
if got != secret {
t.Errorf("the url carries %q, want the opened password", got)
}
if got == word {
t.Error("the sealed word itself was put in the url")
}
if u.User.Username() != "administrator@v308" {
t.Errorf("the user is %q", u.User.Username())
}
if u.String() == "" || !strings.HasSuffix(u.Path, "/sdk") {
t.Errorf("the endpoint is %q", u.Path)
}
// A password still in the clear goes through untouched, so a file nobody has
// let gvm rewrite yet keeps working.
plain := v
plain.Password = "still-plain"
u, err = loginURL(plain)
if err != nil {
t.Fatal(err)
}
if got, _ := u.User.Password(); got != "still-plain" {
t.Errorf("a plain password came through as %q", got)
}
// And one that will not open never gets as far as a url.
broken := v
broken.Password = sealTag + "rubbish"
if u, err := loginURL(broken); err == nil {
got, _ := u.User.Password()
t.Errorf("a broken seal produced a url carrying %q", got)
} else if !strings.Contains(err.Error(), "v308") {
t.Errorf("the error does not name the vCenter: %v", err)
}
}
+872 -18
View File
File diff suppressed because it is too large Load Diff
+28 -27
View File
@@ -25,7 +25,7 @@ func snapList(vc VCenter, vmname string) error {
return err
}
tree, err := snapshots(s, vm)
tree, err := snapshotsOf(s, vm.Reference())
if err != nil {
return err
}
@@ -35,7 +35,7 @@ func snapList(vc VCenter, vmname string) error {
}
PF("Snapshots for %s (%s):\n", Cwb(vm.Name()), vc.Name)
printSnapshots(tree, "")
printSnapshots(tree)
return nil
}
@@ -62,11 +62,11 @@ func snapNew(vc VCenter, vmname string) error {
}
PO("snapshot " + name + " created")
tree, err := snapshots(s, vm)
tree, err := snapshotsOf(s, vm.Reference())
if err != nil {
return err
}
printSnapshots(tree, "")
printSnapshots(tree)
return nil
}
@@ -86,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
@@ -227,7 +240,7 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
return err
}
tree, err := snapshots(s, vm)
tree, err := snapshotsOf(s, vm.Reference())
if err != nil {
return err
}
@@ -235,7 +248,7 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
P("no snapshots for", vm.Name())
return nil
}
printSnapshots(tree, "")
printSnapshots(tree)
ok, err := confirmDestructive(vc, SF("remove ALL snapshots of %s", vm.Name()),
[][2]string{{"machine", vm.Name()}},
@@ -253,24 +266,12 @@ func snapRemoveAll(vc VCenter, vmname string, yes bool) error {
return nil
}
// snapshots reads the machine's snapshot tree, empty when it has none.
func snapshots(s *session, vm *object.VirtualMachine) ([]types.VirtualMachineSnapshotTree, error) {
var mvm mo.VirtualMachine
if err := vm.Properties(s.ctx, vm.Reference(), []string{"snapshot"}, &mvm); err != nil {
return nil, fmt.Errorf("%s: cannot read the snapshots of %s: %w", s.vc.Name, vm.Name(), err)
}
if mvm.Snapshot == nil {
return nil, nil
}
return mvm.Snapshot.RootSnapshotList, nil
}
func printSnapshots(list []types.VirtualMachineSnapshotTree, indent string) {
for _, s := range list {
PF("%s|- %s (%s)\n", indent, s.Name, s.CreateTime.Local().Format("02.01.2006 15:04"))
if len(s.ChildSnapshotList) > 0 {
printSnapshots(s.ChildSnapshotList, indent+" ")
}
// printSnapshots writes the tree the interactive list draws, indented one step
// so it reads as a listing rather than as output. One drawing for all three
// places a snapshot tree appears.
func printSnapshots(entries []snapEntry) {
for _, e := range entries {
PF(" %s\n", e.line())
}
}
+255
View File
@@ -0,0 +1,255 @@
// snapold.go — the snapshots nobody has come back for.
//
// This is the one recurring job in a vSphere estate that nothing in vCenter
// does for you: somebody takes a snapshot before an upgrade, the upgrade goes
// well, and the snapshot stays. Six weeks later its delta disk is bigger than
// the machine and the datastore is the thing that pages you.
//
// So the report reads every vCenter at once and prints one line per snapshot
// older than the age asked for, oldest first, with what it costs — and with -m
// it goes out by mail, which is the form it is actually useful in: this is a
// cron job, not something anyone remembers to run.
package main
import (
"sort"
"strings"
"github.com/fatih/color"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
var snapOldColumns = []printColumn{
{header: "MACHINE", width: 24},
{header: "VC", width: 4},
{header: "SNAPSHOT", width: 22},
{header: "AGE", width: 6, right: true},
{header: "TAKEN", width: 16},
{header: "SIZE", width: 9, right: true},
}
// oldSnap is one snapshot in the report, with the machine it belongs to.
type oldSnap struct {
row vmRow
snap snapEntry
bytes int64 // what it owns on the datastore, 0 when that could not be read
}
// snapOldReport prints the report and, when asked, mails it.
func snapOldReport(cfg Config, targets []VCenter, days int, mail bool) error {
if mail {
// Asked before the sweep, not after it: finding out that the mail cannot
// be sent is of no use once the report has scrolled past.
if err := cfg.mailReady(); err != nil {
return err
}
}
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
for _, why := range found.failed {
PE(why) // said before the report, where it will not be scrolled past
}
if err != nil {
return err
}
// Which machines have snapshots at all decides what the sizes are asked
// for, so it is worked out before anything else is read.
var carrying []vmRow
for _, r := range found.rows {
if len(r.snaps) > 0 {
carrying = append(carrying, r)
}
}
sizes := snapshotSizes(carrying)
var old []oldSnap
for _, r := range carrying {
for _, e := range r.snaps {
if e.days() < days {
continue
}
old = append(old, oldSnap{row: r, snap: e, bytes: sizes[snapKey(r, e)]})
}
}
// Oldest first: that is the order the work is done in, and the first line of
// a mail is the one that gets read.
sort.SliceStable(old, func(i, j int) bool {
return old[i].snap.when.Before(old[j].snap.when)
})
body := snapOldTable(old, days, len(found.rows))
if !mail || len(old) == 0 {
return nil
}
return sendmail(cfg, SF("old snapshots (%s)", vcNames(targets)), "<pre>"+body+"</pre>")
}
// snapOldTable prints the report and returns the same report as text.
//
// Both come out of one set of cells: the screen gets them painted and fitted,
// the mail gets them plain. A report that was formatted twice would eventually
// say two different things, and the mail is the copy nobody checks.
func snapOldTable(old []oldSnap, days, machines int) string {
if len(old) == 0 {
line := SF("no snapshot on any of the %d machines is %s old", machines, plural(days, "day"))
// Only where somebody is reading. This report runs weekly out of cron,
// and cron mails whatever is printed: a weekly "nothing to clean up"
// teaches everyone to filter the report away.
if !color.NoColor {
P(Cgb(line))
}
return line + "\n"
}
cells := make([][]cell, 0, len(old))
total := int64(0)
for _, o := range old {
total += o.bytes
cells = append(cells, []cell{
{o.row.name, colName},
{o.row.vc.Name, colWhere},
{o.snap.name, colChosen},
{SF("%dd", o.snap.days()), ageColor(o.snap.days())},
{o.snap.created, colAddress},
sizeCell(o.bytes),
})
}
cols := append([]printColumn(nil), snapOldColumns...)
widen(cols, cells)
head := SF("%d snapshots older than %s, on %s:", len(old), plural(days, "day"),
plural(countMachines(old), "machine"))
tail := SF("%s in %s, on %s", units.ByteSize(total),
plural(len(old), "snapshot"), plural(countMachines(old), "machine"))
P(Cwb(head))
P()
printRow(cols, "", nil)
for _, c := range cells {
printRow(cols, "", c)
}
P()
P(Cob(tail))
var sb strings.Builder
sb.WriteString(head + "\n\n")
sb.WriteString(plainRow(cols, nil) + "\n")
for _, c := range cells {
sb.WriteString(plainRow(cols, c) + "\n")
}
sb.WriteString("\n" + tail + "\n")
return sb.String()
}
// countMachines counts the machines rather than the snapshots: three snapshots
// of one machine is one machine's worth of work.
func countMachines(old []oldSnap) int {
seen := map[string]bool{}
for _, o := range old {
seen[o.row.id()] = true
}
return len(seen)
}
// sizeCell is what the snapshot owns, or a dash where the file layout could not
// be read. Nought bytes and "not known" are different answers and a report that
// prints 0 B for the second invites somebody to remove the wrong snapshot.
func sizeCell(b int64) cell {
if b <= 0 {
return cell{"-", colOff}
}
return cell{units.ByteSize(b).String(), colSize}
}
// ageColor takes the table's thresholds: yellow once a snapshot has stopped
// being this week's, red once it has stopped being this month's.
func ageColor(days int) string {
switch {
case days >= snapOldDays:
return colFull
case days >= snapStaleDays:
return colBusy
}
return colSize
}
// snapKey names one snapshot of one machine across every server: two vCenters
// hand out the same references, and a machine may hold two snapshots of one
// name.
func snapKey(r vmRow, e snapEntry) string { return r.id() + "/" + e.ref.Value }
// snapshotSizes is what each snapshot owns on the datastore.
//
// The file layout is asked for only for the machines that have snapshots, and
// for all of them at once per server: it lists every file of every machine and
// is far too much to carry through the ordinary sweep.
func snapshotSizes(rows []vmRow) map[string]int64 {
bySession := map[*session][]types.ManagedObjectReference{}
rowOf := map[string]vmRow{}
for _, r := range rows {
if r.sess == nil {
continue
}
bySession[r.sess] = append(bySession[r.sess], r.ref)
rowOf[r.sess.vc.Name+"/"+r.ref.Value] = r
}
out := map[string]int64{}
for s, refs := range bySession {
var vms []mo.VirtualMachine
if err := s.objects(refs, []string{"layoutEx"}, &vms); err != nil {
continue // the report is worth having without the sizes
}
for _, vm := range vms {
r, ok := rowOf[s.vc.Name+"/"+vm.Reference().Value]
if !ok {
continue
}
for ref, size := range snapshotBytes(vm.LayoutEx) {
out[r.id()+"/"+ref.Value] = size
}
}
}
return out
}
// snapshotBytes is the size of each snapshot in one machine's file layout.
//
// A snapshot owns its state file — the .vmsn, with the memory in it — and the
// *last* link of each of its disk chains. The links in front of that one are
// the disks its ancestors froze, and the delta the machine is writing to right
// now belongs to no snapshot at all: it is in the machine's own chain, not in
// any snapshot's. Counting only the last link is therefore both the whole of
// what removing this snapshot would give back and free of double counting,
// which summing whole chains is not.
func snapshotBytes(layout *types.VirtualMachineFileLayoutEx) map[types.ManagedObjectReference]int64 {
if layout == nil {
return nil
}
size := make(map[int32]int64, len(layout.File))
for _, f := range layout.File {
size[f.Key] = f.Size
}
out := make(map[types.ManagedObjectReference]int64, len(layout.Snapshot))
for _, sl := range layout.Snapshot {
total := size[sl.DataKey]
if sl.MemoryKey >= 0 && sl.MemoryKey != sl.DataKey {
total += size[sl.MemoryKey]
}
for _, d := range sl.Disk {
if len(d.Chain) == 0 {
continue
}
for _, key := range d.Chain[len(d.Chain)-1].FileKey {
total += size[key]
}
}
out[sl.Key] = total
}
return out
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"strings"
"testing"
"github.com/fatih/color"
"github.com/vmware/govmomi/vim25/types"
)
// snapRef is a snapshot's reference, as the file layout keys its entries by.
func snapRef(v string) types.ManagedObjectReference {
return types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: v}
}
// The size of a snapshot is what removing it would give back: its own state
// file and the last link of each of its disk chains. The links in front of that
// belong to its ancestors, and counting whole chains — which is the obvious
// thing to do — reports the same delta once per descendant.
func TestSnapshotBytesCountsEachDeltaOnce(t *testing.T) {
// Two snapshots in a line. base froze file 10; after-patch froze file 11.
// Each has a state file of its own (1 and 2), and the machine is writing to
// file 12, which belongs to neither.
layout := &types.VirtualMachineFileLayoutEx{
File: []types.VirtualMachineFileLayoutExFileInfo{
{Key: 1, Type: "snapshotData", Size: 100},
{Key: 2, Type: "snapshotData", Size: 200},
{Key: 10, Type: "diskExtent", Size: 1000},
{Key: 11, Type: "diskExtent", Size: 2000},
{Key: 12, Type: "diskExtent", Size: 4000}, // the running delta
},
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
{
Key: snapRef("snapshot-1"), DataKey: 1, MemoryKey: -1,
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
{FileKey: []int32{10}},
}}},
},
{
Key: snapRef("snapshot-2"), DataKey: 2, MemoryKey: -1,
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
{FileKey: []int32{10}}, {FileKey: []int32{11}},
}}},
},
},
}
got := snapshotBytes(layout)
if got[snapRef("snapshot-1")] != 1100 {
t.Errorf("the first snapshot is %d bytes, want 1100", got[snapRef("snapshot-1")])
}
if got[snapRef("snapshot-2")] != 2200 {
t.Errorf("the second snapshot is %d bytes, want 2200 — its parent's delta was counted again",
got[snapRef("snapshot-2")])
}
}
// A separate memory file is part of the snapshot; a memoryKey of -1 means there
// is not one, and the key that says so must not be looked up as a file.
func TestSnapshotBytesTakesTheMemoryFile(t *testing.T) {
layout := &types.VirtualMachineFileLayoutEx{
File: []types.VirtualMachineFileLayoutExFileInfo{
{Key: 1, Size: 100}, {Key: 3, Size: 8000},
},
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
{Key: snapRef("s"), DataKey: 1, MemoryKey: 3},
},
}
if got := snapshotBytes(layout)[snapRef("s")]; got != 8100 {
t.Errorf("with a memory file the snapshot is %d bytes, want 8100", got)
}
layout.Snapshot[0].MemoryKey = -1
if got := snapshotBytes(layout)[snapRef("s")]; got != 100 {
t.Errorf("without one it is %d bytes, want 100", got)
}
}
func TestSnapshotBytesOfNothing(t *testing.T) {
if got := snapshotBytes(nil); got != nil {
t.Errorf("a machine with no file layout reported %v", got)
}
}
// Nought bytes and "the layout could not be read" are different answers, and
// the second must not look like a snapshot that costs nothing.
func TestSizeCellSaysWhenItDoesNotKnow(t *testing.T) {
if got := sizeCell(0); got.text != "-" || got.col != colOff {
t.Errorf("an unknown size is shown as %q", got.text)
}
if got := sizeCell(1 << 30); !strings.Contains(got.text, "GB") {
t.Errorf("a gigabyte is shown as %q", got.text)
}
}
// The report's ages take the table's colours, so a red count in the list and a
// red line in the mail mean the same thing.
func TestAgeColorMatchesTheTable(t *testing.T) {
for _, c := range []struct {
days int
want string
}{
{0, colSize}, {snapStaleDays - 1, colSize},
{snapStaleDays, colBusy}, {snapOldDays - 1, colBusy},
{snapOldDays, colFull}, {365, colFull},
} {
if got := ageColor(c.days); got != c.want {
t.Errorf("a snapshot of %d days is coloured wrongly", c.days)
}
}
// And the same thresholds the column uses.
r := testRow("web01", true, "10.0.0.5")
r.snaps = []snapEntry{aged("s", snapOldDays+1)}
if r.snapColor() != colFull {
t.Error("the column and the report disagree about an old snapshot")
}
}
// A report with nothing in it says so in the same breath as saying what it
// looked for: "no old snapshots" without the age is not an answer.
//
// And it says it only where somebody is reading. The report runs weekly out of
// cron, and cron mails whatever is printed: a weekly "nothing to clean up"
// teaches everybody to filter the report away, and then the week it has
// something to say is filtered away with it.
func TestAnEmptyReportSaysWhatItLookedForOnlyOnATerminal(t *testing.T) {
var body string
out := captureStdout(t, func() { body = snapOldTable(nil, 30, 212) })
if out != "" {
t.Errorf("an empty report printed %q into a pipe", out)
}
if !strings.Contains(body, "212") || !strings.Contains(body, "30 days") {
t.Errorf("the empty report reads %q", body)
}
onATerminal(t)
out = captureStdout(t, func() { snapOldTable(nil, 30, 212) })
if !strings.Contains(stripEscapes(out), "no snapshot") {
t.Errorf("on a terminal the empty report printed %q", out)
}
}
// onATerminal makes the colour library — which is also gvm's answer to "is
// anybody reading this" — say yes for the length of one test.
func onATerminal(t *testing.T) {
t.Helper()
saved := color.NoColor
color.NoColor = false
t.Cleanup(func() { color.NoColor = saved })
}
// The report counts machines, not snapshots: three snapshots of one machine is
// one machine's worth of work.
func TestCountMachines(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
other := testRow("db01", true, "10.0.0.6")
other.ref = types.ManagedObjectReference{Value: "vm-43"}
old := []oldSnap{
{row: r, snap: aged("a", 40)},
{row: r, snap: aged("b", 50)},
{row: other, snap: aged("c", 60)},
}
if got := countMachines(old); got != 2 {
t.Errorf("three snapshots on two machines counted as %d machines", got)
}
}
// The mail carries the same table as the screen, and carries no escape
// sequences: a mail client shows those as four stray characters per colour.
func TestTheMailedReportHasNoColours(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
old := []oldSnap{{row: r, snap: aged("before-patch", 63), bytes: 3 << 30}}
var body string
out := captureStdout(t, func() { body = snapOldTable(old, 30, 1) })
for _, want := range []string{"web01", "before-patch", "63d"} {
if !strings.Contains(body, want) {
t.Errorf("the mail leaves out %q:\n%s", want, body)
}
if !strings.Contains(stripEscapes(out), want) {
t.Errorf("the screen leaves out %q:\n%s", want, out)
}
}
if strings.ContainsRune(body, 0x1b) {
t.Errorf("the mail carries escape sequences:\n%q", body)
}
}
+98 -31
View File
@@ -11,35 +11,112 @@
package main
import (
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/methods"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// snapEntry is one snapshot of a machine, flattened out of the tree with its
// depth kept so the list can still show what descends from what.
// snapEntry is one snapshot of a machine, flattened out of the tree. The tree is
// what a snapshot list is *about* — which state descends from which — so each
// entry carries the drawing of its own branch rather than only a depth: that way
// the sheet, the picker and `snap -l` all show one and the same tree instead of
// three approximations of it.
type snapEntry struct {
ref types.ManagedObjectReference
name string
desc string
created string
created string // when it was taken, as it is shown
when time.Time // and as it is compared: an age is not a string
depth int
current bool // the state the machine is running from
prefix string // the branch drawn in front of the name
current bool // the state the machine is running from
}
// label is the entry as one line: indented by depth, dated, and marked when it is
// the snapshot the machine is currently on.
func (e snapEntry) label() string {
s := SR(" ", e.depth) + e.name + " (" + e.created + ")"
// age is how long ago the snapshot was taken. A snapshot whose date did not
// come back has no age rather than an age of nothing: zero would read as
// "taken just now", which is the opposite of what an absent date means.
func (e snapEntry) age() (time.Duration, bool) {
if e.when.IsZero() {
return 0, false
}
return time.Since(e.when), true
}
// days is the age in whole days, for the reports that count in them.
func (e snapEntry) days() int {
d, ok := e.age()
if !ok {
return 0
}
return int(d.Hours() / 24)
}
// line is the entry as it is shown: its branch, its name, when it was taken, and
// a mark when it is the one the machine is running from.
func (e snapEntry) line() string {
s := e.prefix + e.name + " (" + e.created + ")"
if e.current {
s += " ← current"
}
return s
}
// The pieces of the tree. A child hangs off a branch; the last child closes it,
// and the run underneath either carries the line on or leaves the space blank.
const (
treeBranch = "├─ "
treeLast = "└─ "
treeCarry = "│ "
treeBlank = " "
)
// flattenSnapshots walks the snapshot tree into a list, parents before their
// children, drawing each branch as it goes. Root snapshots start flush left:
// they are the beginnings of separate lines of descent, not children of anything.
func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.ManagedObjectReference) []snapEntry {
var out []snapEntry
var walk func(nodes []types.VirtualMachineSnapshotTree, depth int, prefix string)
walk = func(nodes []types.VirtualMachineSnapshotTree, depth int, prefix string) {
for i, n := range nodes {
last := i == len(nodes)-1
branch, carry := treeBranch, treeCarry
if last {
branch, carry = treeLast, treeBlank
}
if depth == 0 { // a root hangs off nothing
branch, carry = "", ""
}
out = append(out, snapEntry{
ref: n.Snapshot,
name: n.Name,
desc: n.Description,
created: n.CreateTime.Local().Format("02.01.2006 15:04"),
when: n.CreateTime,
depth: depth,
prefix: prefix + branch,
current: n.Snapshot == current,
})
walk(n.ChildSnapshotList, depth+1, prefix+carry)
}
}
walk(roots, 0, "")
return out
}
// snapshotsOf reads the machine's snapshots as a flat list, parents before their
// children. Empty when it has none.
//
// The sweep brings the same tree back for every machine at once (browse.go), and
// this asks for one machine's again. That is deliberate: everything that acts on
// a snapshot addresses it by reference, and a reference out of a sweep that ran
// minutes ago may name a snapshot somebody has since removed. The table may be a
// few minutes old; the list one is about to revert to may not be.
func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, error) {
vm := object.NewVirtualMachine(s.client.Client, ref)
@@ -47,32 +124,22 @@ func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, err
if err := vm.Properties(s.ctx, ref, []string{"snapshot"}, &mvm); err != nil {
return nil, errf("%s: cannot read the snapshots: %w", s.vc.Name, err)
}
if mvm.Snapshot == nil {
return nil, nil
}
return snapshotsIn(mvm.Snapshot), nil
}
// snapshotsIn is the tree as it comes out of the property collector, flattened.
// One function for both ways of getting there — the sheet asking for one machine
// and the sweep bringing back every machine — so the two cannot disagree about
// what a machine's snapshots are.
func snapshotsIn(info *types.VirtualMachineSnapshotInfo) []snapEntry {
if info == nil {
return nil
}
current := types.ManagedObjectReference{}
if mvm.Snapshot.CurrentSnapshot != nil {
current = *mvm.Snapshot.CurrentSnapshot
if info.CurrentSnapshot != nil {
current = *info.CurrentSnapshot
}
var out []snapEntry
var walk func(list []types.VirtualMachineSnapshotTree, depth int)
walk = func(list []types.VirtualMachineSnapshotTree, depth int) {
for _, t := range list {
out = append(out, snapEntry{
ref: t.Snapshot,
name: t.Name,
desc: t.Description,
created: t.CreateTime.Local().Format("02.01.2006 15:04"),
depth: depth,
current: t.Snapshot == current,
})
walk(t.ChildSnapshotList, depth+1)
}
}
walk(mvm.Snapshot.RootSnapshotList, 0)
return out, nil
return flattenSnapshots(info.RootSnapshotList, current)
}
// revertToSnapshot puts the machine back to the exact snapshot given. Everything
+374
View File
@@ -0,0 +1,374 @@
// 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
// legendBreak starts a new line of the legend at this entry. Thirteen
// orders do not fit across eighty columns, and a legend that ran off the
// edge would hide the very choices it exists to offer — so it is two lines,
// broken where the meaning breaks rather than wherever the width runs out.
legendBreak bool
}
// sortOrders in the order the legend lists them, which is two groups: first
// what a machine is doing and what it wants doing to it, then what it is made
// of and where it lives. The legend breaks between the two.
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) }},
// How many rollback points the machine is carrying, most first. Nought is a
// figure here and not a missing one — nothing to clean up is a fact about
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key carries no mnemonic — every letter that does was taken — so it is
// simply one that is free and easy to reach. The name is what the command
// line takes: `--sort snapshots`, or `--sort snaps`.
{key: 'z', name: "snapshots", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
// with no snapshots has no age, and sorts to the bottom either way round.
{key: 'o', name: "snapshot age", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
// By what is wrong with the machine, worst first: broken above wants-a-look
// above nothing to report, and within each the machine with the most to
// answer for first. Sorting the reasons as text would put "alarm" above
// "disks need consolidating" and mean nothing at all.
{key: 'w', name: "issues", natural: true,
cmp: func(a, b vmRow) int { return cmpIssues(a, b) }},
{key: 's', name: "memory size", natural: true, legendBreak: 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
}
// issueRank is how bad the machine's worst reason is: two for something broken,
// one for something that wants a look, nought for nothing to report.
func issueRank(r vmRow) int {
rank := 0
for _, i := range r.issueList() {
if i.bad {
return 2
}
rank = 1
}
return rank
}
// cmpIssues orders by that, and within it by how many reasons there are: a
// machine with a full disk *and* no Tools is worse off than one with only the
// disk. Nothing to report is nought and sorts where nought belongs, so the
// order run the other way up is the machines that are fine, by name.
func cmpIssues(a, b vmRow) int {
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
return n
}
return cmpInt(len(a.issueList()), len(b.issueList()))
}
// 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 choices, laid out for a terminal of this width: one line
// where they fit on one, and otherwise the two groups they fall into — what the
// machine is doing and wants doing to it, then what it is made of and where it
// lives.
//
// One line is the better answer and the usual one; two is what a narrow
// terminal gets instead of a legend that runs off the right-hand edge, hiding
// the very choices it exists to offer. Decided here, at render time, so a
// window that is dragged wider gets the one line back — the same way the table
// itself is fitted (fitColumns) and the sheet is wrapped.
//
// Terse either way: it shares the bottom of the screen with nothing but itself,
// and the title says what the order is anyway, so nobody who misses it is lost.
func sortLegend(cols int) []string {
const label = "sort: "
entries := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
entries = append(entries, string(o.key)+"·"+shortName(o.name))
}
// Reverse is not an order of its own and goes at the end.
entries = append(entries, string(sortReverse)+"·reverse")
if one := label + strings.Join(entries, " "); len([]rune(one)) <= cols {
return []string{one}
}
// Two, broken where the meaning breaks. The second line is indented under
// the first one's entries rather than under its label, so the two read as
// one list and not as a sentence continued.
at := len(sortOrders)
for i, o := range sortOrders {
if o.legendBreak {
at = i
break
}
}
return []string{
label + strings.Join(entries[:at], " "),
SR(" ", len(label)) + strings.Join(entries[at:], " "),
}
}
// 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 "power":
return "pwr" // as the column is headed, and it keeps the legend inside 80
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"
case "snapshot age":
return "old"
case "snapshots":
return "snaps"
case "issues":
return "why" // as the column is headed
}
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() {
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
cols, _ := termSize()
lines := sortLegend(cols)
b.prompt = &prompt{text: lines[0], col: colPrompt}
if len(lines) > 1 {
b.prompt.more = strings.Join(lines[1:], " ")
}
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"
case "snapshot age", "snapshots":
return header == "SNAP"
case "issues":
return header == "WHY"
}
return false
}
+430
View File
@@ -0,0 +1,430 @@
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)
}
// In the table the order belongs to: sorting by what is wrong with a
// machine lights the reason column, which only the issues listing has.
table := listColumns(b.rows, o.name == "issues")
lit := 0
for _, c := range table {
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) {
lines := sortLegend(80)
legend := strings.Join(lines, "\n")
for _, o := range sortOrders {
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the legend does not offer %q for %s:\n%s", string(o.key), o.name, legend)
}
}
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
t.Errorf("the legend does not offer the reverse:\n%s", legend)
}
// Every line of it fits a terminal of eighty. The legend has the bottom two
// rows to itself, and they begin at the left edge rather than behind the
// pointer's gutter, so the budget is eighty whole — but a line over it would
// be truncated, and the choices it hid would be unreachable in the only
// place they are offered.
for i, line := range lines {
if n := len([]rune(line)); n > 80 {
t.Errorf("legend line %d is %d columns wide: %s", i+1, n, line)
}
}
// Two rows, and not three: there are only two to spare.
if len(lines) > 2 {
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
}
// One line wherever one line will do — which is every terminal wide enough
// for it, and the usual case. A legend on two lines is what a narrow
// terminal gets instead of one that runs off the edge.
wide := sortLegend(200)
if len(wide) != 1 {
t.Errorf("a wide terminal gets the legend on %d lines:\n%s", len(wide), strings.Join(wide, "\n"))
}
if n := len([]rune(wide[0])); n > 200 {
t.Errorf("the one-line legend is %d columns wide", n)
}
// And every choice is on it, so nothing is reachable only when the terminal
// happens to be narrow.
for _, o := range sortOrders {
if !strings.Contains(wide[0], string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the one-line legend does not offer %q for %s: %s", string(o.key), o.name, wide[0])
}
}
if !strings.Contains(wide[0], string(sortReverse)+"·reverse") {
t.Errorf("the one-line legend does not offer the reverse: %s", wide[0])
}
// The width at which it gives up on one line is the width of the legend
// itself, and not a number written down somewhere.
if got := sortLegend(len([]rune(wide[0]))); len(got) != 1 {
t.Error("the legend broke in two at exactly its own width")
}
if got := sortLegend(len([]rune(wide[0])) - 1); len(got) != 2 {
t.Error("the legend stayed on one line one column too narrow for it")
}
// 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)
}
}
}
// By how many snapshots a machine is carrying. Nought is a figure here and not
// a missing one — nothing to clean up is a fact about the machine — so it sorts
// where nought belongs: at the bottom going down, at the top coming back up.
func TestSortBySnapshotCount(t *testing.T) {
rows := []vmRow{
sortRow("none", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("three", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("one", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
}
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
rows[2].snaps = []snapEntry{aged("a", 1)}
if got := orderOf(t, rows, 'z', true); got != "three one none" {
t.Errorf("most snapshots first gave %q", got)
}
if got := orderOf(t, rows, 'z', false); got != "none one three" {
t.Errorf("fewest first gave %q", got)
}
}
// By what is wrong with the machine: broken above wants-a-look above nothing to
// report, and within each the machine with the most to answer for first.
func TestSortByIssues(t *testing.T) {
rows := []vmRow{
sortRow("fine", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("warned", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("broken", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
sortRow("worse", "v308", "esx1", "10.0.0.4", 1, 1024, 0, 0, true),
}
// sortRow builds machines with no guest information at all, which reports
// nothing: the issues that are only true of a running machine need a guest
// to be true of. So each is given exactly what it is named for.
for i := range rows {
rows[i].vm.Guest = &types.GuestInfo{
ToolsRunningStatus: "guestToolsRunning",
IpAddress: rows[i].ip(),
}
}
rows[1].vm.Summary.OverallStatus = types.ManagedEntityStatusYellow // one warning
rows[2].vm.Summary.Runtime.ConsolidationNeeded = true // one breakage
rows[3].vm.Summary.Runtime.ConsolidationNeeded = true // and the same
rows[3].vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // plus a warning
if got := orderOf(t, rows, 'w', true); got != "worse broken warned fine" {
t.Errorf("worst first gave %q", got)
}
// And the other way up, the machines with nothing wrong come first, which
// is a listing worth having too.
if got := orderOf(t, rows, 'w', false); got != "fine warned broken worse" {
t.Errorf("nothing to report first gave %q", got)
}
}
// orderOf sorts the rows given by one order and returns the names in order.
//
// A key that is not an order at all is fatal here rather than left to sort by
// name: the browser's default order is index nought, so a test naming a letter
// that has been renamed would go on passing while checking the name order.
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
t.Helper()
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
found := false
for i, o := range sortOrders {
if o.key == key {
b.sortBy, found = i, true
}
}
if !found {
t.Fatalf("%q is not one of the sort orders", string(key))
}
b.applySort()
var names []string
for _, r := range b.rows {
names = append(names, r.name)
}
return strings.Join(names, " ")
}
// The two snapshot orders are different questions: how many, and how old. A
// machine with one snapshot from March needs attention before one with six
// from this morning.
func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
rows := []vmRow{
sortRow("many-new", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("one-ancient", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
}
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
rows[1].snaps = []snapEntry{aged("march", 200)}
if got := orderOf(t, rows, 'z', true); got != "many-new one-ancient" {
t.Errorf("by count: %q", got)
}
if got := orderOf(t, rows, 'o', true); got != "one-ancient many-new" {
t.Errorf("by age: %q", got)
}
}
+170
View File
@@ -0,0 +1,170 @@
// tasks.go — what a machine is in the middle of.
//
// A machine being cloned, migrated or consolidated looks in the table exactly
// like one that is idle, and that is the one moment when the table is wrong
// about the most important thing on the line: why the machine is slow, why its
// disk is growing, why it must be left alone. vCenter keeps the answer on the
// machine itself, in recentTask, so the sweep picks it up along the way.
//
// "Recent" is vCenter's word, not gvm's: a task stays on that list for minutes
// after it has finished. Only the ones that are still going on are shown — a
// finished task is history, and the event log is where history belongs.
package main
import (
"strings"
"time"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// runningTask is one thing vCenter is doing to a machine right now.
type runningTask struct {
what string // the operation, in one word
queued bool // accepted but not started yet
progress int32
since time.Time
}
// taskVerbs are the operations worth naming in the eight characters the column
// has left once a percentage is beside them — "pwr off" for the same reason the
// sort legend says pwr. The key is
// vSphere's descriptionId, which is the same word on every vCenter — the
// task's own Description is localised, so a German vCenter would put German
// into an English table.
//
// Anything not listed keeps its method name, which is still the truth and still
// tells an operator to leave the machine alone.
var taskVerbs = map[string]string{
"createSnapshot": "snapshot",
"removeSnapshot": "rm snap",
"removeAllSnapshots": "rm snaps",
"revertToSnapshot": "revert",
"consolidateDisks": "consolid",
"promoteDisks": "consolid",
"clone": "clone",
"relocate": "migrate",
"migrate": "migrate",
"reconfigure": "reconfig",
"powerOn": "pwr on",
"powerOff": "pwr off",
"suspend": "suspend",
"reset": "reset",
"shutdownGuest": "shutdown",
"rebootGuest": "reboot",
"destroy": "delete",
"customize": "custom",
"createDisk": "disk",
"extendDisk": "disk",
"upgradeTools": "tools",
"upgradeVirtualHardware": "hardware",
}
// taskVerb is the operation in one word. A descriptionId reads
// "VirtualMachine.createSnapshot"; the kind in front of the dot is already the
// row the task is on, so only what follows it says anything.
func taskVerb(descriptionID string) string {
method := descriptionID
if i := strings.LastIndexByte(method, '.'); i >= 0 {
method = method[i+1:]
}
if v, ok := taskVerbs[method]; ok {
return v
}
if method == "" {
return "busy"
}
return method
}
// cell is the task as the table shows it: what it is and how far it has got.
// A percentage is only shown once there is one — vCenter reports 0 both for
// "just started" and for "no idea", and a task sitting at 0 % looks stuck when
// it is merely young.
func (t runningTask) cell() string {
if t.queued {
return t.what + " q"
}
if t.progress > 0 {
return SF("%s %d%%", t.what, t.progress)
}
return t.what
}
// line is the task on the machine's sheet, where there is room to say when it
// started and that queued means nothing has happened yet.
func (t runningTask) line() string {
parts := []string{t.what}
switch {
case t.queued:
parts = append(parts, "queued, not started")
case t.progress > 0:
parts = append(parts, SF("%d %%", t.progress))
}
if !t.since.IsZero() {
parts = append(parts, "since "+t.since.Local().Format("15:04:05"))
}
return join(parts)
}
// runningTasks maps the machines of one sweep to what is being done to them.
// The task references come off the machines themselves and are read in one
// call for the whole inventory, so this costs one round trip per vCenter no
// matter how much is going on.
//
// A machine with two tasks at once keeps the one that has started; of two
// running ones, the first. There is one column, and "something is going on" is
// what it has to say.
func runningTasks(s *session, vms []mo.VirtualMachine) map[types.ManagedObjectReference]runningTask {
var refs []types.ManagedObjectReference
for _, vm := range vms {
refs = append(refs, vm.RecentTask...)
}
if len(refs) == 0 {
return nil
}
tasks, err := s.tasks(refs)
if err != nil {
return nil // the table is worth having without it
}
byRef := make(map[types.ManagedObjectReference]runningTask, len(tasks))
for _, t := range tasks {
rt, ok := taskOf(t.Info)
if !ok {
continue
}
byRef[t.Reference()] = rt
}
out := make(map[types.ManagedObjectReference]runningTask, len(vms))
for _, vm := range vms {
for _, ref := range vm.RecentTask {
rt, ok := byRef[ref]
if !ok {
continue
}
if cur, seen := out[vm.Reference()]; seen && (!cur.queued || rt.queued) {
continue
}
out[vm.Reference()] = rt
}
}
return out
}
// taskOf is one task, when it is still going on.
func taskOf(info types.TaskInfo) (runningTask, bool) {
switch info.State {
case types.TaskInfoStateRunning:
since := info.QueueTime
if info.StartTime != nil {
since = *info.StartTime
}
return runningTask{what: taskVerb(info.DescriptionId), progress: info.Progress, since: since}, true
case types.TaskInfoStateQueued:
return runningTask{what: taskVerb(info.DescriptionId), queued: true, since: info.QueueTime}, true
}
return runningTask{}, false
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The operation is named from vSphere's descriptionId and not from the task's
// own Description, which vCenter localises: a German vCenter would otherwise
// put German words in an English table.
func TestTaskVerb(t *testing.T) {
for _, c := range []struct{ id, want string }{
{"VirtualMachine.createSnapshot", "snapshot"},
{"VirtualMachine.removeAllSnapshots", "rm snaps"},
{"VirtualMachine.relocate", "migrate"},
{"VirtualMachine.reconfigure", "reconfig"},
{"Datacenter.somethingNobodyHasHeardOf", "somethingNobodyHasHeardOf"},
{"noDotAtAll", "noDotAtAll"},
{"", "busy"},
} {
if got := taskVerb(c.id); got != c.want {
t.Errorf("taskVerb(%q) = %q, want %q", c.id, got, c.want)
}
}
}
// Every verb fits the column, or the cell it is put in would be truncated with
// the percentage — the part that says whether anything is happening — cut off.
func TestEveryTaskVerbFitsItsColumn(t *testing.T) {
room := taskColumn.width - len(" 100%")
for id, verb := range taskVerbs {
if len(verb) > room {
t.Errorf("%s is called %q, which is %d characters of the %d there are",
id, verb, len(verb), room)
}
}
}
// A task at nought per cent has not reported any progress, which is not the
// same as having made none: printing 0 % makes a task that has just started
// look stuck.
func TestTaskCell(t *testing.T) {
for _, c := range []struct {
task runningTask
want string
}{
{runningTask{what: "clone", progress: 40}, "clone 40%"},
{runningTask{what: "clone"}, "clone"},
{runningTask{what: "clone", queued: true}, "clone q"},
{runningTask{what: "clone", queued: true, progress: 10}, "clone q"},
} {
if got := c.task.cell(); got != c.want {
t.Errorf("cell() = %q, want %q", got, c.want)
}
}
}
func TestTaskLineSpellsQueuedOut(t *testing.T) {
since := time.Date(2026, 9, 8, 11, 42, 0, 0, time.Local)
got := runningTask{what: "consolid", queued: true, since: since}.line()
if got != "consolid · queued, not started · since 11:42:00" {
t.Errorf("the sheet line reads %q", got)
}
got = runningTask{what: "clone", progress: 40, since: since}.line()
if got != "clone · 40 % · since 11:42:00" {
t.Errorf("the sheet line reads %q", got)
}
}
// Only what is still going on is a task. A task that has finished stays on
// vCenter's recentTask list for minutes afterwards, and a table that showed it
// would report a snapshot being taken long after it was taken.
func TestOnlyRunningTasksCount(t *testing.T) {
for _, c := range []struct {
state types.TaskInfoState
want bool
}{
{types.TaskInfoStateRunning, true},
{types.TaskInfoStateQueued, true},
{types.TaskInfoStateSuccess, false},
{types.TaskInfoStateError, false},
} {
_, ok := taskOf(types.TaskInfo{State: c.state, DescriptionId: "VirtualMachine.clone"})
if ok != c.want {
t.Errorf("a %s task counts = %v, want %v", c.state, ok, c.want)
}
}
}
// A running task is timed from when it started, a queued one from when it was
// accepted — there is nothing else to time it from.
func TestTaskTakesItsTimeFromTheRightEnd(t *testing.T) {
queued := time.Date(2026, 9, 8, 11, 0, 0, 0, time.UTC)
started := time.Date(2026, 9, 8, 11, 5, 0, 0, time.UTC)
got, _ := taskOf(types.TaskInfo{
State: types.TaskInfoStateRunning, QueueTime: queued, StartTime: &started,
DescriptionId: "VirtualMachine.clone",
})
if !got.since.Equal(started) {
t.Errorf("a running task is timed from %v", got.since)
}
got, _ = taskOf(types.TaskInfo{
State: types.TaskInfoStateQueued, QueueTime: queued,
DescriptionId: "VirtualMachine.clone",
})
if !got.since.Equal(queued) {
t.Errorf("a queued task is timed from %v", got.since)
}
}
+17 -230
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,14 +49,16 @@ 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()]
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 string(ret)
return tmp
}
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
@@ -169,70 +71,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 +83,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 +106,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
+82 -13
View File
@@ -83,8 +83,12 @@ const (
keyShiftTab
keyEnter
keyCtrlA
keyCtrlE
keyCtrlL
keyCtrlO
keyCtrlR
keyCtrlS
keyCtrlW
keyCtrlC
keyEsc
)
@@ -131,15 +135,53 @@ func (kr *keyReader) next() key {
if !ok {
return key{special: keyCtrlC} // input closed - treat like cancel
}
return kr.decode(b)
}
// nextWithin is next() with a limit on how long it waits — for live mode, which
// has to be able to stop waiting and re-read the list.
//
// The limit is on the *first* byte only, which is why it is here and not around
// next() as a whole: a control sequence arrives in one burst, and a deadline
// that could expire in the middle of "ESC [ A" would turn one arrow key into an
// Esc and a stray letter in the filter.
func (kr *keyReader) nextWithin(d time.Duration) (key, bool) {
select {
case b, ok := <-kr.ch:
if !ok {
return key{special: keyCtrlC}, true
}
return kr.decode(b), true
case <-time.After(d):
return key{}, false
}
}
// decode turns one byte, and whatever else belongs with it, into a key.
func (kr *keyReader) decode(b byte) key {
switch b {
case 0x03:
return key{special: keyCtrlC}
case 0x01:
return key{special: keyCtrlA}
case 0x05:
return key{special: keyCtrlE}
// ^l, which in a shell redraws the screen. Nothing is lost by taking it:
// gvm redraws the whole screen on every keystroke anyway, so there is
// nothing here for a redraw key to fix.
case 0x0c:
return key{special: keyCtrlL}
case 0x0f:
return key{special: keyCtrlO}
case 0x12:
return key{special: keyCtrlR}
case 0x13:
return key{special: keyCtrlS}
// ^w, and not the ^i the mnemonic wants: Ctrl-I *is* Tab (0x09), which the
// list already moves down with, so an issues filter bound to it would have
// scrolled the table instead.
case 0x17:
return key{special: keyCtrlW}
case 0x1b:
return kr.readEscape()
case '\r', '\n':
@@ -183,11 +225,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 +272,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 {
+84 -4
View File
@@ -15,8 +15,10 @@ import (
"github.com/vmware/govmomi"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/property"
"github.com/vmware/govmomi/view"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// dialTimeout bounds a login. Without one a vCenter that accepts the connection
@@ -30,6 +32,12 @@ type session struct {
ctx context.Context
client *govmomi.Client
cancel context.CancelFunc
// The names of the alarm definitions this server has triggered, filled in by
// the sweep when anything is actually alarming (browse.go). It belongs to
// the connection rather than to a machine: one alarm stands against many
// machines, and its name is worth reading once per server, not once per row.
alarms map[types.ManagedObjectReference]string
}
// connect logs in to one server. The caller closes what comes back — a session
@@ -38,12 +46,11 @@ type session struct {
func connect(vc VCenter) (*session, error) {
ctx, cancel := context.WithCancel(context.Background())
u, err := url.Parse(vc.sdkURL())
u, err := loginURL(vc)
if err != nil {
cancel()
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
return nil, err
}
u.User = url.UserPassword(vc.User, vc.Password)
dial, dialCancel := context.WithTimeout(ctx, dialTimeout)
defer dialCancel()
@@ -56,6 +63,24 @@ func connect(vc VCenter) (*session, error) {
return &session{vc: vc, ctx: ctx, client: client, cancel: cancel}, nil
}
// loginURL is the endpoint with the credentials in it — the one place where a
// password is opened and handed over. Its own function so that what goes on the
// wire can be checked without a server: whether the password that leaves here is
// the opened one, and not the sealed word out of the file, is the whole claim of
// seal.go.
func loginURL(vc VCenter) (*url.URL, error) {
u, err := url.Parse(vc.sdkURL())
if err != nil {
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
}
secret, err := vc.password()
if err != nil {
return nil, err
}
u.User = url.UserPassword(vc.User, secret)
return u, nil
}
// close logs out and drops the context. Logging out is best effort: there is
// nothing useful to do about a failure while shutting down.
func (s *session) close() {
@@ -105,7 +130,8 @@ func (s *session) retrieve(kind string, props []string, dst any) error {
return nil
}
// vms and hosts are the two inventory sweeps gvm makes.
// vms, hosts and datastores are the inventory sweeps gvm makes: one call each,
// for everything of that kind in the whole inventory.
func (s *session) vms(props ...string) ([]mo.VirtualMachine, error) {
var out []mo.VirtualMachine
return out, s.retrieve("VirtualMachine", props, &out)
@@ -115,3 +141,57 @@ func (s *session) hosts(props ...string) ([]mo.HostSystem, error) {
var out []mo.HostSystem
return out, s.retrieve("HostSystem", props, &out)
}
func (s *session) datastores(props ...string) ([]mo.Datastore, error) {
var out []mo.Datastore
return out, s.retrieve("Datastore", props, &out)
}
// instanceUUID identifies this vCenter to itself: it is the serverGuid the
// vSphere client puts in the URLs of the objects it shows, which is the one
// thing gvm cannot work out from the configuration alone (see vsphereURL).
func (s *session) instanceUUID() string {
if s.client == nil {
return ""
}
return s.client.ServiceContent.About.InstanceUuid
}
// objects fills dst with the named properties of exactly the objects given,
// rather than of everything of a kind. Alarm definitions and tasks are not in
// the inventory container view — they hang off their managers — so the only way
// to read them is by reference, and by all of them in one call: one round trip
// for a screenful, not one per line.
func (s *session) objects(refs []types.ManagedObjectReference, props []string, dst any) error {
if len(refs) == 0 {
return nil
}
if err := property.DefaultCollector(s.client.Client).
Retrieve(s.ctx, refs, props, dst); err != nil {
return fmt.Errorf("%s: cannot read %d objects: %w", s.vc.Name, len(refs), err)
}
return nil
}
// tasks reads what those task references are doing. A task that has finished is
// still on a machine's recentTask list for a while afterwards, so the caller
// decides what counts as going on; this only reports.
func (s *session) tasks(refs []types.ManagedObjectReference) ([]mo.Task, error) {
var out []mo.Task
return out, s.objects(refs, []string{"info"}, &out)
}
// alarmNames resolves alarm definitions to the names a person gave them. A
// triggered alarm carries only the reference of its definition, and "alarm-3 is
// red" is not something anyone can act on.
func (s *session) alarmNames(refs []types.ManagedObjectReference) map[types.ManagedObjectReference]string {
var alarms []mo.Alarm
if err := s.objects(refs, []string{"info.name"}, &alarms); err != nil {
return nil // the names are a courtesy; the references still say which
}
out := make(map[types.ManagedObjectReference]string, len(alarms))
for _, a := range alarms {
out[a.Reference()] = a.Info.Name
}
return out
}
+1 -1
View File
@@ -1 +1 @@
0.1.21
1.2.0
+107 -86
View File
@@ -1,110 +1,131 @@
// 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 {
re, err := regexp.Compile("(?i)" + match)
// lsOptions is what one printed listing was asked for. A struct rather than six
// arguments in a row: three of them are booleans, and a call site reading
// (rows, "", false, true, false) says nothing about which is which.
type lsOptions struct {
match string // regexp on the machine's name
orderBy string // one of sortOrders, by letter or by name
reverse bool
issues bool // only the machines with something wrong with them
json bool // as a document instead of a table
}
// 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, opt lsOptions) error {
re, err := regexp.Compile("(?i)" + opt.match)
if err != nil {
return fmt.Errorf("bad pattern %q: %w", match, err)
return errf("bad pattern %q: %w", opt.match, err)
}
by, err := findOrder(opt.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)
}
wg.Wait()
var all []string
failed := 0
for i := range targets {
if errs[i] != nil {
PE(errs[i].Error())
failed++
continue
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
// In a table the failures are said before it, where they will not be
// scrolled past. In a document they belong *in* it: a line of prose in the
// middle of the JSON would break whatever is reading it, and a script that
// cannot tell "no machines" from "the server did not answer" is a script
// that reports an empty cluster.
if !opt.json {
for _, why := range found.failed {
PE(why)
}
all = append(all, rows[i]...)
}
if err != nil {
return err
}
sort.Strings(all)
for _, line := range all {
PN(line)
rows := make([]vmRow, 0, len(found.rows))
for _, r := range found.rows {
if re.MatchString(r.name) {
rows = append(rows, r)
}
}
if opt.issues {
rows = withIssues(rows)
}
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
// 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")
if opt.json {
return printJSON(found, rows)
}
// Nothing to report, and nobody watching: say nothing at all. This is the
// listing that belongs in cron, and cron mails whatever a command prints —
// so a daily "nothing wrong" would be a daily mail nobody reads, and the
// one morning it did not arrive would mean nothing either. On a terminal
// somebody is waiting for an answer, so there it is said.
if opt.issues && len(rows) == 0 {
if !color.NoColor {
PF("%s\n", Cgb(SF("nothing to report on any of the %d machines", len(found.rows))))
}
return nil
}
printList(rows, listColumns(rows, opt.issues))
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, table []browseColumn) {
cols := fitColumnsOf(table, printWidth(table))
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(table []browseColumn) int {
if color.NoColor {
return tableWidth(table)
}
cols, _ := termSize()
return max(cols, 20)
}