9 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
22 changed files with 4089 additions and 182 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/
tmp/
gvm
.gvmrc
.claude/
+273 -40
View File
@@ -97,6 +97,8 @@ not.
| `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 |
@@ -121,6 +123,8 @@ help, as does anything gvm does not recognise:
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
@@ -131,6 +135,8 @@ 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
@@ -173,6 +179,126 @@ 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
@@ -285,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.
@@ -301,57 +431,84 @@ 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 action menu
### The sheet's four letters
In a machine's sheet, `^a` opens the menu for it. Above the choices it 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, because two of the choices are about them.
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.
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)
────────────────────────────────────────────────────────────
e recent events h ssh to the guest
y copy the address w open in the vSphere client
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", "no address" — rather
than left out, and picking it anyway spells the reason out instead of running it.
The group under the rule changes nothing, on the machine or on the vCenter. It
is here rather than on four more control keys of its own because this is where
one already looks for "what can I do with this machine", and because the sheet's
help line is not the place to learn them:
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, 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 root %h` in `~/.gvmrc` says how; the target is
always one argument and never goes through a shell, because it is a name the
guest chose for itself.
* **`y`** copies that address to the clipboard — the terminal's own, asked for
with an escape sequence rather than through `pbcopy`, so it works over ssh and
lands where the person actually is.
* **`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. 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)
c change the vCPU count ...
m change the memory ...
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:
@@ -428,6 +585,55 @@ 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.
@@ -686,6 +892,33 @@ wrong quietly:
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
+162 -56
View File
@@ -59,15 +59,16 @@ var menuFacts = []string{"state", "guest", "hostname", "address"}
// 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 because two of the choices are *about* them: ssh
// and copy-the-address. The guest's operating system decides nothing here.
// 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 terminal of
// twenty-four rows gets out of this: the menu grew a group of its own, all four
// facts no longer fit above it there, and none is a worse answer than three.
// 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
@@ -127,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 {
@@ -146,12 +155,28 @@ 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() {
@@ -159,7 +184,7 @@ func (b *browser) closeMenu() {
}
// 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"
@@ -183,48 +208,29 @@ 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),
separator(),
pwr('S', "power off (hard, at the hypervisor)", opPowerOff),
pwr('B', "reset (hard, at the hypervisor)", opReset),
separator(),
// The last group changes nothing, on the machine or on the vCenter: it
// reads its history, copies its address, opens it somewhere else. It is
// here rather than on keys of its own because the sheet's help line is
// not the place to learn four more control keys, and because this is
// where one already looks for "what can I do with this machine".
{key: 'e', label: "recent events", run: func(b *browser, r vmRow) { b.showEvents(r) }},
guestItem('h', "ssh to the guest", r, func(b *browser, r vmRow) { b.sshTo(r) }),
guestItem('y', "copy the address to the clipboard", r,
func(b *browser, r vmRow) { b.copyAddress(r) }),
vsphereItem('w', "open in the vSphere client", r),
}
}
// guestItem is one of the entries that needs somewhere to connect to. A machine
// whose guest is not talking has no address, and saying so where the entry is
// beats a keystroke that does nothing.
func guestItem(key rune, label string, r vmRow, run func(*browser, vmRow)) menuItem {
if r.sshTarget() == "" {
return menuItem{key: key, label: label, hint: "no address",
why: SF("%s has no address or hostname — its guest is not reporting one", r.name),
run: run}
}
return menuItem{key: key, label: label, run: run}
}
// vsphereItem needs the same, plus a way to open a browser: on a machine with
// no desktop the URL is still worth having, so it is offered and printed rather
// than left out.
func vsphereItem(key rune, label string, r vmRow) menuItem {
if vsphereURL(r) == "" {
return menuItem{key: key, label: label, hint: "no connection",
why: "the vSphere client link needs the connection this machine was read over",
run: func(b *browser, r vmRow) { b.openVSphere(r) }}
}
return menuItem{key: key, label: label, run: func(b *browser, r vmRow) { b.openVSphere(r) }}
// 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
@@ -456,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.
@@ -637,7 +715,7 @@ func (b *browser) renderMenu() {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim,
"lowercase asks the guest, uppercase the hypervisor · the last group only reads"})
"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())
@@ -793,13 +871,17 @@ func wrap(s string, width int) []string {
return out
}
// ------------------------------------------------- the harmless half of the menu
// ------------------------------------------------------- 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.closeMenu()
b.working(SF("reading the events of %s ...", r.name))
lines, err := eventsOf(r)
@@ -818,41 +900,65 @@ func (b *browser) showEvents(r vmRow) {
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) {
b.closeMenu()
target := r.sshTarget()
argv := sshCommand(b.ssh, target)
if err := b.runInTerminal(argv); err != nil {
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))
return
}
b.setStatus(colDim, "back from "+target)
}
// copyAddress puts the address where the next paste will find it, by asking the
// terminal rather than the operating system — see osc52.
// 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) {
b.closeMenu()
target := r.sshTarget()
b.write(osc52(target))
b.setStatus(colInfo, "copied "+target)
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) {
b.closeMenu()
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.write(osc52(url))
b.toClipboard(url) // the same two routes as `y`
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
return
}
+50 -9
View File
@@ -137,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)
@@ -147,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))
@@ -159,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")
}
@@ -170,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))
@@ -395,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
}
@@ -606,15 +606,15 @@ func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
wantFacts bool
}{
{"30", true}, // room for both, all four facts
{"24", true}, // room for three of them, the hostname among them
{"23", false}, // room for two: the state and the address
{"18", false}, // no room at all, so the choices have the screen
{"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)
b.menu = b.buildMenu(r, nil, testSizing())
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
frame := renderToPipe(t, b, b.renderMenu)
@@ -761,3 +761,44 @@ func TestEveryTaskWaitIsBounded(t *testing.T) {
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}
}
+138 -19
View File
@@ -82,8 +82,8 @@ var (
)
const (
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll ^a actions (events, ssh, vsphere) ^s snapshot esc/⏎ back ^c quit"
listHelp = "type to filter ↑/↓ move ⏎ details ^e estate ^o sort ^w issues ^l live ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll e events h ssh y copy w vsphere ^a actions ^s snapshot esc back ^c quit"
gutter = 2 // the pointer's two columns, in front of every row
colSep = 2
labelWidth = 12
@@ -105,6 +105,12 @@ type vmRow struct {
// slow in proportion to how many machines are on the screen.
snaps []snapEntry // its snapshots, as the sweep found them
task *runningTask // what vCenter is doing to it right now, if anything
// trend is the machine's recent CPU load drawn as one column of text. It is
// not read from anywhere: it is written here by live.go out of samples the
// browser keeps across sweeps, because a row is thrown away and rebuilt by
// every one of them and a history cannot live on something that short-lived.
trend string
}
// id is what makes this machine this machine. A name does not: two vCenters may
@@ -321,6 +327,11 @@ type browseColumn struct {
// narrow for all of them: 1 goes first. Zero means never — the name and the
// power state are what a list of machines is for.
//
// Only the order matters, not the figures, and 2 is the trend column
// (live.go) rather than anything here: it 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 and the one a shape is worth more than.
//
// It is deliberately separate from the order the columns are shown in. The
// two used to be the same thing, so what a narrow terminal lost was simply
// whatever happened to be listed last, and the memory figures went before the
@@ -338,7 +349,7 @@ var browseColumns = []browseColumn{
// its place on a terminal of eighty.
{header: "NAME", width: 22, flex: true, color: fixed(colName),
cell: func(r vmRow) string { return r.name }},
{header: "VC", width: 4, color: fixed(colWhere), expendable: 10,
{header: "VC", width: 4, color: fixed(colWhere), expendable: 11,
cell: func(r vmRow) string { return r.vc.Name }},
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
// How many rollback points the machine is dragging along, aged by colour.
@@ -349,20 +360,20 @@ var browseColumns = []browseColumn{
// `vm -l --issues` and in `snap --old`, all of which name it and date it; an
// address has no other home. So a terminal of eighty gives up the count and
// keeps the address, and the count is back from eighty-five.
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 3},
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 4},
// The address is the widest thing here that is not a name, and it is a
// lookup field: on a terminal this narrow nobody is looking an address up,
// they are glancing at what is busy. So it goes before the small figures do.
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 4},
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 5},
{header: "HOST", width: 10, color: fixed(colAside), expendable: 3,
cell: func(r vmRow) string { return r.host }},
{header: "CPU", width: 3, color: fixed(colSize), expendable: 5,
{header: "CPU", width: 3, color: fixed(colSize), expendable: 6,
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
{header: "CPU%", width: 4, expendable: 9,
{header: "CPU%", width: 4, expendable: 10,
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 6},
{header: "MEM%", width: 4, expendable: 8,
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 7},
{header: "MEM%", width: 4, expendable: 9,
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
@@ -382,7 +393,7 @@ var browseColumns = []browseColumn{
// shifting about: it is the news. What must not happen — a column coming back
// as the terminal is dragged *narrower* — is fitColumns' business, and this
// does not touch it.
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 8,
cell: vmRow.taskCell, color: fixed(colBusy)}
// The reason outranks every other column but the machine's name and its power
@@ -392,7 +403,7 @@ var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
// still expendable rather than fixed, so that a terminal too narrow for it
// falls down the same ladder as everything else instead of into the one-column
// fallback.
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 11,
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 12,
cell: vmRow.issueCell, color: vmRow.issueColor}
// The figures the issues listing leaves out. A machine is in that list because
@@ -417,7 +428,9 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
}
}
out := make([]browseColumn, 0, len(browseColumns)+1)
trend := anyTrend(rows)
out := make([]browseColumn, 0, len(browseColumns)+2)
for _, c := range browseColumns {
if why {
if c.header == "GUEST OS" {
@@ -432,6 +445,12 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
if busy && c.header == "PWR" {
out = append(out, taskColumn)
}
// Next to the figure it is the history of, which is where the eye
// already is when it wonders whether 80 per cent is where the machine
// has been sitting all morning or where it went a moment ago.
if trend && c.header == "CPU%" {
out = append(out, trendColumn)
}
}
return out
}
@@ -638,14 +657,21 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
if err != nil {
return nil, nil, err
}
rows, err := sweepOne(vc, s)
return rows, s, err
}
// sweepOne is that pass over one server, over a connection that is already
// open. It is split out for live mode (live.go), which re-reads the same
// properties every few seconds and must not log in again to do it.
func sweepOne(vc VCenter, s *session) ([]vmRow, error) {
vms, err := s.vms(sweepProps...)
if err != nil {
return nil, s, err
return nil, err
}
hosts, err := s.hosts("name")
if err != nil {
return nil, s, err
return nil, err
}
hostName := make(map[types.ManagedObjectReference]string, len(hosts))
for _, h := range hosts {
@@ -673,7 +699,7 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
}
rows = append(rows, r)
}
return rows, s, nil
return rows, nil
}
// triggeredAlarms is every alarm definition this sweep saw complaining, once
@@ -729,6 +755,15 @@ type browser struct {
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
// Live mode (live.go): the list re-reading itself on a timer rather than on
// a keystroke. hist is what the trend column is drawn from, kept here
// because every sweep throws the rows away and builds new ones.
live bool
liveNext time.Time
liveGap time.Duration // what the pending tick was scheduled with, for the title
hist map[string][]float64
saidLive string // the last changed line, so a quiet tick clears only its own
detail []sheetLine // non-nil while a machine's sheet is on screen
dtitle string
dvc string // the vCenter, in the title, in the colour its column has
@@ -742,10 +777,12 @@ type browser struct {
// underneath without having to remember where it came from.
menu []menuItem
menuSnaps []snapEntry
menuSize sizing // what the machine has, for the two hardware entries
menuInfo []sheetLine // the few lines of the sheet shown above the choices
menuSel int
pick *picker
confirm *confirmation
estate *estate // the whole estate on one screen (estate.go)
status string
statusCol string
@@ -759,7 +796,7 @@ type browser struct {
// browseVMs is the command: gather, then hand the terminal over to the loop.
//
// ssh is the command line the action menu's `h` runs, out of the configuration:
// ssh is the command line the sheet's `h` runs, out of the configuration:
// the browser is handed it rather than reading it, so nothing in the interactive
// half has to know where settings come from.
func browseVMs(targets []VCenter, filter, ssh string) error {
@@ -840,7 +877,19 @@ func (b *browser) loop() {
for {
b.render()
k := b.keys.next()
// A key, or the next refresh, whichever comes first. Only where a tick
// is allowed to happen at all (liveReady): with a menu or a half-typed
// answer on screen the read blocks the way it always has.
k := key{}
if b.liveReady() {
var ok bool
if k, ok = b.keys.nextWithin(b.liveIn()); !ok {
b.liveTick()
continue
}
} else {
k = b.keys.next()
}
if k.special == keyCtrlC {
return
}
@@ -852,6 +901,8 @@ func (b *browser) loop() {
b.pickerKey(k)
case b.menu != nil:
b.menuKey(k)
case b.estate != nil:
b.estateKey(k)
case b.detail != nil:
b.detailKey(k)
default:
@@ -873,6 +924,8 @@ func (b *browser) render() {
b.renderPicker()
case b.menu != nil:
b.renderMenu()
case b.estate != nil:
b.renderEstate()
case b.detail != nil:
b.renderDetail()
default:
@@ -922,6 +975,10 @@ func (b *browser) listKey(k key) bool {
b.setStatus(colDim, "open the machine first: ⏎ for its details, then ^a")
case keyCtrlR:
b.reload()
case keyCtrlL:
b.toggleLive()
case keyCtrlE:
b.openEstate()
case keyBackspace:
if r := []rune(b.filter); len(r) > 0 {
b.filter = string(r[:len(r)-1])
@@ -957,6 +1014,8 @@ func (b *browser) detailKey(k key) {
b.openMenu()
case keyCtrlS:
b.snapshot()
case keyRune:
b.detailRune(k.r)
case keyUp:
b.dscroll = max(b.dscroll-1, 0)
case keyDown:
@@ -972,6 +1031,32 @@ func (b *browser) detailKey(k key) {
}
}
// detailRune answers the sheet's own letters: the four things that only read the
// machine or point somewhere else at it. They are letters here rather than
// entries at the foot of the action menu because nothing they do needs thinking
// about first, and the sheet — unlike the list — has no filter to swallow them.
// A letter that means nothing here is ignored, not complained about.
func (b *browser) detailRune(r rune) {
row := b.current()
if row == nil {
return
}
switch r {
case 'e':
b.showEvents(*row)
case 'h':
if b.hasAddress(*row) {
b.sshTo(*row)
}
case 'y':
if b.hasAddress(*row) {
b.copyAddress(*row)
}
case 'w':
b.openVSphere(*row)
}
}
// refilter rebuilds the visible set. The selection stays on the machine it was
// on where that machine is still in the list, which is what makes typing a few
// letters and pressing enter feel like one motion.
@@ -1032,11 +1117,25 @@ func (b *browser) toggleIssues() {
// the list cannot make a column come and go under the cursor.
func (b *browser) columns() []browseColumn { return listColumns(b.rows, b.issuesOnly) }
// current is the machine under the cursor, or nil where there is none.
//
// Both steps are bounds-checked, and the second is not paranoia: the view holds
// indexes into the rows, and there is a moment in every refresh — the rows
// replaced, the view not yet rebuilt — when an index of the old list points
// past the end of the new one. refilter itself asks this question in that
// moment, to remember which machine the cursor was on. Checking only the view's
// own length left a panic there that needed nothing but a machine disappearing
// from the inventory while the cursor was near the end of the list: rare with
// ^r, and every ten seconds with live mode.
func (b *browser) current() *vmRow {
if b.sel < 0 || b.sel >= len(b.view) {
return nil
}
return &b.rows[b.view[b.sel]]
i := b.view[b.sel]
if i < 0 || i >= len(b.rows) {
return nil
}
return &b.rows[i]
}
func (b *browser) move(n int) {
@@ -1062,6 +1161,7 @@ func (b *browser) reload() {
b.rows, b.sessions = found.rows, found.sessions
b.answered, b.lost = found.answered, found.lost
b.applySort() // which refilters; the order the table was in survives a reload
b.sample() // a reload is a sweep, and the trend column is drawn from sweeps
closeSessions(old)
if len(found.failed) > 0 {
@@ -1303,6 +1403,18 @@ func (b *browser) renderList() {
title = append(title,
seg{colDim, " "},
seg{colMatch, b.sortLabel()}) // pink: the one thing here that was chosen
// A screen that changes on its own has to say so, and how often: a list
// that moves under somebody's hands with nothing to explain it reads as a
// fault. The busy colour, because that is what the task column wears.
if b.live {
// The gap the pending tick was scheduled with, not one worked out again
// here: a clone that started in the meantime would make the title
// promise every two seconds while the refresh that is actually pending
// is nine seconds away.
title = append(title,
seg{colDim, " "},
seg{colBusy, SF("live %s", b.liveGap)})
}
segLine(&sb, cols, title...)
if b.filter == "" {
@@ -1568,6 +1680,13 @@ func sheetFor(sheet []sheetLine, cols int) []sheetLine {
func (b *browser) openDetail() {
r := b.current()
if r == nil {
// Nothing to show it of. A sheet that is already open is then a page of
// facts about a machine that has gone — deleted, or on a vCenter that
// stopped answering — and leaving it on the screen would be the one
// thing worse than closing it: an entire screen of stale truth with
// nothing to say it is stale. It is reached by a live refresh, which
// rebuilds an open sheet without anybody pressing anything.
b.detail, b.dscroll = nil, 0
return
}
b.dtitle = r.name
+145 -3
View File
@@ -251,6 +251,135 @@ func TestListViewHasNoActionKeys(t *testing.T) {
}
}
// The four that only read a machine are the sheet's own letters now, not entries
// at the foot of the action menu. Each one has to reach its own action from
// there, and a letter that means nothing must be dropped rather than answered.
func TestSheetLettersReachTheirActions(t *testing.T) {
// Said to be an ssh login, which keeps the test off the clipboard of whoever
// is running it: with no local tool to hand, `y` uses the terminal's own
// escape sequence and nothing else.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
// That sequence goes to the terminal, so the sheet needs one to write to.
pipe := func(b *browser) func() string {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = w
return func() string {
w.Close()
out, _ := io.ReadAll(r)
r.Close()
return string(out)
}
}
// y copies. It is the one of the four that needs neither a session nor a
// browser, so it is checked all the way through.
b := testBrowser("web01")
read := pipe(b)
b.detailRune('y')
if got := read(); !strings.Contains(got, "\x1b]52;c;") {
t.Errorf("y sent no clipboard sequence: %q", got)
}
// What the line says about which clipboard is TestCopyAddressSaysWhatWentWhere's
// business; here it only has to name the thing that was copied.
for _, want := range []string{"clipboard", "web01.example"} {
if !strings.Contains(b.status, want) {
t.Errorf("y said %q, which does not mention %q", b.status, want)
}
}
// e and w need the connection the machine was read over, and these rows have
// none: what matters is that the letter arrived at the action, which says so.
for _, c := range []struct {
k rune
want string
}{{'e', "v308"}, {'w', "v308"}} {
b := testBrowser("web01")
read := pipe(b)
b.detailRune(c.k)
read()
if !strings.Contains(b.status, c.want) {
t.Errorf("%q on a machine with no connection said %q", string(c.k), b.status)
}
}
// A letter nothing is bound to is ignored — silently, because the sheet is
// not a filter and there is nothing to correct.
b = testBrowser("web01")
b.setStatus("", "")
b.detailRune('q')
if b.status != "" {
t.Errorf("an unbound letter said %q", b.status)
}
}
// The view holds indexes into the rows, and every refresh has a moment where
// the rows have been replaced and the view has not been rebuilt yet. refilter
// asks which machine the cursor was on in exactly that moment, so a machine
// disappearing from the inventory while the cursor sat near the end of the list
// used to take gvm down with an index out of range — rare with ^r, and every
// ten seconds once live mode ticks on its own.
func TestASweepThatCameBackShorterDoesNotPanic(t *testing.T) {
b := testBrowser("web01", "db01", "app07", "mail02")
b.applySort()
b.sel = len(b.view) - 1 // where somebody scrolling to the bottom leaves it
// What a reload and a live tick both do: the rows in place, then sort and
// refilter against a view that still describes the longer list.
b.rows = b.rows[:1]
b.applySort()
if len(b.view) != 1 || b.sel != 0 {
t.Errorf("the shorter list left view %v and the cursor at %d", b.view, b.sel)
}
if cur := b.current(); cur == nil {
t.Error("there is a machine left, and the cursor is on none of them")
}
// And with nothing left at all, the cursor is on nothing rather than on
// memory that is no longer there.
b.rows = nil
b.applySort()
if cur := b.current(); cur != nil {
t.Errorf("an empty list has a machine under the cursor: %q", cur.name)
}
// A stale view against empty rows is the same question asked the other way
// round, and it is the one refilter walks into.
b.view = []int{0, 1, 2}
if cur := b.current(); cur != nil {
t.Errorf("a view pointing past the rows gave back %q", cur.name)
}
}
// A sheet is not left on screen for a machine that has gone. It is reached by a
// live refresh, which rebuilds an open sheet with nobody pressing anything: a
// whole screen of facts about something that no longer exists, with nothing on
// it to say so, is worse than no screen.
func TestTheSheetClosesWhenItsMachineGoes(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
b.openDetail()
if b.detail == nil {
t.Fatal("no sheet to begin with")
}
was := b.dtitle
b.rows = nil
b.refilter()
b.openDetail() // what a live tick does
if b.detail != nil {
t.Errorf("the sheet of %q is still on screen with %d lines", was, len(b.detail))
}
if b.dscroll != 0 {
t.Errorf("the closed sheet kept a scroll position of %d", b.dscroll)
}
}
func TestDetailSheetHasTheParameters(t *testing.T) {
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
@@ -1390,16 +1519,29 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
}
}
// The four that only read the machine are the sheet's own letters, so the
// sheet is where they have to be advertised.
for _, want := range []string{"e events", "h ssh", "y copy", "w vsphere", "^a actions"} {
if !strings.Contains(sheet, want) {
t.Errorf("the sheet's help does not offer %q:\n%s", want, sheet)
}
}
b.menu = b.buildMenu(*b.current(), b.current().snaps)
b.menu = b.buildMenu(*b.current(), b.current().snaps, testSizing())
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
for _, want := range []string{"take a snapshot", "power off", "recent events",
"ssh to the guest", "copy the address", "vSphere client"} {
for _, want := range []string{"take a snapshot", "power off", "reset"} {
if !strings.Contains(menu, want) {
t.Errorf("the menu does not show %q:\n%s", want, menu)
}
}
// And the menu is now only the things that change a machine.
for _, gone := range []string{"recent events", "ssh to the guest",
"copy the address", "vSphere client"} {
if strings.Contains(menu, gone) {
t.Errorf("the menu still holds %q:\n%s", gone, menu)
}
}
}
// Every line at the foot of the screen that wants an answer wears one colour.
+1 -1
View File
@@ -244,7 +244,7 @@ func inventoryAge() string {
var (
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
"--off", "--reset"}
"--off", "--reset", "--vm"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
+4 -4
View File
@@ -30,7 +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 action menu's `h` runs; %h is the machine
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
@@ -587,10 +587,10 @@ func writeConfigTemplate(path string) {
b.WriteString("# smtpport = 25\n\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 action menu's 'h' logs in to a guest ---\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 plain 'ssh <machine>'.\n")
b.WriteString("# ssh = ssh -l root %h\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())
+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)
}
}
}
+129 -12
View File
@@ -4,33 +4,51 @@
// 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
// sit in the harmless group at the foot of the action menu.
// 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 {
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)
return strings.TrimSpace(g.HostName), "hostname"
}
if ip := r.ip(); ip != "-" {
return ip
return ip, "address"
}
return ""
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
@@ -39,7 +57,7 @@ func (r vmRow) sshTarget() string {
// quoting is not supported, which is a limit worth having here.
func sshCommand(template, target string) []string {
if strings.TrimSpace(template) == "" {
template = "ssh %h"
template = defaultSSH
}
fields := strings.Fields(template)
@@ -116,16 +134,74 @@ func openURL(url string) error {
}
// osc52 is the escape sequence that puts text in the clipboard of the terminal
// that is being looked at, wherever that terminal is running.
// 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.
//
// Deliberately not pbcopy or xclip: gvm is run over ssh as often as not, and a
// local pbcopy would then copy an address into the clipboard of a machine
// nobody is sitting at. This asks the terminal itself, which is the one program
// in the chain that knows where the person is.
// 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.
@@ -139,11 +215,13 @@ func (b *browser) runInTerminal(argv []string) error {
}
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 {
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)
@@ -161,3 +239,42 @@ func (b *browser) runInTerminal(argv []string) error {
}
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)
}
+160 -32
View File
@@ -2,9 +2,15 @@ package main
import (
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
@@ -33,8 +39,8 @@ func TestSSHCommand(t *testing.T) {
template string
want []string
}{
{"", []string{"ssh", "web01"}},
{"ssh %h", []string{"ssh", "web01"}},
{"", []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"}},
@@ -46,6 +52,142 @@ func TestSSHCommand(t *testing.T) {
}
}
// 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
@@ -110,51 +252,37 @@ func TestOsc52CarriesTheTextItself(t *testing.T) {
}
}
// The menu says why an entry cannot be used rather than leaving it out, and the
// two entries that need somewhere to connect to say exactly that.
func TestTheMenuGreysOutWhatItCannotDo(t *testing.T) {
// 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}}
menu := b.buildMenu(r, nil)
for _, key := range []rune{'h', 'y'} {
found := false
for _, m := range menu {
if m.key != key {
continue
}
found = true
if m.available() {
t.Errorf("%q is offered on a machine with no address", string(key))
}
if m.hint == "" || m.why == "" {
t.Errorf("%q is greyed out without saying why", string(key))
}
}
if !found {
t.Errorf("the menu has no %q entry", string(key))
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 they are there to be used.
r = testRow("web01", true, "10.0.0.5")
for _, m := range b.buildMenu(r, nil) {
if (m.key == 'h' || m.key == 'y') && !m.available() {
t.Errorf("%q is greyed out on a machine with an address: %s", string(m.key), m.why)
}
// 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 harmless group must not have taken a letter the
// dangerous half already uses.
// 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) {
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.isSeparator() {
continue
}
+26 -1
View File
@@ -47,7 +47,7 @@ var helpTail = strings.Join([]string{
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.1.0"
var version = "1.2.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -131,6 +131,20 @@ 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"
@@ -158,6 +172,7 @@ func run() error {
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)
@@ -265,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 {
+5 -3
View File
@@ -69,14 +69,16 @@ smtpport = 25
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# --- ssh, for the action menu's 'h' ---
# --- 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 plain "ssh <machine>".
# 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 root %h
# ssh = ssh -l someone %h
# --- the same settings from the environment ---
# Every setting above has an environment spelling that wins over the file:
+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)
}
}
+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)
}
}
+355 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"os"
"strings"
@@ -1170,7 +1171,7 @@ func TestSimIssuesListing(t *testing.T) {
}
}
// The machine's own events, which is what the action menu's 'e' fetches.
// The machine's own events, which is what 'e' on a machine's sheet fetches.
func TestSimEventsOfOneMachine(t *testing.T) {
quiet(t)
vc := simVCenter(t)
@@ -1218,6 +1219,359 @@ func TestSimEventsWithoutAConnection(t *testing.T) {
}
}
// The estate screen against a server that answers: the hosts come back grouped
// under their clusters, what they carry is added up from the rows the list
// already holds, and Enter narrows the list to the host under the cursor.
func TestSimEstateScreen(t *testing.T) {
quiet(t)
t.Setenv("COLUMNS", "150")
t.Setenv("LINES", "20")
vc := simVCenter(t)
found, err := gatherVMs([]VCenter{vc})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
b := &browser{targets: []VCenter{vc}, rows: found.rows, sessions: found.sessions,
answered: found.answered}
b.applySort()
b.openEstate()
if b.estate == nil {
t.Fatalf("the estate screen did not open: %q", b.status)
}
e := b.estate
// Every host of the simulated inventory, under a heading each, and the
// cursor on a host rather than on a heading.
var hosts, headings int
for _, r := range e.rows {
if r.isHeading() {
headings++
continue
}
hosts++
}
if hosts == 0 || headings == 0 {
t.Fatalf("%d hosts under %d headings", hosts, headings)
}
if e.rows[e.sel].isHeading() {
t.Error("the cursor started on a heading")
}
// The machines are charged to the hosts they are on, and the total matches
// what the list holds — nothing counted twice, nothing dropped.
charged := 0
for _, r := range e.rows {
if !r.isHeading() {
charged += r.vms
}
}
placed := 0
for _, r := range b.rows {
if r.vm.Summary.Runtime.Host != nil {
placed++
}
}
if charged != placed {
t.Errorf("%d machines charged to hosts, %d placed on one", charged, placed)
}
// A heading is the sum of the hosts under it.
for i, r := range e.rows {
if !r.isHeading() {
continue
}
sum := 0
for _, h := range e.rows[i+1:] {
if h.isHeading() {
break
}
sum += h.vms
}
if r.vms != sum {
t.Errorf("%q says %d machines, its hosts hold %d", r.heading, r.vms, sum)
}
}
// It draws, with the figures on it.
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
for _, want := range []string{"Estate", "CLUSTER / HOST", "MEM ALLOC", "DC0_C0"} {
if !strings.Contains(frame, want) {
t.Errorf("the screen does not show %q:\n%s", want, frame)
}
}
// And Enter is the whole point of arrowing to a host: the machine list,
// narrowed to it.
//
// Arrowed to one that carries something, deliberately: the simulator does
// not spread its machines evenly and leaves some hosts empty, so starting
// from wherever the cursor happens to open would be a test that passes or
// fails by placement rather than by behaviour. (It did: one run in three.)
for i, r := range e.rows {
if !r.isHeading() && r.vms > 0 {
e.sel = i
break
}
}
host := e.rows[e.sel].host
if e.rows[e.sel].vms == 0 {
t.Fatal("no host in the simulated inventory carries a machine")
}
b.showHost()
if b.estate != nil {
t.Error("the estate screen stayed open after Enter")
}
if b.filter != host {
t.Errorf("the filter is %q, want the host %q", b.filter, host)
}
if len(b.view) == 0 {
t.Errorf("filtering to %s left no machines, though it carries some", host)
}
for _, i := range b.view {
if b.rows[i].host != host {
t.Errorf("%s is on %s, not on %s", b.rows[i].name, b.rows[i].host, host)
}
}
}
// Live mode against a server that answers: the refresh goes over the connection
// that is already open, it notices what happened behind gvm's back, and it says
// what changed.
func TestSimLiveRefreshUsesTheOpenSession(t *testing.T) {
quiet(t)
vc := simVCenter(t)
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
b := &browser{targets: []VCenter{vc}, rows: rows, sessions: sessions,
answered: found.answered}
b.applySort()
b.sample()
// The refresh reads the same machines back, and does not touch the
// sessions: a reload replaces them, this must not.
before := len(b.rows)
was := b.rows
if err := b.resweep(); err != nil {
t.Fatalf("resweep: %v", err)
}
if len(b.rows) != before {
t.Errorf("the refresh came back with %d of %d machines", len(b.rows), before)
}
if len(b.sessions) != len(sessions) || b.sessions[0] != sessions[0] {
t.Error("the refresh replaced the session it was supposed to reuse")
}
if got := changesBetween(was, b.rows); got != "" {
t.Errorf("a refresh with nothing happening in between said %q", got)
}
// Something happens that gvm did not do. A refresh has to notice, and say
// so — this is the whole reason to leave the list open.
var victim vmRow
for _, r := range b.rows {
if r.running() {
victim = r
break
}
}
if victim.name == "" {
t.Fatal("no running machine to stop")
}
if _, err := runPower(victim.sess, victim, opPowerOff); err != nil {
t.Fatalf("cannot stop %s: %v", victim.name, err)
}
was = b.rows
if err := b.resweep(); err != nil {
t.Fatalf("resweep after the change: %v", err)
}
said := changesBetween(was, b.rows)
if !strings.Contains(said, victim.name+" off") {
t.Errorf("the refresh did not report the machine stopping: %q", said)
}
for _, r := range b.rows {
if r.name == victim.name && r.running() {
t.Error("the refreshed row still says the machine is running")
}
}
// One server of two failing moves the title as well as the status line: the
// rows of the one that did not answer are kept, and it is named as lost
// rather than left in the list of servers holding machines. A title reading
// "on v308, v309" above a status line saying v309 did not answer is two
// lines contradicting each other, and the next keystroke clears the true
// one.
dead, stop := context.WithCancel(context.Background())
stop()
b.sessions = append(b.sessions, &session{vc: VCenter{Name: "v309"}, ctx: dead,
client: sessions[0].client, cancel: stop})
b.answered, b.lost = []string{vc.Name, "v309"}, nil
if err := b.resweep(); err == nil {
t.Error("a refresh over a dead session reported no trouble")
}
if contains(b.answered, "v309") {
t.Errorf("v309 did not answer and is still named as holding machines: %v", b.answered)
}
if !contains(b.lost, "v309") {
t.Errorf("v309 is not named as lost: %v", b.lost)
}
if !contains(b.answered, vc.Name) {
t.Errorf("%s answered and is not named: %v", vc.Name, b.answered)
}
b.sessions = b.sessions[:len(b.sessions)-1]
// And it really is the open session it reads over: with that closed, the
// refresh fails instead of quietly logging in again. Three logins a minute
// is what live mode exists not to do.
closeSessions(sessions)
if err := b.resweep(); err == nil {
t.Error("the refresh worked with every session closed, so it logged in again")
}
// The rows of a server that stopped answering are kept rather than dropped:
// an empty list because a vCenter is restarting would be worse than one
// that is a minute old.
if len(b.rows) != before {
t.Errorf("a failed refresh left %d of %d machines on screen", len(b.rows), before)
}
}
// Changing what a machine has, against a server that answers: the refusal on a
// running machine, and the change itself once it is off. The numbers are read
// back from the vCenter afterwards rather than assumed — a reconfigure that is
// accepted and does nothing would otherwise look exactly like one that worked.
func TestSimResizeAMachine(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
sz, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
if !sz.known {
t.Fatal("the machine's configuration came back empty")
}
// The simulator's machines run, and are built without hot-plug — which is
// the case the objection exists for.
if !r.running() {
t.Fatal("the simulated machine is not running, so there is nothing to refuse")
}
if err := checkSize(r, sz, sizeCPUs, sz.cpus+2); err == nil {
t.Error("more vCPUs were allowed on a running machine with no hot-add")
}
if err := checkSize(r, sz, sizeMemory, sz.memoryMB*2); err == nil {
t.Error("more memory was allowed on a running machine with no hot-add")
}
// Off it goes, and with it the objection.
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot power the machine off: %v", err)
}
if err := refreshOne(s, &r); err != nil {
t.Fatalf("cannot re-read the machine: %v", err)
}
wantCPUs, wantMemory := sz.cpus+2, sz.memoryMB+1024
for _, c := range []struct {
kind sizeKind
want int32
}{{sizeCPUs, wantCPUs}, {sizeMemory, wantMemory}} {
// Read again between the two, so the second change is checked and
// described against what the first one left behind rather than against
// what the machine looked like before either.
sz, err = sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
t.Fatalf("runResize(%v): %v", c.kind.what(), err)
}
if !strings.Contains(msg, "→") {
t.Errorf("the message does not say what changed: %q", msg)
}
}
after, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf after the change: %v", err)
}
if after.cpus != wantCPUs {
t.Errorf("the machine has %d vCPUs, want %d", after.cpus, wantCPUs)
}
if after.memoryMB != wantMemory {
t.Errorf("the machine has %d MB, want %d", after.memoryMB, wantMemory)
}
// And the change that would take memory away from it while it runs is still
// refused after it has been given some.
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOn
if err := checkSize(r, after, sizeMemory, after.memoryMB-1024); err == nil {
t.Error("memory was taken away from a running machine")
}
}
// A command line that asks for two changes does neither until both are known to
// be possible: setting the vCPUs and then refusing the memory would leave half
// of what somebody asked for, which is the outcome nobody wanted.
func TestSimResizeRefusesBothOrNeither(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot power the machine off: %v", err)
}
before, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
// The vCPU count is good and the memory is not a multiple of 4 MB. -y so
// that a confirmation cannot be what stops it: the refusal has to come from
// the check, before anything is sent.
err = sizeCLI(vc, "DC0_C0_RP0_VM1", Itoa(int(before.cpus+2)), "1026m", true)
if err == nil {
t.Fatal("a half-impossible command line was accepted")
}
if !strings.Contains(err.Error(), "multiples of 4 MB") {
t.Errorf("refused for the wrong reason: %v", err)
}
after, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf after the refusal: %v", err)
}
if after.cpus != before.cpus {
t.Errorf("the vCPUs were changed anyway: %d, was %d", after.cpus, before.cpus)
}
if after.memoryMB != before.memoryMB {
t.Errorf("the memory was changed anyway: %d, was %d", after.memoryMB, before.memoryMB)
}
}
// refreshOne re-reads the properties the sweep reads, for a row a test has just
// changed something about.
func refreshOne(s *session, r *vmRow) error {
var fresh mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if err := vm.Properties(s.ctx, r.ref, sweepProps, &fresh); err != nil {
return err
}
r.vm = fresh
return nil
}
// The instance UUID is what the vSphere client's links are made of, and the one
// thing gvm cannot work out from the configuration.
func TestSimVsphereLinkFromASession(t *testing.T) {
+33
View File
@@ -83,6 +83,8 @@ const (
keyShiftTab
keyEnter
keyCtrlA
keyCtrlE
keyCtrlL
keyCtrlO
keyCtrlR
keyCtrlS
@@ -133,11 +135,42 @@ 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:
+1 -1
View File
@@ -1 +1 @@
1.1.3
1.2.0