16 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 5d90d6ef8a [mike@mwxm4]
Version 1.3.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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:53:47 +02:00
Michael WesemannandClaude Opus 5 2babb72e62 [mike@mwxm4]
The second road: gvm writes the customisation itself, out of ~/.gvmrc.

Five settings, which are the site's answers and not any machine's — domain,
dns, netmask, gateway, timezone — written once in the configuration file where
every other site answer already lives. The two that are about the one machine
stay on the command line:

    gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55

--spec stays exactly as it was, and both roads are now offered side by side:
the picker lists gvm's own alongside whatever specifications the vCenter holds,
and the deployment takes whichever was chosen. A site with specifications
should still prefer them — the policy is then somewhere the web client can see
it too — and Windows has no other option, since a Sysprep is a licence key, an
administrator password and a domain to join, none of which belongs in a file
next to the SMTP relay. A Windows template is refused by name, pointing at
--spec.

What it writes: LinuxPrep with the hostname (the machine's name unless
--hostname says otherwise), the domain and the timezone; the resolvers and the
search domain in the global settings; and one adapter with the address, the
netmask and the gateway. Without --ip the adapter is left on DHCP, which is a
whole answer — the name is still set, and that is what was asked for.

Refused rather than guessed: a configuration that is not complete, named field
by field; an address, netmask or gateway that is not one, each said to be the
configuration's; and a Windows template. Said rather than refused: a gateway
that is not on the machine's own network. A routed setup can put one anywhere,
so it is not an error — but almost every time it is a typo, and a machine that
cannot reach its gateway is one somebody drives to the console for.

`gvm config` grew a line for it, which says either what a new guest would be
told or which settings are still missing. browseVMs takes the configuration
rather than one string out of it, since it now needs two things from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:43:47 +02:00
Michael WesemannandClaude Opus 5 badd357ca5 [mike@mwxm4]
The customisation picker skipped itself in silence, which looks exactly like a
step that is broken.

Two ways to get there, and both were quiet: a vCenter that holds no
customisation specifications — where not asking is right, since the answer
could only be "none" — and one that will not let them be read, where the
warning that was set went straight into the line the next question draws over
and was never seen.

Both now say so on the confirmation, and the reason goes *first* on that line:
it shares one line with the placement, a terminal cuts from the right, and the
placement is the same every time while "there was no question about
customisation, and here is why" is what somebody is looking for.

    make web05 from ubuntu-tpl on v308?   y = yes, anything else = no
    v308 has no customisation specifications · in prod-cluster, powered off

`gvm new --specs` lists what a server holds, which is the question that follows
the one above, and prints the server's own words where it refuses.

The mechanism itself was right: driven end to end against the simulator — a
template, a typed name, and the picker on the screen with its four
specifications. What was missing was everything gvm says when there are none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:24:45 +02:00
Michael WesemannandClaude Opus 5 ab76d56858 [mike@mwxm4]
Hostname and address on a deployed machine, out of the vCenter's own
customisation specifications.

--spec names one the vCenter already holds — the ones made in the vSphere
client under Policies and Profiles. Everything about the site lives there: the
domain, the DNS servers, the netmask, the gateway, the timezone, Linux or
Windows. gvm writes exactly two things into it, the two that are about this one
machine: the hostname, which defaults to the machine's own name, and the
address. A tool that knew better than the vCenter about any of the rest is how
one ends up with a network policy nobody remembers agreeing to.

    gvm new --from ubuntu-tpl --name web05 --spec linux-static --ip 10.0.0.55

In the list it is a step of its own: after the name, a picker of the
specifications the server holds with "leave the guest as the template made it"
at the top, then one line for the address — empty leaves what the specification
says.

Refused rather than guessed at:

* An address where the specification's adapter has no netmask, because it takes
  one from DHCP. An address without a mask half works, and where to get a mask
  is a question for the vCenter.
* A hand-written Windows answer file, and a cloud-init specification: both
  carry the hostname in a format gvm does not own.
* Silence about VMware Tools. A template that reports none cannot have a
  customisation carried out inside it, and that is said before the clone rather
  than found twenty minutes later on a machine that came up under the
  template's name. It is a guess — the version is what the machine last
  reported — and it is said as one; the machine is made either way.

The confirmation reads the two facts back off the specification that was built
rather than repeating what was asked for, so what it shows is what will happen.

The snapshot picker is now a picker: a title, lines, and what to do with the
one that is chosen. The second list — these specifications — would otherwise
have been a near-copy of the screen and its keys, and two of those drift.

Tested against the simulator, which holds four real specifications covering
every shape that matters: LinuxPrep with a netmask, LinuxPrep on DHCP without
one, and two Sysprep ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:15:22 +02:00
Michael WesemannandClaude Opus 5 4ed9d53602 [mike@mwxm4]
Making a machine from a template: p in a template's action menu, `gvm new` on
the command line.

deploy.go is the only thing in gvm that brings a machine into being rather than
acting on one that exists, and that makes its hard question where rather than
whether. A template has no resource pool — vSphere takes it away when a machine
is marked as one — so a copy of it has nowhere to run until something says
where. That is the one thing it cannot inherit; the folder, the datastore and
the hardware it can.

So the placement is worked out before anything is asked and the confirmation
says it in full: the pool is the one the template's own host belongs to, which
on a cluster is the cluster's and leaves the host to DRS the way every other
deployment there does. --host pins it, --datastore moves it.

The interactive half does not wait. A clone is minutes to the half hour, and a
list frozen for that long is a list nobody would start one from. vCenter hangs
the task off the template, so the row it was started from shows the progress in
its TASK column — which is what live mode was for — and the new machine turns
up in the list when it exists, announced on the changed line. `gvm new` does
wait: a script that gets its prompt back wants the machine to be there.

A template's menu is its own: the one thing that can be done with it at the
top, and everything else greyed with "a template" beside it, because vSphere
will not start one, snapshot one or reconfigure one. Greyed rather than left
out — a menu that changes shape between rows is one nobody learns.

Refused before anything is sent: a source that is not a template, a name
vSphere would not take, and a name the server already has (which vCenter itself
would only refuse several seconds into the clone).

No guest customisation — no hostname, no address, no domain join. That is a
second machine's worth of vSphere, it is site policy rather than a tool's
business, and a half-done version of it would be worse than none.

confirmDestructive gains a sibling without the warning, and both now print
their fact block from one place.

Tested against the simulator end to end: an ordinary machine refused, the same
machine marked as a template and deployed from, and what comes out read back
off the server — a machine and not another template, in the pool it was given,
switched off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:44:37 +02:00
Michael WesemannandClaude Opus 5 269bce195d [mike@mwxm4]
Version 1.2.0.

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

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

The crashes:

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

The rest:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 08:51:18 +02:00
Michael Wesemann 4a5477bde7 [mike@mwxm4] 2026-09-08 17:15:05 +02:00
Michael Wesemann 137a799399 [mike@mwxm4] 2026-09-08 17:08:47 +02:00
27 changed files with 6347 additions and 296 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/
tmp/
gvm
.gvmrc
.claude/
+442 -49
View File
@@ -44,6 +44,13 @@ setting has an environment spelling that wins over the file — `GVM_VCENTER_V30
`GVM_MAILTO`, `GVM_DEFAULT` and so on — which is how to run gvm from cron
without the password living in a file.
Besides the servers it holds the site's own answers, each written once: where
mail goes, where telemetry is posted, how `h` logs in to a guest, and what a
machine made from a template is told about the network (`domain`, `dns`,
`netmask`, `gateway`, `timezone` — see *Making a machine from a template*).
`gvm config` prints the lot, passwords as set or not set, and names what is
missing for anything that is half configured.
The file holds passwords, so gvm creates it mode 0600 and complains when it
finds it readable by others.
@@ -97,6 +104,13 @@ 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 |
| `new --from <template> --name <vm>` | make a new machine from a template |
| `new ... --host <h> --datastore <ds> --on` | where it lands, and whether it starts |
| `new ... --spec <spec> --ip <addr>` | customise the guest: hostname and address |
| `new --specs` | the customisation specifications this vCenter holds |
| `config` | also shows what a new guest would be told about the network |
| `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 +135,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 +147,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,13 +191,132 @@ column's minimum is one notch narrower than it reads in order to buy the address
its place there, and the count is the one column here that has somewhere else to
be said — `^w`, `--issues` and `snap --old` all name it and date it.
### The estate
`^e` answers the question the machine list cannot: **where is there still room.**
Every host of every server that answered, grouped under its cluster, with what
it carries set against what it has:
Estate v308, v309 14 hosts · 212 machines
CLUSTER / HOST ON/VM vCPU x MEM ALLOC x CPU LOAD % MEM USED %
v308 · prod 58/61 196/96 2.0x 1.1TB/768.0GB 1.5x
▸ esx01 21/22 72/32 2.2x 384.0GB/256.0GB 1.5x ████······ 41 ███████··· 72
esx02 19/20 68/32 2.1x 360.0GB/256.0GB 1.4x ███······· 32 ██████···· 64
esx03 18/19 56/32 1.7x 376.0GB/256.0GB 1.4x █████····· 53 ████████·· 81
v308 · standalone 4/4 8/16 32.0GB/128.0GB ██········ 12 ██········ 21
Two kinds of number, and the difference between them is the whole point — which
is also what decides what a narrow terminal gives up. Three layouts, each the
widest that fits whole: everything with the load drawn as bars; the same figures
with the bars down to their percentages; and, narrower still, only the two
ratios and the two percentages. Both kinds of number survive all three, because
one of them alone says nothing.
**What is allocated** — every vCPU and every megabyte the machines on a host have
been promised, added up. It routinely exceeds the host and is meant to: the ratio
is the figure worth having, because 1.5x of memory is a decision somebody made
and 8.0x is one somebody forgot. It is left blank where a host has room to
spare, since a column of "0.4x" down a screen of healthy hosts is noise where
the point is to spot the one that is over.
**What is in use** — what the host itself reports it is doing, as a bar and a
percentage. A host at 2.2x allocation and 41 % load is fine; the same host at
90 % is not, and no allocation figure can tell those two apart. A load that is
not known draws nothing rather than an empty trough: a host at one per cent
fills none of the bar either, and "almost idle" must not look like "I cannot see
this host".
A host vCenter has lost touch with keeps its hardware figures — they do not
depend on reaching it — but reports no load at all rather than the nought its
cached statistics would give: an empty bar is what an idle host looks like.
Only running machines are charged to a host. A machine that is switched off has
been promised nothing it is using, and counting it would make a host of parked
machines look full when the whole point of parking them there was that it is
not — they are still in the ON/VM count, which is where that belongs.
`⏎` on a host is the other half: it goes back to the machine list with the filter
set to that host, so the answer to "what is on this one" is the table everybody
already knows how to read, and `Esc` undoes it. The count it reports is the
host's own, not the filtered list's: the filter is a text match over the whole
row and carries no server, so it can also catch another vCenter's host of the
same name or a machine named after a host, and a count taken from it would then
contradict the screen it came from. `^r` reads the screen again —
live mode deliberately does not tick here, because this screen reads the hosts
itself and a timer doing that every ten seconds would be paying for a screen
somebody is reading rather than watching.
The allocations are added up from the rows the list already holds, so no machine
is read twice for this, and they are matched to hosts by reference rather than by
name — the same lesson `gvm host` carries a comment about, where a host added by
address and renamed later reported zero machines while running dozens.
### Live
`^l` and the list stops being a snapshot. It re-reads itself every ten seconds —
every two while vCenter is doing anything at all, so a clone's progress is
something one watches finish rather than a figure one waits for — and the title
says so, because a screen that moves on its own with nothing to explain it reads
as a fault.
It refreshes over the connections that are already open. `^r` logs in again,
which is how a session that has died is recovered; doing that every ten seconds
would be three logins a minute for nothing.
Two things come with it.
**The line under the table says what just changed.** A table shows what is; this
is the only thing on the screen that says what *became*:
db01 off · web01 +1 snapshot · app07 clone · esx03: disks need consolidating
Power states, snapshots appearing and going, tasks starting and finishing,
machines arriving and leaving, and vCenter starting to complain. Four of them and
a count of the rest, because a line that has to be read carefully is a line
nobody reads. It is the reason to leave the thing open.
Where two vCenters hold a machine of the same name the server goes in front of
it — "v309 web01 off" — and only there: a name is not what makes a machine that
machine, and the width is worth spending on the rare case rather than on every
line. A quiet refresh clears that line and nothing else: a message somebody
produced by pressing a key is theirs to keep.
**The `CPU~` column is where each machine has just been.** Six sweeps of its
processor load, one character each:
NAME PWR CPU CPU% CPU~ MEM
app07 on 4 18 ▃▅█▆▄▂ 8.0GB
web01 on 4 82 ▄▂▁▃▅█ 8.0GB
The scale is fixed at 0 to 100 and never fitted to the samples: a line that
scales itself would make a machine idling between 1 and 2 per cent look exactly
like one swinging between 40 and 80. The column is there only once there is
something in it — a machine has a history after its second sweep — and it is the
first thing after the guest's operating system that a narrow terminal gives up:
everything else in the table is a fact about a machine, and this is a shape.
A machine that is not running has no load rather than a load of zero, so nothing
is sampled from it and it draws nothing — and what it had drawn before goes with
it, rather than leaving a busy shape beside a CPU% of "-".
The refresh holds still for every screen that asks something: a menu decides what
it offers from the state it was drawn with, the picker holds a list being chosen
from, and a half-typed name or a confirmation is an answer in progress. Being ten
seconds out of date is better than any of those moving under a hand. A machine's
sheet *is* refreshed, at the line it was being read at — watching one machine's
memory is a reason to have it open.
Nothing here acts on a machine. A refresh that could start or stop something
would be a timer with the power to do it, and that is the one thing a screen left
open unattended must not have.
### Sorting
`^o` puts a legend on the status line and the next key picks the order, so the
list stays on screen while it rearranges itself:
sort: n·name p·pwr c·cpu% m·mem% #·snaps o·old w·why
s·size u·cpus v·vc h·host a·ip r·reverse
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why s·size u·cpus v·vc h·host a·ip r·reverse
Each order comes with its own direction, because that is what asking for it
means: by name is a to z, by processor load is the busiest first. `r` reverses
@@ -194,21 +331,24 @@ direction the order runs. Machines that compare equal stay in name order, so
flipping the direction on a screen full of identical figures does not reshuffle
them.
Two lines, because thirteen choices do not fit across eighty columns and a
legend that ran off the edge would hide the very choices it exists to offer.
They are grouped where the meaning breaks: what the machine is doing and what it
wants doing to it above, what it is made of and where it lives below. The second
line takes the help line's row, which describes keys that do nothing while a
menu is waiting for one.
Thirteen choices are ninety-three columns, so a terminal narrower than that
gets them on two lines instead of one that runs off the right-hand edge, hiding
the very choices the legend exists to offer. They break where the meaning
breaks — what the machine is doing and what it wants doing to it, then what it
is made of and where it lives — and the second line takes the help line's row,
which describes keys that do nothing while a menu is waiting for one. Decided
at render time, so a window dragged wider gets the one line back:
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why
s·size u·cpus v·vc h·host a·ip r·reverse
Three of them are about the two columns that are new:
* `#` is by how many snapshots the machine is carrying, most first. Nought is a
* `z` is by how many snapshots the machine is carrying, most first. Nought is a
figure here and not a missing one — nothing to clean up is a fact about the
machine — so a machine with none sorts where nought belongs: at the bottom
going down, at the top coming back up. The key is not a letter because every
letter with a mnemonic in it was taken, and a hash is what a count is written
with anyway; `--sort snapshots` needs no shell quoting.
going down, at the top coming back up. The letter carries no mnemonic because
every letter that does was taken; `--sort snaps` spells it out.
* `o` is by the age of the machine's *oldest* snapshot, oldest first, which is
the order the housekeeping is done in — a different question from `#`, and the
more useful one: one snapshot from March wants attention before six from this
@@ -283,8 +423,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.
@@ -299,57 +443,205 @@ 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
### Making a machine from a template
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.
`p` in a template's action menu, `gvm new` on the command line. It is the one
thing gvm does that brings a machine into being rather than acting on one that
already exists, and that makes its hard question *where* rather than *whether*.
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
A template is not a machine that happens to be switched off. vSphere takes its
resource pool away when it is marked as one, so a copy of it has nowhere to run
until somebody says where — and that is the one thing that cannot be inherited
from the source. Everything else is: the folder it sits in, the datastore it
lives on, the hardware it was built with.
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.
So the placement is worked out first and the question says it in full:
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:
make web05 from ubuntu-24.04-template on v308? y = yes, anything else = no
it will run in prod-cluster, powered off
The resource pool is the one the template's own host belongs to — the cluster's,
where it is in a cluster, which leaves the choice of host to DRS the way every
other deployment there does. `--host` overrides that and pins it; `--datastore`
puts it somewhere other than the template's own.
**It does not wait.** A clone copies every disk the template has, which is
minutes to the half hour, and a list frozen for that long is a list nobody would
start one from. The task hangs off the template in vCenter, so the row it was
started from shows how far along it is in its TASK column — `^l` makes that move
on its own, and announces the new machine on the line under the table when it
exists. `gvm new` on the command line does wait: a script that gets its prompt
back wants the machine to be there.
The rest of a template's menu is greyed out with "a template" next to it, because
vSphere will not start one, snapshot one or reconfigure one. They are greyed
rather than left out: a menu that changes shape between rows is one nobody
learns.
#### Telling the guest what it is
A copy of a template comes up as the template: same hostname, same address. Those
two are the facts that are about *this* machine, and everything else — the
domain, the resolvers, the netmask, the gateway, the timezone — is the site's
answer, the same for every machine. So the two are typed and the rest is kept
somewhere it is written once.
**Two places it can be kept, and gvm takes it from either.**
*In `~/.gvmrc`*, which is the short road for a site that keeps no specifications
in its vCenter:
domain = fhi-berlin.mpg.de
dns = 141.14.128.1, 141.14.128.2
netmask = 255.255.255.0
gateway = 10.0.0.1
timezone = Europe/Berlin
gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55
gvm writes the customisation itself from those: hostname from the machine's name
(`--hostname` where they differ), address from `--ip`, and the five above. It is
**Linux only** — a Windows guest is a Sysprep, which is a licence key, an
administrator password and a domain to join, and none of that belongs in a
configuration file next to the SMTP relay. `gvm config` shows what it would
write, and names the settings that are still missing.
*In the vCenter*, as a customisation specification made in the vSphere client
under *Policies and Profiles*:
gvm new --from ubuntu-tpl --name web05 --spec linux-static --ip 10.0.0.55
`--spec` names one, and **gvm overrides exactly two things in it** — the
hostname and the address — and leaves the rest alone. This is the road for
Windows, and the one to prefer where the specifications already exist: the
policy is then where the web client and every other tool can see it too. A tool
that knew better than the vCenter about any of the rest is how one ends up with
a network policy nobody remembers agreeing to.
In the list it is a step of its own: after the name, a picker of whichever roads
are open —
customise web05 how v308
▸ leave the guest as the template made it
this site — fhi-berlin.mpg.de, gateway 10.0.0.1
linux-static
windows-domain
— and then one line for the address. Empty means DHCP on gvm's own road, and
whatever the specification says on the vCenter's.
Where that picker does **not** appear, the confirmation says why rather than
leaving a step to look broken — neither road is open, because the vCenter holds
no specifications (or will not say) *and* `~/.gvmrc` has not been told the site:
make web05 from ubuntu-tpl on v308? y = yes, anything else = no
no specifications on v308, and ~/.gvmrc has no domain, netmask, gateway · in prod-cluster, powered off
`gvm new --specs` lists what a server holds, and prints the server's own words
when it will not say.
Three things it refuses rather than guesses at, on either road:
* **An address with no netmask to go with it.** Where the specification's
adapter takes its address from DHCP it carries no mask, and a machine given an
address without one half works. Where to get a mask from is a question for the
vCenter.
* **A hand-written Windows answer file**, and a cloud-init specification. Both
carry the hostname inside a format gvm does not own, and reaching into either
to change one line would be guessing.
* **Nothing at all**, silently. If the template reports no VMware Tools — which
is what carries a customisation out inside the guest — it says so before the
clone rather than leaving it to be discovered twenty minutes later on a
machine that came up under the template's name. It is a guess and is said as
one: the machine is made either way.
One more thing it says rather than refuses: a gateway that is not on the
machine's own network. A routed setup can put one anywhere, so it is not an
error — but almost every time it is a typo, and a machine that cannot reach its
gateway is one somebody drives to the console for.
Deploying without either road still works, and the guest comes up as the
template made it.
### The sheet's four letters
The four letters are the things that change nothing, on the machine or on the
vCenter: they read its history, copy its address, open it somewhere else. They
are letters of the sheet rather than entries in the menu because nothing they do
needs thinking about first, and — unlike the table, whose filter swallows every
ordinary letter — the sheet has nothing else to do with them:
* **`e`** puts the machine's own recent events at the foot of its sheet and
scrolls down to them — why is this thing off, who rebooted it, what happened
at four this morning. `gvm log` is the whole vCenter over the last hour, which
is the right shape for a mail and the wrong one for that question. They are
fetched when they are asked for: opening a machine stays one call.
* **`h`** logs in to the guest, 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:
@@ -426,6 +718,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.
@@ -588,6 +929,14 @@ selected row on a `darker` surface with a `violet` pointer, the filter's hits in
`pink`, counts in `green`, questions in `yellow`, errors in `red`, headers and
the help line in `dark`.
Every line at the foot of the screen that wants an answer is that one `yellow`,
whatever kind of question it is: the sort legend, both its lines; a yes/no
question and its hint; the label in front of a snapshot name or the `YES` of a
confirmation. They are different kinds of question and one state — gvm is
waiting for a key — and that state is worth learning once, in one place and one
tone, rather than being worked out per screen. What is typed in answer stays
`white`: it is the operator's, not part of the question.
Inside the table and the sheet every colour is a role, not a decoration:
| | |
@@ -676,6 +1025,50 @@ 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
* that a step which does not happen says why — a vCenter with no customisation
specifications, and one that will not let them be read, both used to skip the
question in silence — and that the reason and the placement both fit on the
line they share
* that the specification gvm writes itself carries the site's five settings and
this machine's two, invents nothing where an address was not given, and is
refused for a Windows template and for a configuration that is not complete —
named field by field
* that a customisation writes the hostname and the address into a vCenter's own
specification and changes nothing else in it, for Linux and for Windows; that
an address with no netmask behind it is refused, as are the two specification
kinds gvm will not reach into; and — against the simulator's four real
specifications — that the whole way through works
* that a deployment is refused from anything that is not a template, that a name
already in use is refused before the clone starts, and — against the simulator
— that what comes out is a machine rather than another template, in the pool it
was given, switched off
* 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
+426 -89
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,20 +155,63 @@ func (b *browser) openMenu() {
return
}
b.menuSnaps = snaps
b.menu = b.buildMenu(*r, snaps)
// What the machine has, and what of it may be changed while it runs. It is
// not in the sweep — it is four fields of the whole configuration document
// — so it is read here, for this one machine, so that the two hardware
// entries can be greyed out with the reason rather than accepting a number
// vCenter is going to refuse. A failure is not fatal to the rest of the
// menu: those two entries grey themselves out, saying what could not be
// read, and the warning below says it again where it cannot be missed.
b.menuSize, err = sizingOf(r.sess, r.ref)
if err != nil {
warn = append(warn, err.Error())
}
b.menu = b.buildMenu(*r, snaps, b.menuSize)
// vmDetail asks nothing of the network; the snapshots are left out of it
// because the menu only wants the four lines above.
b.menuInfo = sheetPick(vmDetail(*r, nil, ""), menuFacts)
b.menuSel = 0
b.setStatus("", "")
if len(warn) > 0 {
b.setStatus(colWarn, strings.Join(warn, " · "))
}
}
func (b *browser) closeMenu() {
b.menu, b.menuSnaps, b.menuInfo, b.menuSel = nil, nil, nil, 0
}
// buildMenu is the menu for one machine in its current state.
func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
// buildMenu is the menu for one machine in its current state — or for a
// template, which is a different thing with a different menu.
func (b *browser) buildMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
if r.isTemplate() {
// vSphere refuses to start a template, to snapshot it or to reconfigure
// it — the one thing to be done with one is to make a machine from it.
// So the entry that does that is put at the top, where the eye starts,
// and everything else says what is in the way rather than being left
// out: a menu that changes shape between machines is one nobody learns.
why := SF("%s is a template — make a machine from it first", r.name)
items := []menuItem{
{key: 'p', label: "deploy a new machine from this template ...",
run: func(b *browser, r vmRow) { b.deploy(r) }},
separator(),
}
for _, m := range b.machineMenu(r, snaps, sz) {
if !m.isSeparator() {
m.why, m.hint = why, "a template"
}
items = append(items, m)
}
return items
}
return b.machineMenu(r, snaps, sz)
}
// machineMenu is everything one does to a machine that exists: its snapshots,
// what it is made of, and its power.
func (b *browser) machineMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
noSnaps, noSnapsHint := "", ""
if len(snaps) == 0 {
noSnaps, noSnapsHint = "the machine has no snapshots", "no snapshots"
@@ -183,48 +235,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
@@ -282,10 +315,27 @@ func (b *browser) menuMove(step int) {
}
}
// ----------------------------------------------------------- the snapshot picker
// ------------------------------------------------------------------ the picker
// pickKind is what the picker is picking for. It decides the wording and what
// happens to the snapshot that is chosen.
// picker is one list of things to choose one of. It knows nothing about what it
// is choosing: the caller hands it the lines to draw and what to do with the
// one that is picked.
//
// It was the snapshot picker and nothing else. Making it general rather than
// copying it for the second list — the customisation specifications a
// deployment can use — is the difference between one screen with one set of
// keys and two screens that drift apart.
type picker struct {
title string // "revert to which snapshot of web01"
row vmRow
lines []string
chosen func(int) // what Enter does with the index
sel int
scroll int
}
// pickKind is what a snapshot picker is picking for: it decides the wording and
// what happens to the snapshot that is chosen.
type pickKind int
const (
@@ -293,20 +343,36 @@ const (
pickRemove
)
type picker struct {
kind pickKind
row vmRow
items []snapEntry
sel int
scroll int
}
func (b *browser) openPicker(r vmRow, kind pickKind) {
if len(b.menuSnaps) == 0 {
b.setStatus(colWarn, r.name+" has no snapshots")
return
}
b.pick = &picker{kind: kind, row: r, items: b.menuSnaps}
snaps := b.menuSnaps
title := "revert to which snapshot"
if kind == pickRemove {
title = "remove which snapshot"
}
lines := make([]string, len(snaps))
for i, e := range snaps {
lines[i] = e.line()
}
b.choose(title+" of "+r.name, r, lines, func(i int) {
b.closeMenu()
if kind == pickRevert {
b.revert(r, snaps[i])
return
}
b.removeOne(r, snaps[i])
})
}
// choose puts a list on the screen and calls back with what was picked. The
// callback runs after the picker has closed, so that what it does — a
// confirmation, another question — has the screen to itself.
func (b *browser) choose(title string, r vmRow, lines []string, chosen func(int)) {
b.pick = &picker{title: title, row: r, lines: lines, chosen: chosen}
}
func (b *browser) closePicker() { b.pick = nil }
@@ -319,24 +385,18 @@ func (b *browser) pickerKey(k key) {
case keyUp, keyShiftTab:
p.sel = max(p.sel-1, 0)
case keyDown, keyTab:
p.sel = min(p.sel+1, len(p.items)-1)
p.sel = min(p.sel+1, len(p.lines)-1)
case keyHome:
p.sel = 0
case keyEnd:
p.sel = len(p.items) - 1
p.sel = len(p.lines) - 1
case keyEnter:
if p.sel < 0 || p.sel >= len(p.items) {
if p.sel < 0 || p.sel >= len(p.lines) {
return
}
entry := p.items[p.sel]
kind, row := p.kind, p.row
at, chosen := p.sel, p.chosen
b.closePicker()
b.closeMenu()
if kind == pickRevert {
b.revert(row, entry)
return
}
b.removeOne(row, entry)
chosen(at)
}
}
@@ -456,6 +516,260 @@ 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))
}
// deploy makes a new machine from the template under the cursor.
//
// It does not wait. A clone copies every disk the template has and takes
// minutes to the half hour, and a list that is frozen for that long is a list
// nobody will start one from. vCenter carries the task either way, and it hangs
// off the template — so the row this was started from shows how far along it is
// in its TASK column, and ^l makes that move on its own. The machine itself
// turns up in the list when it exists, which live mode announces.
//
// The question at the end is the plain one. Nothing is lost here; something is
// made, and what has to be read before it is made is *where* — which is why
// that goes on the line under the question rather than into it, where the width
// would eat it.
func (b *browser) deploy(r vmRow) {
b.closeMenu()
if r.sess == nil {
b.setStatus(colErr, "no connection to "+r.vc.Name)
return
}
// Read again rather than taken from the row: whether this is a template is
// the whole premise, and the folder it sits in is not in the sweep.
src, err := sourceOf(r.sess, r.ref)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
if !src.template {
b.setStatus(colWarn, SF("%s is a machine, not a template", src.name))
return
}
target, err := targetFor(r.sess, src, deployOpts{})
if err != nil {
b.setStatus(colErr, err.Error())
return
}
name, ok := b.input(SF("name the new machine from %s: ", src.name))
if !ok || name == "" {
b.setStatus(colDim, "nothing done")
return
}
if err := checkName(name); err != nil {
b.setStatus(colWarn, err.Error())
return
}
// What the guest is to be told about itself, where the vCenter has anything
// to tell it with. A server with no customisation specifications is not
// asked the question — the answer could only be "none" — but it is told
// why it was not asked.
//
// Saying so is the whole point. Both of these used to be silent: a vCenter
// with no specifications and one that would not let them be read looked
// exactly like a step that had gone missing, and the warning that was set
// for the second went straight into the line the next question draws over.
// Two roads, and the picker offers whichever are open: the specifications
// the vCenter holds, and gvm's own, which it writes from ~/.gvmrc. A site
// with neither is told which of the two to set up rather than left with a
// step that looks broken.
specs, err := specNames(r.sess)
own := b.site.ready() && !src.windows()
switch {
case err != nil && !own:
// Short on purpose: this shares a line with the placement, and a line
// that runs off the edge loses whichever half was put last. `gvm new
// --specs` is where the server's own words are.
b.deployAsk(r, src, target, name, deployOpts{},
"the customisation specifications could not be read (gvm new --specs)")
return
case len(specs) == 0 && !own:
why := SF("%s has no customisation specifications", r.vc.Name)
if src.windows() {
why = SF("%s holds none, and gvm writes no Sysprep for a Windows template", r.vc.Name)
} else if miss := b.site.missing(); len(miss) > 0 {
why = SF("no specifications on %s, and %s has no %s", r.vc.Name,
configFile(), strings.Join(miss, ", "))
}
b.deployAsk(r, src, target, name, deployOpts{}, why)
return
}
lines := []string{"leave the guest as the template made it"}
kinds := []string{""} // what each line means: "" none, "-" gvm's own, else a name
if own {
// What it will do, not where the settings are kept: a picker line that
// is mostly an absolute path says nothing about the choice being made,
// and `gvm config` is where the file is named.
lines = append(lines, SF("this site — %s, gateway %s", b.site.domain, b.site.gateway))
kinds = append(kinds, "-")
}
for _, name := range specs {
lines = append(lines, name)
kinds = append(kinds, name)
}
b.choose(SF("customise %s how", name), r, lines, func(i int) {
switch kinds[i] {
case "":
b.deployAsk(r, src, target, name, deployOpts{}, "")
case "-":
b.deployAddress(r, src, target, name, deployOpts{st: b.site})
default:
b.deployAddress(r, src, target, name, deployOpts{spec: kinds[i], st: b.site})
}
})
}
// deployAddress asks for the address, which is the other half of what a
// specification cannot know: it holds the netmask, the gateway and the domain,
// and the machine holds its own number.
//
// Empty is an answer: it leaves whatever the specification says, which is
// usually DHCP, and that is the ordinary case on a network that hands out
// addresses.
func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name string, opts deployOpts) {
// Empty means two different things and the question says which: with a
// specification of the vCenter's it leaves whatever that one says, and with
// gvm's own it leaves the adapter on DHCP.
ask := SF("address for %s (empty = DHCP): ", name)
if !opts.building() {
ask = SF("address for %s (empty = as %s says): ", name, opts.spec)
}
ip, ok := b.input(ask)
if !ok {
b.setStatus(colDim, "nothing done")
return
}
opts.ip = strings.TrimSpace(ip)
b.deployAsk(r, src, t, name, opts, "")
}
// deployAsk is the last step: what will happen, in full, and then the machine.
//
// note is why a step did not happen — no customisation specifications on the
// server, or none that could be read. A step that is skipped without a word
// looks like one that is broken.
func (b *browser) deployAsk(r vmRow, src deploySource, t deployTarget, name string, opts deployOpts, note string) {
// One line holds all of this, and a terminal cuts it from the right. So
// what is unexpected goes first: the placement is the same every time and
// the machine will show it afterwards, while "there was no question about
// customisation, and here is why" is the thing somebody is looking for and
// will not scroll to find.
var parts []string
if note != "" {
parts = append(parts, note)
}
parts = append(parts, "in "+t.describe()+", powered off")
if opts.customising() {
with := opts.spec
if opts.building() {
with = "named and addressed for " + opts.st.domain
if opts.ip != "" && gatewayOffSubnet(opts.ip, opts.st) {
parts = append(parts, SF("the gateway %s is not on %s's network", opts.st.gateway, opts.ip))
}
}
if opts.ip != "" {
with += " at " + opts.ip
}
parts = append(parts, with)
if toolsMissing(r.sess, src.ref) {
parts = append(parts, "no VMware Tools in the template, so this may not take")
}
}
where := strings.Join(parts, " · ")
if !b.askWith(SF("make %s from %s on %s?", name, src.name, r.vc.Name), where) {
b.setStatus(colDim, "nothing done")
return
}
b.working(SF("starting %s ...", name))
if _, err := startDeploy(r.sess, src, t, name, opts); err != nil {
b.setStatus(colErr, err.Error())
return
}
b.done(SF("%s is being made — %s shows how far along it is, and it appears in the list when it exists",
name, src.name))
}
// 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 +951,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())
@@ -647,11 +961,6 @@ func (b *browser) renderPicker() {
cols, rows := termSize()
p := b.pick
what := "revert to which snapshot"
if p.kind == pickRemove {
what = "remove which snapshot"
}
visible := max(rows-5, 1)
if p.sel < p.scroll {
p.scroll = p.sel
@@ -659,21 +968,21 @@ func (b *browser) renderPicker() {
if p.sel >= p.scroll+visible {
p.scroll = p.sel - visible + 1
}
end := min(p.scroll+visible, len(p.items))
end := min(p.scroll+visible, len(p.lines))
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
segLine(&sb, cols, seg{colTitle, what + " of " + p.row.name},
segLine(&sb, cols, seg{colTitle, p.title},
seg{colDim, " " + p.row.vc.Name})
segLine(&sb, cols)
for i := p.scroll; i < end; i++ {
e := p.items[i]
line := p.lines[i]
pointer, col := " ", colRow
if i == p.sel {
pointer, col = "▸ ", colRowSel
}
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, e.line()})
segLine(&sb, cols, seg{colPointer, pointer}, seg{col, line})
}
for i := end - p.scroll; i < visible; i++ {
sb.WriteString(scrEOL + "\r\n")
@@ -793,13 +1102,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 +1131,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}
}
+192 -48
View File
@@ -39,20 +39,29 @@ const (
// The screen's colours. The comment on each is the name mwxcol's fzf theme gives
// the same job, so the two stay in step.
var (
colRow = cGrey.fg() // fg
colRowSel = cWhite.fg() // fg+
colSurface = cDarker.bg() // bg+
colMatch = cPink.fg() // hl, hl+
colPointer = cViolet.fg() // pointer
colHeader = cDark.fg() // header, label
colInfo = cGreen.fg() // info
colQuery = cWhite.fg() // query
colErr = cRed.fg() // prompt
colWarn = cYellow.fg() // not in the theme: a question, a wait
colTitle = attrBold + cWhite.fg() //
colLabel = cDark.fg() // the sheet's field names
colValue = cWhite.fg() // its values, where nothing better applies
colDim = cDark.fg() // disabled
colRow = cGrey.fg() // fg
colRowSel = cWhite.fg() // fg+
colSurface = cDarker.bg() // bg+
colMatch = cPink.fg() // hl, hl+
colPointer = cViolet.fg() // pointer
colHeader = cDark.fg() // header, label
colInfo = cGreen.fg() // info
colQuery = cWhite.fg() // query
colErr = cRed.fg() // prompt
colWarn = cYellow.fg() // not in the theme: a question, a wait
// colPrompt is every line at the foot of the screen that wants an answer:
// the sort legend, a yes/no question, the label in front of something being
// typed. One colour for all of them, whatever kind of question it is —
// yellow, which is the job mwxcol's own theme gives it — so that "gvm is
// waiting for me" is learned once, in one place and one tone, rather than
// being a thing to work out per screen. It is the same value as colWarn and
// a name of its own, because the two mean different things and only one of
// them may ever change.
colPrompt = cYellow.fg()
colTitle = attrBold + cWhite.fg() //
colLabel = cDark.fg() // the sheet's field names
colValue = cWhite.fg() // its values, where nothing better applies
colDim = cDark.fg() // disabled
)
// What the columns and the sheet's values are coloured by. Every one of these is
@@ -73,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
@@ -96,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
@@ -106,6 +121,12 @@ func (r vmRow) id() string { return r.vc.Name + "/" + r.ref.Value }
func (r vmRow) power() types.VirtualMachinePowerState { return r.vm.Summary.Runtime.PowerState }
// isTemplate reports whether this is a template rather than a machine. vSphere
// takes a template's resource pool away and refuses to start it, snapshot it or
// reconfigure it: the only thing to be done with one is to make a machine from
// it (deploy.go).
func (r vmRow) isTemplate() bool { return r.vm.Summary.Config.Template }
func (r vmRow) powerShort() string {
switch r.power() {
case types.VirtualMachinePowerStatePoweredOn:
@@ -312,6 +333,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
@@ -329,7 +355,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.
@@ -340,20 +366,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),
@@ -373,7 +399,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
@@ -383,7 +409,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
@@ -408,7 +434,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" {
@@ -423,6 +451,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
}
@@ -629,14 +663,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 {
@@ -664,7 +705,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
@@ -718,7 +759,17 @@ type browser struct {
events []eventLine
eventsOf string // the id of the machine they are of
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
site site // what a machine made from a template is told about the network
// 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
@@ -733,10 +784,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
@@ -750,10 +803,11 @@ 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:
// 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 {
// The configuration is handed in rather than read here, so that nothing in the
// interactive half has to know where a setting comes from: the browser keeps
// the two things it needs from it — the command `h` runs, and what a deployed
// guest is told about the network.
func browseVMs(targets []VCenter, filter string, cfg Config) error {
// Asked before anything else: the inventory sweep is three logins and a few
// seconds, and there is no point spending either on a screen that does not
// exist. It also keeps a stray `gvm` in a pipe or under cron from touching
@@ -762,7 +816,7 @@ func browseVMs(targets []VCenter, filter, ssh string) error {
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
}
b := &browser{targets: targets, filter: filter, ssh: ssh}
b := &browser{targets: targets, filter: filter, ssh: cfg.SSH, site: cfg.site()}
defer func() { closeSessions(b.sessions) }()
PF("asking %s ...\n", vcNames(targets))
@@ -831,7 +885,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
}
@@ -843,6 +909,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:
@@ -864,6 +932,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:
@@ -913,6 +983,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])
@@ -948,6 +1022,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:
@@ -963,6 +1039,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.
@@ -1023,11 +1125,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) {
@@ -1053,6 +1169,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 {
@@ -1213,17 +1330,24 @@ type prompt struct {
text string
hint string
col string
// more is a second line, drawn where the help line goes. Only the sort
// legend uses it: a menu of thirteen choices does not fit across eighty
// columns, and the help line underneath it is describing keys that do
// nothing while a menu is waiting for one.
// more is a second line, drawn where the help line goes, for what will not
// fit in the question: the sort legend, which is thirteen choices and does
// not cross eighty columns, and the placement of a machine about to be
// deployed. The help line it covers is describing keys that do nothing
// while a question is waiting for one.
more string
}
// ask puts one question on the status line and waits for a single key. Only "y"
// means yes — every other key, Esc and Ctrl-C included, means no.
func (b *browser) ask(question string) bool {
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
func (b *browser) ask(question string) bool { return b.askWith(question, "") }
// askWith is the same with something too long for the question on the help
// line under it — where a deployment puts the placement, which is the one thing
// about it worth reading twice and the one thing a status line would truncate.
func (b *browser) askWith(question, more string) bool {
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no",
col: colPrompt, more: more}
b.render()
k := b.keys.next()
b.prompt = nil
@@ -1294,6 +1418,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 == "" {
@@ -1353,12 +1489,13 @@ func (b *browser) renderList() {
b.write(sb.String())
}
// editLine draws the input in the status area: the label stays put, the typed
// text is the query colour, and a hint says what Enter alone would do.
// editLine draws the input in the status area: the label stays put in the colour
// every question at the foot of the screen has, and the typed text is the query
// colour, because it is the answer and not part of the question.
func (b *browser) editLine(sb *strings.Builder, cols int) {
segLine(sb, cols,
seg{colWarn, b.edit.label},
seg{colQuery, string(b.edit.runes)})
seg{colPrompt, b.edit.label},
seg{colQuery, string(b.edit.runes)}) // the answer is the operator's, and white
}
// parkCursor puts the terminal's own cursor where the typing happens and shows
@@ -1558,6 +1695,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
+202 -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,14 +1519,84 @@ 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.
// The sort legend is a menu, a confirmation is a yes/no question and a snapshot
// name is something typed, and they are three different kinds of question — but
// "gvm is waiting for me" is one thing, and it is learned once rather than
// worked out per screen.
func TestEveryQuestionWearsOneColour(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "12")
b := testBrowser("web01", "db01")
b.applySort()
for _, c := range []struct {
what string
set func()
text string
lines int // how many of the bottom rows the question occupies
}{
{"the sort legend", func() {
// Narrower than the whole legend, so it is the two-line one: the
// second line is the part that could quietly lose its colour.
lines := sortLegend(60)
b.prompt = &prompt{text: lines[0], col: colPrompt, more: lines[1]}
b.edit = nil
}, "sort:", 2},
{"a yes/no question", func() {
b.prompt = &prompt{text: "power on web01 on v308?", col: colPrompt}
b.edit = nil
}, "power on web01", 1},
{"a name being typed", func() {
b.prompt = nil
b.edit = &editor{label: "name the snapshot of web01: "}
}, "name the snapshot", 1},
} {
c.set()
frame := renderToPipe(t, b, b.renderList)
if !strings.Contains(frame, colPrompt+c.text) {
t.Errorf("%s is not in the colour every question has:\n%s",
c.what, frame[max(len(frame)-400, 0):])
}
// And the second line of a two-line question is in it as well, or the
// menu would fade out halfway down.
if c.lines == 2 {
second := stripEscapes(frame)
if !strings.Contains(second, "r·reverse") {
t.Errorf("%s lost its second line:\n%s", c.what, second)
}
if strings.Count(frame, colPrompt) < 2 {
t.Errorf("%s does not carry the colour onto its second line", c.what)
}
}
}
b.prompt, b.edit = nil, nil
}
+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", "--from"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
+97 -4
View File
@@ -30,7 +30,82 @@ 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
// What a deployed guest is told about the network it wakes up on
// (deploy.go). These are the site's answers and not the machine's: every
// machine gets the same domain, the same resolvers, the same mask and the
// same gateway, and only its own name and address differ. So they live
// here, where they are written once, and the command line carries the two
// that are about the one machine.
Domain string // fhi-berlin.mpg.de
DNS string // one or more resolvers, separated by commas
Netmask string // 255.255.255.0
Gateway string // 10.0.0.1
Timezone string // Europe/Berlin
}
// site is those five, as the deployment uses them: the list of resolvers split
// out, and a way to ask whether there is enough here to describe a network at
// all.
type site struct {
domain string
dns []string
netmask string
gateway string
timezone string
}
func (c Config) site() site {
s := site{domain: strings.TrimSpace(c.Domain), netmask: strings.TrimSpace(c.Netmask),
gateway: strings.TrimSpace(c.Gateway), timezone: strings.TrimSpace(c.Timezone)}
for _, d := range strings.Split(c.DNS, ",") {
if d = strings.TrimSpace(d); d != "" {
s.dns = append(s.dns, d)
}
}
return s
}
// ready reports whether gvm has been told enough to write a network into a
// guest. The domain, the mask and the gateway are the three a machine cannot
// be given an address without; the resolvers and the timezone are worth having
// and not worth refusing over.
func (s site) ready() bool {
return s.domain != "" && s.netmask != "" && s.gateway != ""
}
// describe is the site's network in one line, for `gvm config`: what a machine
// made from a template would be told, or what is still missing before one can
// be told anything.
func (s site) describe() string {
if !s.ready() {
return SF("not set up — a deployment needs %s in %s",
strings.Join(s.missing(), ", "), configFile())
}
out := SF("%s · netmask %s · gateway %s", s.domain, s.netmask, s.gateway)
if len(s.dns) > 0 {
out += " · dns " + strings.Join(s.dns, ", ")
}
if s.timezone != "" {
out += " · " + s.timezone
}
return out
}
// missing names what is not there, for a message that says what to write in
// ~/.gvmrc rather than only that something is missing.
func (s site) missing() []string {
var out []string
for _, f := range []struct {
name string
val string
}{{"domain", s.domain}, {"netmask", s.netmask}, {"gateway", s.gateway}} {
if f.val == "" {
out = append(out, f.name)
}
}
return out
}
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
@@ -412,6 +487,11 @@ func applyConfig(c *Config, m map[string]string) {
set("smtpport", &c.SMTPPort)
set("telemetry", &c.Telemetry)
set("ssh", &c.SSH)
set("domain", &c.Domain)
set("dns", &c.DNS)
set("netmask", &c.Netmask)
set("gateway", &c.Gateway)
set("timezone", &c.Timezone)
applyVCenters(c, m)
}
@@ -478,6 +558,11 @@ func applyEnv(c *Config) {
env("GVM_SMTPPORT", &c.SMTPPort)
env("GVM_TELEMETRY", &c.Telemetry)
env("GVM_SSH", &c.SSH)
env("GVM_DOMAIN", &c.Domain)
env("GVM_DNS", &c.DNS)
env("GVM_NETMASK", &c.Netmask)
env("GVM_GATEWAY", &c.Gateway)
env("GVM_TIMEZONE", &c.Timezone)
applyVCenterEnv(c)
}
@@ -587,10 +672,18 @@ 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("# --- what a machine made from a template is told about the network ---\n")
b.WriteString("# The site's answers, written once. gvm adds the machine's own name and\n")
b.WriteString("# address to them: gvm new --from <template> --name web05 --ip 10.0.0.55\n")
b.WriteString("# domain = example.org\n")
b.WriteString("# dns = 10.0.0.1, 10.0.0.2\n")
b.WriteString("# netmask = 255.255.255.0\n")
b.WriteString("# gateway = 10.0.0.1\n")
b.WriteString("# timezone = Europe/Berlin\n\n")
b.WriteString("# --- how the sheet's 'h' logs in to a guest ---\n")
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
b.WriteString("# not written anywhere. Unset means 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())
+694
View File
@@ -0,0 +1,694 @@
// deploy.go — making a new machine from a template.
//
// Everything else gvm does happens to a machine that already exists. This is the
// one thing that brings one into being, and that makes it the only operation
// here whose hardest question is not "may I" but "where".
//
// A template is not a machine that happens to be switched off. vSphere takes its
// resource pool away when it is marked as one, so there is nowhere for a clone
// of it to run until somebody says where — and that somewhere is the one thing
// that cannot be inherited from the source. Everything else can: the folder it
// sits in, the datastore it lives on, the hardware it was built with.
//
// So the placement is worked out before anything is asked, and the confirmation
// says it in full. "A new machine appeared somewhere on the estate" is not an
// outcome anybody should get from a keystroke.
//
// What this deliberately does not do is customise the guest — no hostname, no
// address, no domain join. That is a second machine's worth of vSphere
// (CustomizationSpec), it is site policy rather than a tool's business, and a
// half-done version of it that sets a hostname and leaves the address to DHCP
// would be worse than leaving it alone. The new machine is its template, under
// a new name, in a place somebody chose.
package main
import (
"net"
"sort"
"strings"
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// cloneWait is how long the command line watches a deployment before it stops
// watching. A clone copies every disk the template has, which on a fat template
// over a busy datastore is genuinely half an hour — so this is long, and like
// every other wait in gvm it is a limit on a frozen terminal rather than on the
// operation. The interactive half does not wait at all (see deploy).
const cloneWait = 60 * time.Minute
// maxVMName is what vSphere takes for a machine's name. Typing stops there
// rather than sending something the server will refuse.
const maxVMName = 80
// deployOpts is what a deployment may be told, beyond the name.
type deployOpts struct {
host string // land it here, by name; empty leaves the choice to the cluster
datastore string // empty means the template's own
powerOn bool
// What the guest is told about itself, and where that comes from.
//
// Two ways, and they are the same two everywhere in vSphere. spec names a
// customisation specification the vCenter already holds, where the site's
// answers live in the vCenter and gvm overrides only this machine's two
// facts. Without one, gvm writes the specification itself out of st — the
// same site answers, kept in ~/.gvmrc instead. The second is the shorter
// road for a site that has no specifications and does not want to keep a
// set of them; the first is the only road for Windows, which needs a
// Sysprep gvm does not write.
spec string
hostname string // empty means the machine's own name
ip string // empty leaves the adapter on DHCP
st site // the site's own answers, from the configuration
}
// customising reports whether anything is to be done to the guest at all.
func (o deployOpts) customising() bool {
return strings.TrimSpace(o.spec) != "" || strings.TrimSpace(o.ip) != "" ||
strings.TrimSpace(o.hostname) != ""
}
// building reports whether gvm is to write the specification itself rather than
// take one from the vCenter.
func (o deployOpts) building() bool {
return strings.TrimSpace(o.spec) == "" && o.customising()
}
// deployTarget is where the new machine will go, resolved from the template and
// the options before anything is sent — so the question that is asked is the
// same thing that then happens.
type deployTarget struct {
folder types.ManagedObjectReference
pool types.ManagedObjectReference
where string // the cluster or host that pool belongs to, in words
host *types.ManagedObjectReference
hostName string
datastore *types.ManagedObjectReference
dsName string
}
// describe is the placement as the confirmation says it: where it will run, on
// which host if one was named, and on which datastore if one was.
func (t deployTarget) describe() string {
out := []string{t.where}
if t.hostName != "" {
out = append(out, "on "+t.hostName)
}
if t.dsName != "" {
out = append(out, "datastore "+t.dsName)
}
return strings.Join(out, " · ")
}
// templateProps are what has to be read of the source. The sweep does not carry
// the folder a machine sits in — nothing else needs it — and "is this a
// template" is in the summary the list already holds, but it is asked again
// here: this is the one place that acts on the answer.
var templateProps = []string{"name", "parent", "summary.config.template",
"summary.config.guestId", "summary.runtime.host"}
// deploySource is the template, read fresh.
type deploySource struct {
ref types.ManagedObjectReference
name string
folder types.ManagedObjectReference
host *types.ManagedObjectReference
template bool
guestID string // vSphere's own word for what is installed, e.g. ubuntu64Guest
}
// windows reports whether the template holds Windows, which decides whether gvm
// can write a customisation for it at all: Linux takes a LinuxPrep, which is
// five lines of network, and Windows takes a Sysprep, which is a licence key,
// an administrator password and a domain to join.
//
// From the guest id vSphere itself keeps, which is what the machine was created
// as. A template whose id says nothing is treated as Linux and left to the
// server to refuse — guessing "Windows" from silence would turn every template
// with an unset id into one gvm will not deploy.
func (src deploySource) windows() bool {
return strings.Contains(strings.ToLower(src.guestID), "windows")
}
func sourceOf(s *session, ref types.ManagedObjectReference) (deploySource, error) {
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, templateProps, &mvm); err != nil {
return deploySource{}, errf("%s: cannot read %s: %w", s.vc.Name, ref.Value, err)
}
src := deploySource{ref: ref, name: mvm.Name, host: mvm.Summary.Runtime.Host,
guestID: mvm.Summary.Config.GuestId}
if c := mvm.Summary.Config; c.Template {
src.template = true
}
if mvm.Parent == nil {
return src, errf("%s is in no folder, so there is nowhere to put a copy of it", src.name)
}
src.folder = *mvm.Parent
return src, nil
}
// targetFor works out where the new machine goes.
//
// The resource pool is the part that cannot be left out. A template has none of
// its own — vSphere takes it away when a machine is marked as one — so the pool
// of whatever the template is registered on is used instead: the cluster's,
// where it is in one, which leaves the choice of host to DRS the way every other
// deployment on that cluster does. Naming a host overrides both, and pins it.
func targetFor(s *session, src deploySource, opts deployOpts) (deployTarget, error) {
t := deployTarget{folder: src.folder}
hostRef := src.host
if opts.host != "" {
ref, name, err := hostByName(s, opts.host)
if err != nil {
return t, err
}
hostRef, t.host, t.hostName = &ref, &ref, name
}
if hostRef == nil {
return t, errf("%s is not registered on any host, so there is nothing to work out where a copy of it would run", src.name)
}
pool, where, err := poolOfHost(s, *hostRef)
if err != nil {
return t, err
}
t.pool, t.where = pool, where
if opts.datastore != "" {
ref, name, err := datastoreByName(s, opts.datastore)
if err != nil {
return t, err
}
t.datastore, t.dsName = &ref, name
}
return t, nil
}
// poolOfHost is the root resource pool of whatever a host belongs to, and the
// name of that thing. For a host in a cluster this is the cluster's pool, which
// is what makes DRS place the machine; for a standalone host it is the host's
// own.
func poolOfHost(s *session, host types.ManagedObjectReference) (types.ManagedObjectReference, string, error) {
var hosts []mo.HostSystem
if err := s.objects([]types.ManagedObjectReference{host}, []string{"name", "parent"}, &hosts); err != nil {
return types.ManagedObjectReference{}, "", err
}
if len(hosts) == 0 || hosts[0].Parent == nil {
return types.ManagedObjectReference{}, "", errf("%s: cannot tell what %s belongs to", s.vc.Name, host.Value)
}
var crs []mo.ComputeResource
if err := s.objects([]types.ManagedObjectReference{*hosts[0].Parent}, []string{"name", "resourcePool"}, &crs); err != nil {
return types.ManagedObjectReference{}, "", err
}
if len(crs) == 0 || crs[0].ResourcePool == nil {
return types.ManagedObjectReference{}, "", errf("%s: %s has no resource pool to run a machine in",
s.vc.Name, shortHost(hosts[0].Name))
}
where := crs[0].Name
if where == hosts[0].Name {
where = shortHost(where) // a standalone host's compute resource is named after it
}
return *crs[0].ResourcePool, where, nil
}
// hostByName and datastoreByName resolve what was asked for by name, and say
// what there was when it is not found: a typo answered with "no such host" and
// nothing else is a puzzle, and the list is short.
func hostByName(s *session, name string) (types.ManagedObjectReference, string, error) {
hosts, err := s.hosts("name")
if err != nil {
return types.ManagedObjectReference{}, "", err
}
var had []string
for _, h := range hosts {
short := shortHost(h.Name)
if strings.EqualFold(short, name) || strings.EqualFold(h.Name, name) {
return h.Reference(), short, nil
}
had = append(had, short)
}
return types.ManagedObjectReference{}, "", errf("%s has no host called %q — it has %s",
s.vc.Name, name, strings.Join(had, ", "))
}
func datastoreByName(s *session, name string) (types.ManagedObjectReference, string, error) {
stores, err := s.datastores("name")
if err != nil {
return types.ManagedObjectReference{}, "", err
}
var had []string
for _, d := range stores {
if strings.EqualFold(d.Name, name) {
return d.Reference(), d.Name, nil
}
had = append(had, d.Name)
}
return types.ManagedObjectReference{}, "", errf("%s has no datastore called %q — it has %s",
s.vc.Name, name, strings.Join(had, ", "))
}
// checkName is what vSphere will take, asked before it is typed into a
// confirmation rather than after.
func checkName(name string) error {
n := strings.TrimSpace(name)
switch {
case n == "":
return errf("the new machine needs a name")
case len(n) > maxVMName:
return errf("%d characters is longer than the %d vSphere takes for a name", len(n), maxVMName)
case strings.ContainsAny(n, "/\\"):
return errf("a machine's name cannot hold a slash")
}
return nil
}
// nameTaken reports whether the server already has a machine of that name.
// vSphere refuses a duplicate itself, several seconds into the clone; asking
// first turns that into an answer before anything starts.
func nameTaken(s *session, name string) bool {
vm, err := s.vm(name)
return err == nil && vm != nil
}
// cloneSpec is what is sent: where it goes, what the guest is told about
// itself, and that what comes out is a machine. Its own function so all of that
// can be checked without a server — a spec built twice, once in the code and
// once in a test, proves nothing.
//
// Template is false and stated rather than left out: cloning a template
// produces another template unless something says otherwise, and a second
// template nobody asked for is the kind of thing that is only noticed weeks
// later, when somebody wonders why the machine will not start.
func cloneSpec(t deployTarget, opts deployOpts, custom *types.CustomizationSpec) types.VirtualMachineCloneSpec {
return types.VirtualMachineCloneSpec{
Location: types.VirtualMachineRelocateSpec{
Pool: &t.pool,
Host: t.host,
Datastore: t.datastore,
},
Customization: custom,
PowerOn: opts.powerOn,
Template: false,
}
}
// ------------------------------------------------------- telling the guest
// specNames are the customisation specifications this vCenter holds, in the
// order it lists them.
func specNames(s *session) ([]string, error) {
m := object.NewCustomizationSpecManager(s.client.Client)
info, err := m.Info(s.ctx)
if err != nil {
return nil, errf("%s: cannot read the customisation specifications: %w", s.vc.Name, err)
}
out := make([]string, 0, len(info))
for _, i := range info {
out = append(out, i.Name)
}
sort.Strings(out)
return out, nil
}
// customizationFor is the specification the vCenter holds, with this machine's
// own two facts written into it.
//
// Only those two. Everything else the specification says — the domain, the DNS
// servers, the netmask, the gateway, the timezone, whether the guest is Linux
// or Windows — is the site's answer, kept where the site keeps it. gvm knowing
// better than the vCenter about any of that is how a tool ends up with a
// network policy of its own that nobody remembers agreeing to.
func customizationFor(s *session, opts deployOpts, src deploySource, name string) (*types.CustomizationSpec, error) {
if opts.building() {
return builtSpec(opts, src, name)
}
m := object.NewCustomizationSpecManager(s.client.Client)
item, err := m.GetCustomizationSpec(s.ctx, strings.TrimSpace(opts.spec))
if err != nil {
had, _ := specNames(s)
if len(had) == 0 {
return nil, errf("%s has no customisation specification called %q, and none at all — "+
"they are made in the vSphere client, under Policies and Profiles",
s.vc.Name, opts.spec)
}
return nil, errf("%s has no customisation specification called %q — it has %s",
s.vc.Name, opts.spec, strings.Join(had, ", "))
}
spec := item.Spec
host := strings.TrimSpace(opts.hostname)
if host == "" {
host = name // the machine's own name, which is what one means by a hostname
}
if err := setHostName(&spec, host); err != nil {
return nil, err
}
if opts.ip != "" {
if err := setAddress(&spec, opts.ip); err != nil {
return nil, err
}
}
return &spec, nil
}
// builtSpec is the customisation gvm writes itself: the site's answers from
// ~/.gvmrc, and this machine's name and address.
//
// It is Linux only, and says so rather than producing something that half
// works. Windows is a Sysprep — a licence key, an administrator password, a
// domain to join and a workgroup if it does not — and none of that is a thing
// to keep in a configuration file next to the SMTP relay. A site with Windows
// templates wants a specification in the vCenter, which is what --spec is for.
func builtSpec(opts deployOpts, src deploySource, name string) (*types.CustomizationSpec, error) {
if src.windows() {
return nil, errf("%s is a Windows template, and gvm does not write a Sysprep — "+
"make a specification in the vSphere client and name it with --spec", src.name)
}
st := opts.st
if !st.ready() {
return nil, errf("a machine cannot be told about the network until %s says %s — "+
"or name a specification the vCenter holds with --spec",
configFile(), strings.Join(st.missing(), ", "))
}
host := strings.TrimSpace(opts.hostname)
if host == "" {
host = name
}
adapter := types.CustomizationIPSettings{
Ip: &types.CustomizationDhcpIpGenerator{},
SubnetMask: st.netmask,
Gateway: []string{st.gateway},
DnsServerList: st.dns,
}
if ip := strings.TrimSpace(opts.ip); ip != "" {
if err := checkAddress(ip, st); err != nil {
return nil, err
}
adapter.Ip = &types.CustomizationFixedIp{IpAddress: ip}
}
spec := &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{
HostName: &types.CustomizationFixedName{Name: host},
Domain: st.domain,
TimeZone: st.timezone,
HwClockUTC: types.NewBool(true),
},
GlobalIPSettings: types.CustomizationGlobalIPSettings{
DnsServerList: st.dns,
DnsSuffixList: []string{st.domain},
},
NicSettingMap: []types.CustomizationAdapterMapping{{Adapter: adapter}},
}
return spec, nil
}
// checkAddress is what gvm can tell about an address before the guest has it:
// that it is one, that the mask and the gateway are, and that the gateway is
// somewhere the machine could reach. The last is a warning's worth of wrong
// rather than an error's — a routed setup can put a gateway anywhere — but
// almost every time it is a typo, and a machine with an unreachable gateway is
// one somebody drives to the console for.
func checkAddress(ip string, st site) error {
addr := net.ParseIP(strings.TrimSpace(ip))
if addr == nil || addr.To4() == nil {
return errf("%q is not an IPv4 address", ip)
}
mask := net.ParseIP(st.netmask)
if mask == nil || mask.To4() == nil {
return errf("the netmask in %s is %q, which is not one", configFile(), st.netmask)
}
gw := net.ParseIP(st.gateway)
if gw == nil || gw.To4() == nil {
return errf("the gateway in %s is %q, which is not an address", configFile(), st.gateway)
}
return nil
}
// gatewayOffSubnet reports whether the gateway is outside the network the
// address and mask describe — said on the confirmation rather than refused.
func gatewayOffSubnet(ip string, st site) bool {
addr, gw := net.ParseIP(strings.TrimSpace(ip)).To4(), net.ParseIP(st.gateway).To4()
mask := net.ParseIP(st.netmask).To4()
if addr == nil || gw == nil || mask == nil {
return false // not knowing is not the same as knowing it is wrong
}
m := net.IPMask(mask)
return !addr.Mask(m).Equal(gw.Mask(m))
}
// setHostName writes the name into whichever kind of identity the
// specification carries. The two that matter are Linux and Windows; the third
// is a Windows answer file somebody wrote by hand, and a tool that reached into
// that to change one line would be guessing at a format it does not own.
func setHostName(spec *types.CustomizationSpec, name string) error {
fixed := &types.CustomizationFixedName{Name: name}
switch id := spec.Identity.(type) {
case *types.CustomizationLinuxPrep:
id.HostName = fixed
case *types.CustomizationSysprep:
id.UserData.ComputerName = fixed
case *types.CustomizationSysprepText:
return errf("that specification is a Windows answer file written by hand — " +
"gvm will not edit one; put the machine's name in the file, or use a specification with a name field")
case *types.CustomizationCloudinitPrep:
return errf("that specification is a cloud-init one, which carries the hostname inside its " +
"metadata — gvm does not rewrite that; leave --hostname off and let cloud-init set it")
default:
return errf("that specification has no kind of identity gvm knows how to name (%T)", spec.Identity)
}
return nil
}
// setAddress puts a fixed address on the specification's first adapter, and
// keeps everything else about it.
//
// The netmask and the gateway are deliberately not asked for: they belong to
// the network, the specification already carries them, and a machine given an
// address with a mask invented by the tool that deployed it is a machine that
// half works. Where the specification has no mask — because its adapter is set
// to DHCP — it is refused, because that is a question for the vCenter and not
// for a command line.
func setAddress(spec *types.CustomizationSpec, ip string) error {
if net.ParseIP(strings.TrimSpace(ip)) == nil {
return errf("%q is not an address", ip)
}
if len(spec.NicSettingMap) == 0 {
return errf("that specification has no network adapter in it, so there is nowhere to put an address")
}
nic := &spec.NicSettingMap[0]
if nic.Adapter.SubnetMask == "" {
return errf("that specification's adapter has no subnet mask — it is set to take one from DHCP, " +
"so an address given here would arrive without one; put a mask in the specification, or leave --ip off")
}
nic.Adapter.Ip = &types.CustomizationFixedIp{IpAddress: strings.TrimSpace(ip)}
return nil
}
// toolsMissing reports whether the template looks as though it has no VMware
// Tools, which is what carries out a customisation inside the guest.
//
// A guess, and treated as one: the version is what the machine last reported,
// so a template made from a machine that never ran says nothing here. It is
// worth saying anyway — finding out that the hostname was never set is
// otherwise something that happens twenty minutes later, on a guest that came
// up under the template's own name.
func toolsMissing(s *session, ref types.ManagedObjectReference) bool {
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, []string{"config.tools"}, &mvm); err != nil {
return false // not known is not the same as not there
}
return mvm.Config != nil && mvm.Config.Tools != nil && mvm.Config.Tools.ToolsVersion == 0
}
// startDeploy sends the clone and hands back the task without waiting for it.
// Who waits is the caller's business: the command line does, the interactive
// list does not (see deploy).
func startDeploy(s *session, src deploySource, t deployTarget, name string, opts deployOpts) (*object.Task, error) {
if !src.template {
return nil, errf("%s is a machine, not a template — gvm only makes copies of templates", src.name)
}
if err := checkName(name); err != nil {
return nil, err
}
if nameTaken(s, name) {
return nil, errf("%s already has a machine called %s", s.vc.Name, name)
}
var custom *types.CustomizationSpec
if opts.customising() {
var err error
if custom, err = customizationFor(s, opts, src, strings.TrimSpace(name)); err != nil {
return nil, err
}
}
vm := object.NewVirtualMachine(s.client.Client, src.ref)
folder := object.NewFolder(s.client.Client, t.folder)
task, err := vm.Clone(s.ctx, folder, strings.TrimSpace(name), cloneSpec(t, opts, custom))
if err != nil {
return nil, errf("%s: cannot start making %s from %s: %w", s.vc.Name, name, src.name, err)
}
return task, nil
}
// hostNameOf and addressOf read back what the specification will actually do,
// for the confirmation. Read back rather than repeated from the options: what
// is shown is then the thing that was built, not the thing that was asked for.
func hostNameOf(spec *types.CustomizationSpec) string {
var name types.BaseCustomizationName
switch id := spec.Identity.(type) {
case *types.CustomizationLinuxPrep:
name = id.HostName
case *types.CustomizationSysprep:
name = id.UserData.ComputerName
}
if fixed, ok := name.(*types.CustomizationFixedName); ok {
return fixed.Name
}
return "as the specification says"
}
func addressOf(spec *types.CustomizationSpec) string {
if len(spec.NicSettingMap) == 0 {
return "as the specification says"
}
nic := spec.NicSettingMap[0].Adapter
fixed, ok := nic.Ip.(*types.CustomizationFixedIp)
if !ok {
return "as the specification says (DHCP, most likely)"
}
out := fixed.IpAddress
if nic.SubnetMask != "" {
out += " netmask " + nic.SubnetMask
}
if len(nic.Gateway) > 0 {
out += " via " + strings.Join(nic.Gateway, ", ")
}
return out
}
// ---------------------------------------------------------- the command line
// specsCLI is `gvm new --specs`: what the vCenter has to customise a guest
// with, and nothing else. It exists because the question it answers is the one
// that follows "why was I not asked which specification to use" — and because
// a name typed at --spec is worth being able to look up.
func specsCLI(vc VCenter) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
names, err := specNames(s)
if err != nil {
return err
}
if len(names) == 0 {
PE(vc.Name+" holds no customisation specifications",
"they are made in the vSphere client, under Policies and Profiles")
return nil
}
for _, n := range names {
PO(n)
}
return nil
}
// deployCLI is `gvm new`: work out where it goes, say so, ask, and then wait —
// a script that gets its prompt back wants the machine to exist.
func deployCLI(vc VCenter, template, name string, opts deployOpts, yes bool) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
if err := checkName(name); err != nil {
return err
}
vm, err := s.vm(template)
if err != nil {
return err
}
src, err := sourceOf(s, vm.Reference())
if err != nil {
return err
}
if !src.template {
return errf("%s is a machine, not a template — gvm only makes copies of templates", src.name)
}
t, err := targetFor(s, src, opts)
if err != nil {
return err
}
if nameTaken(s, name) {
return errf("%s already has a machine called %s", vc.Name, name)
}
started := "no — o in the list, or --on next time"
if opts.powerOn {
started = "yes, as soon as it is made"
}
facts := [][2]string{
{"from", src.name},
{"new machine", name},
{"where", t.describe()},
{"powered on", started},
}
if opts.customising() {
// Resolved before the question, not after it: a specification that does
// not exist, or one whose adapter has no netmask, is something to hear
// about now rather than at the end of a clone.
custom, err := customizationFor(s, opts, src, name)
if err != nil {
return err
}
how := opts.spec
if opts.building() {
how = "from " + configFile()
}
facts = append(facts, [2]string{"customise", how}, [2]string{"hostname", hostNameOf(custom)})
facts = append(facts, [2]string{"address", addressOf(custom)})
if opts.building() && opts.ip != "" && gatewayOffSubnet(opts.ip, opts.st) {
PE(SF("the gateway %s is not on the same network as %s — the machine will not reach it",
opts.st.gateway, opts.ip))
}
if toolsMissing(s, src.ref) {
PE(src.name + " reports no VMware Tools, which is what carries a customisation out inside " +
"the guest — the machine will be made either way, but it may come up as the template did")
}
}
ok, err := confirmFacts(vc, "make "+name+" from the template "+src.name, facts, yes)
if err != nil || !ok {
return err
}
task, err := startDeploy(s, src, t, name, opts)
if err != nil {
return err
}
PO(SF("making %s from %s ...", name, src.name))
if err := waitTask(s.ctx, task, cloneWait, SF("making %s", name)); err != nil {
return err
}
PO(SF("%s is made", name))
return nil
}
+572
View File
@@ -0,0 +1,572 @@
package main
import (
"os"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/types"
)
// templateRow is a row vSphere would call a template rather than a machine.
func templateRow(name string) vmRow {
r := testRow(name, false, "-")
r.vm.Summary.Config.Template = true
return r
}
// What vSphere takes for a name, asked before it is typed into a confirmation
// rather than after it.
func TestCheckName(t *testing.T) {
for _, c := range []struct {
name string
bad string
}{
{name: "web05"},
{name: "web-05.example.org"},
{name: " spaced "}, // trimmed, and what is left is a name
{name: "", bad: "needs a name"},
{name: " ", bad: "needs a name"},
{name: strings.Repeat("x", maxVMName+1), bad: "longer than"},
{name: "web/05", bad: "slash"},
{name: `web\05`, bad: "slash"},
} {
err := checkName(c.name)
switch {
case c.bad == "":
if err != nil {
t.Errorf("%q was refused: %v", c.name, err)
}
case err == nil:
t.Errorf("%q was accepted, want a refusal mentioning %q", c.name, c.bad)
case !strings.Contains(err.Error(), c.bad):
t.Errorf("%q was refused with %q, which does not mention %q", c.name, err, c.bad)
}
}
}
// The placement is the one thing about a deployment worth reading twice, so it
// has to say all of what was decided and none of what was not.
func TestTheTargetDescribesItself(t *testing.T) {
if got := (deployTarget{where: "prod-cluster"}).describe(); got != "prod-cluster" {
t.Errorf("a cluster alone reads as %q", got)
}
got := deployTarget{where: "prod-cluster", hostName: "esx03", dsName: "ssd-2"}.describe()
for _, want := range []string{"prod-cluster", "esx03", "ssd-2"} {
if !strings.Contains(got, want) {
t.Errorf("the placement does not mention %q: %q", want, got)
}
}
// What was left to the cluster is not dressed up as a choice.
if strings.Contains((deployTarget{where: "prod-cluster"}).describe(), "on ") {
t.Errorf("a placement with no host named one anyway: %q", got)
}
}
// A template is not a machine that happens to be off. vSphere will not start
// one, snapshot one or reconfigure one, so the menu offers the single thing
// that can be done with it and says why the rest cannot — rather than leaving
// them out, which would make the menu change shape between rows.
func TestTheMenuOfATemplate(t *testing.T) {
b := &browser{}
menu := b.buildMenu(templateRow("web-template"), nil, testSizing())
first := menu[0]
if first.key != 'p' || !strings.Contains(first.label, "template") {
t.Fatalf("the first entry is %q (%q), want the one that deploys", string(first.key), first.label)
}
if !first.available() {
t.Errorf("deploying from a template is not offered: %s", first.why)
}
for _, m := range menu[1:] {
if m.isSeparator() {
continue
}
if m.available() {
t.Errorf("%q (%s) is offered on a template", string(m.key), m.label)
}
if !strings.Contains(m.why, "is a template") {
t.Errorf("%q is greyed out with %q, which does not say it is a template", string(m.key), m.why)
}
}
// And every letter still reaches exactly one entry, the new one included.
seen := map[rune]string{}
for _, m := range menu {
if m.isSeparator() {
continue
}
if other, ok := seen[m.key]; ok {
t.Errorf("%q is the letter for both %q and %q", string(m.key), other, m.label)
}
seen[m.key] = m.label
}
// An ordinary machine's menu is unchanged: no deploy entry on something
// there is no template to deploy from.
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.key == 'p' {
t.Error("an ordinary machine offers to deploy from itself")
}
}
}
// The row knows what it is, from the same property the sheet prints.
func TestARowKnowsATemplate(t *testing.T) {
if templateRow("web-template").isTemplate() != true {
t.Error("a template does not read as one")
}
if testRow("web01", true, "10.0.0.5").isTemplate() {
t.Error("an ordinary machine reads as a template")
}
// And the sheet says so where it says what the machine is.
sheet := sheetText(vmDetail(templateRow("web-template"), nil, ""))
if !strings.Contains(sheet, "template") {
t.Errorf("the sheet does not say it is a template:\n%s", sheet)
}
}
// Deploying from something that is not a template is refused before anything
// is sent, wherever it is asked from.
func TestDeployingFromAMachineIsRefused(t *testing.T) {
src := deploySource{name: "web01", template: false}
_, err := startDeploy(nil, src, deployTarget{}, "copy01", deployOpts{})
if err == nil {
t.Fatal("a machine was copied as though it were a template")
}
if !strings.Contains(err.Error(), "not a template") {
t.Errorf("it was refused with %q", err)
}
// The interactive half says the same thing rather than starting anything.
b := &browser{rows: []vmRow{testRow("web01", true, "10.0.0.5")}, view: []int{0}}
b.deploy(b.rows[0]) // no session: it gets no further than that
if !strings.Contains(b.status, "no connection") {
t.Errorf("it said %q", b.status)
}
}
// A deployment carries the placement into the spec it sends, and asks for a
// machine rather than another template.
func TestTheCloneSpecSaysWhereAndWhat(t *testing.T) {
pool := types.ManagedObjectReference{Type: "ResourcePool", Value: "resgroup-9"}
host := types.ManagedObjectReference{Type: "HostSystem", Value: "host-3"}
ds := types.ManagedObjectReference{Type: "Datastore", Value: "datastore-7"}
target := deployTarget{pool: pool, host: &host, datastore: &ds, where: "prod"}
spec := cloneSpec(target, deployOpts{}, nil)
if *spec.Location.Pool != pool {
t.Error("the pool did not reach the spec")
}
if spec.Location.Host == nil || *spec.Location.Host != host {
t.Error("the host did not reach the spec")
}
if spec.Location.Datastore == nil || *spec.Location.Datastore != ds {
t.Error("the datastore did not reach the spec")
}
if spec.Template {
t.Error("the copy would be another template")
}
if spec.PowerOn {
t.Error("it would be started, though nothing asked for that")
}
// What was left to the cluster is left out of the spec rather than filled
// in with something: a nil host is vSphere being asked to place it.
bare := cloneSpec(deployTarget{pool: pool}, deployOpts{powerOn: true}, nil)
if bare.Location.Host != nil || bare.Location.Datastore != nil {
t.Error("a placement that named neither a host nor a datastore invented one")
}
if !bare.PowerOn {
t.Error("--on did not reach the spec")
}
if bare.Customization != nil {
t.Error("a deployment that was told nothing about the guest customises it anyway")
}
}
// gvm writes two facts into a customisation specification and leaves every
// other thing in it alone. These are the ones it cannot get from a server, so
// they are checked without one.
func TestWritingTheTwoFactsIntoASpecification(t *testing.T) {
linux := func() *types.CustomizationSpec {
return &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{
Domain: "fhi-berlin.mpg.de",
HostName: &types.CustomizationFixedName{Name: "the-template"},
},
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{
Ip: &types.CustomizationDhcpIpGenerator{},
SubnetMask: "255.255.255.0",
Gateway: []string{"10.0.0.1"},
},
}},
}
}
// The hostname goes in, and the domain it belongs to is not touched.
spec := linux()
if err := setHostName(spec, "web05"); err != nil {
t.Fatalf("setHostName: %v", err)
}
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname came out as %q", got)
}
if d := spec.Identity.(*types.CustomizationLinuxPrep).Domain; d != "fhi-berlin.mpg.de" {
t.Errorf("the domain was changed to %q", d)
}
// The address goes in, and the netmask and gateway the site chose stay.
if err := setAddress(spec, "10.0.0.55"); err != nil {
t.Fatalf("setAddress: %v", err)
}
nic := spec.NicSettingMap[0].Adapter
fixed, ok := nic.Ip.(*types.CustomizationFixedIp)
if !ok || fixed.IpAddress != "10.0.0.55" {
t.Errorf("the address came out as %#v", nic.Ip)
}
if nic.SubnetMask != "255.255.255.0" || len(nic.Gateway) != 1 || nic.Gateway[0] != "10.0.0.1" {
t.Errorf("the netmask or the gateway was rewritten: %q %v", nic.SubnetMask, nic.Gateway)
}
// Windows is the other identity that has a name field.
win := &types.CustomizationSpec{Identity: &types.CustomizationSysprep{}}
if err := setHostName(win, "WEB05"); err != nil {
t.Fatalf("a Windows specification was refused: %v", err)
}
if got := hostNameOf(win); got != "WEB05" {
t.Errorf("the Windows computer name came out as %q", got)
}
// And the ones gvm will not reach into, each saying why rather than
// quietly deploying a machine under the template's own name.
for _, c := range []struct {
what string
spec *types.CustomizationSpec
says string
}{
{"a hand-written answer file", &types.CustomizationSpec{
Identity: &types.CustomizationSysprepText{}}, "answer file"},
{"a cloud-init specification", &types.CustomizationSpec{
Identity: &types.CustomizationCloudinitPrep{}}, "cloud-init"},
{"nothing at all", &types.CustomizationSpec{}, "no kind of identity"},
} {
err := setHostName(c.spec, "web05")
if err == nil {
t.Errorf("%s was named anyway", c.what)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s was refused with %q, which does not mention %q", c.what, err, c.says)
}
}
}
// An address is refused where it would arrive without a netmask, because a
// machine with an address and no mask half works — and where to get the mask
// from is a question for the vCenter, not for a command line.
func TestAnAddressNeedsAMaskToGoWith(t *testing.T) {
dhcp := &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{},
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{Ip: &types.CustomizationDhcpIpGenerator{}},
}},
}
err := setAddress(dhcp, "10.0.0.55")
if err == nil {
t.Fatal("an address was written into a specification with no netmask")
}
if !strings.Contains(err.Error(), "subnet mask") {
t.Errorf("it was refused with %q", err)
}
// A specification with no adapter at all has nowhere to put one.
if err := setAddress(&types.CustomizationSpec{}, "10.0.0.55"); err == nil {
t.Error("an address was written into a specification with no adapter")
}
// And what is not an address is not one.
withNic := &types.CustomizationSpec{
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{SubnetMask: "255.255.255.0"},
}},
}
for _, bad := range []string{"ten.oh.oh.oh", "10.0.0.555", "web05", ""} {
if err := setAddress(withNic, bad); err == nil {
t.Errorf("%q was taken for an address", bad)
}
}
}
// What the confirmation shows is read back off the specification that was
// built, so it says what will happen rather than what was asked for.
func TestTheConfirmationReadsBackWhatWasBuilt(t *testing.T) {
spec := &types.CustomizationSpec{
Identity: &types.CustomizationLinuxPrep{},
NicSettingMap: []types.CustomizationAdapterMapping{{
Adapter: types.CustomizationIPSettings{
Ip: &types.CustomizationDhcpIpGenerator{},
SubnetMask: "255.255.255.0",
Gateway: []string{"10.0.0.1"},
},
}},
}
// Left as the specification has it: said so, rather than shown as blank.
if got := addressOf(spec); !strings.Contains(got, "specification") {
t.Errorf("an address left to the specification reads as %q", got)
}
if got := hostNameOf(spec); !strings.Contains(got, "specification") {
t.Errorf("a name left to the specification reads as %q", got)
}
_ = setHostName(spec, "web05")
_ = setAddress(spec, "10.0.0.55")
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") ||
!strings.Contains(got, "255.255.255.0") || !strings.Contains(got, "10.0.0.1") {
t.Errorf("the address line does not carry all three: %q", got)
}
}
// A step that does not happen has to say why. A vCenter with no customisation
// specifications, and one that will not let them be read, both used to skip
// the question in silence — which looks exactly like a step that is broken,
// and the warning set for the second went straight into the line the next
// question draws over.
func TestAMissingStepSaysWhyItIsMissing(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "24")
for _, note := range []string{
"v308 has no customisation specifications",
"the customisation specifications could not be read (gvm new --specs)",
} {
r := templateRow("ubuntu-tpl")
b := &browser{rows: []vmRow{r}, view: []int{0}}
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = pw
drawn := make(chan string, 1)
go func() {
buf := make([]byte, 1<<16)
n, _ := pr.Read(buf)
drawn <- string(buf[:n])
}()
kr, kw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.keys = newKeyReader(kr)
kw.WriteString("n") // anything but y: the question is what is being checked
b.deployAsk(r, deploySource{name: "ubuntu-tpl"},
deployTarget{where: "prod-cluster"}, "web05", deployOpts{}, note)
frame := stripEscapes(<-drawn)
pw.Close()
pr.Close()
kw.Close()
if !strings.Contains(frame, note) {
t.Errorf("the reason is not on the screen, or was cut short:\n%s", lastLine(frame))
}
// And the placement is still there beside it: the line has to hold both
// on an ordinary terminal.
if !strings.Contains(frame, "prod-cluster") {
t.Errorf("the reason pushed the placement off the line:\n%s", lastLine(frame))
}
if !strings.Contains(frame, "make web05 from ubuntu-tpl") {
t.Errorf("the question itself is missing:\n%s", lastLine(frame))
}
}
}
// lastLine is the bottom of a drawn frame, for a message about it.
func lastLine(frame string) string {
lines := strings.Split(strings.ReplaceAll(strings.TrimRight(frame, "\n"), "\r", ""), "\n")
if len(lines) < 2 {
return frame
}
return strings.Join(lines[len(lines)-2:], "\n")
}
// testSite is a site that has been told everything it needs.
func testSite() site {
return site{domain: "fhi-berlin.mpg.de", dns: []string{"10.0.0.1", "10.0.0.2"},
netmask: "255.255.255.0", gateway: "10.0.0.1", timezone: "Europe/Berlin"}
}
// The specification gvm writes itself: the site's answers from the
// configuration, this machine's two facts, and nothing invented.
func TestTheSpecificationGvmWritesItself(t *testing.T) {
linux := deploySource{name: "ubuntu-tpl", guestID: "ubuntu64Guest"}
opts := deployOpts{st: testSite(), ip: "10.0.0.55"}
spec, err := builtSpec(opts, linux, "web05")
if err != nil {
t.Fatalf("builtSpec: %v", err)
}
// The machine's own two facts.
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname is %q", got)
}
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") {
t.Errorf("the address is %q", got)
}
// And the site's, carried through rather than made up.
id, ok := spec.Identity.(*types.CustomizationLinuxPrep)
if !ok {
t.Fatalf("the identity is %T, want a Linux one", spec.Identity)
}
if id.Domain != "fhi-berlin.mpg.de" {
t.Errorf("the domain is %q", id.Domain)
}
if id.TimeZone != "Europe/Berlin" {
t.Errorf("the timezone is %q", id.TimeZone)
}
if len(spec.GlobalIPSettings.DnsServerList) != 2 {
t.Errorf("the resolvers are %v", spec.GlobalIPSettings.DnsServerList)
}
if len(spec.GlobalIPSettings.DnsSuffixList) != 1 ||
spec.GlobalIPSettings.DnsSuffixList[0] != "fhi-berlin.mpg.de" {
t.Errorf("the search domain is %v", spec.GlobalIPSettings.DnsSuffixList)
}
nic := spec.NicSettingMap[0].Adapter
if nic.SubnetMask != "255.255.255.0" || len(nic.Gateway) != 1 || nic.Gateway[0] != "10.0.0.1" {
t.Errorf("the adapter got mask %q gateway %v", nic.SubnetMask, nic.Gateway)
}
// --hostname wins over the machine's name where the two differ.
named := opts
named.hostname = "web05.fhi-berlin.mpg.de"
spec, err = builtSpec(named, linux, "web05")
if err != nil {
t.Fatalf("builtSpec with a hostname: %v", err)
}
if got := hostNameOf(spec); got != "web05.fhi-berlin.mpg.de" {
t.Errorf("--hostname was ignored: %q", got)
}
// With no address the adapter is left on DHCP, which is a whole answer:
// the name is still set, and that is what was asked for.
spec, err = builtSpec(deployOpts{st: testSite(), hostname: "web05"}, linux, "web05")
if err != nil {
t.Fatalf("builtSpec without an address: %v", err)
}
if _, fixed := spec.NicSettingMap[0].Adapter.Ip.(*types.CustomizationFixedIp); fixed {
t.Error("an address was invented where none was given")
}
}
// Two things it will not write, each saying what to do instead rather than
// producing something that half works.
func TestWhatGvmWillNotWriteItself(t *testing.T) {
// Windows, which is a Sysprep: a licence key, an administrator password
// and a domain to join, none of which belongs in a configuration file next
// to the SMTP relay.
win := deploySource{name: "win2022-tpl", guestID: "windows2019srv_64Guest"}
_, err := builtSpec(deployOpts{st: testSite(), ip: "10.0.0.55"}, win, "web05")
if err == nil {
t.Fatal("a Sysprep was written for a Windows template")
}
if !strings.Contains(err.Error(), "--spec") {
t.Errorf("it was refused without naming the way round it: %v", err)
}
// And a site that has not been told enough, named field by field.
linux := deploySource{name: "ubuntu-tpl", guestID: "ubuntu64Guest"}
_, err = builtSpec(deployOpts{st: site{domain: "example.org"}, ip: "10.0.0.55"}, linux, "web05")
if err == nil {
t.Fatal("a network was written out of a configuration that has none")
}
for _, want := range []string{"netmask", "gateway"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q: %v", want, err)
}
}
// A template whose guest id says nothing is taken for Linux: guessing
// Windows from silence would refuse every template with an unset id.
quiet := deploySource{name: "tpl"}
if _, err := builtSpec(deployOpts{st: testSite()}, quiet, "web05"); err != nil {
t.Errorf("a template with no guest id was refused: %v", err)
}
}
// What gvm can tell about an address before the guest has it.
func TestCheckAddressAndTheGateway(t *testing.T) {
st := testSite()
for _, bad := range []string{"ten.oh.oh.oh", "10.0.0.555", "", "2001:db8::1"} {
if err := checkAddress(bad, st); err == nil {
t.Errorf("%q was taken for an IPv4 address", bad)
}
}
if err := checkAddress("10.0.0.55", st); err != nil {
t.Errorf("a good address was refused: %v", err)
}
// Nonsense in the configuration is named as the configuration's.
for _, broken := range []site{
{domain: "x", netmask: "not-a-mask", gateway: "10.0.0.1"},
{domain: "x", netmask: "255.255.255.0", gateway: "over-there"},
} {
err := checkAddress("10.0.0.55", broken)
if err == nil {
t.Errorf("%+v was accepted", broken)
continue
}
if !strings.Contains(err.Error(), configFile()) {
t.Errorf("it does not say where to fix it: %v", err)
}
}
// A gateway on another network is a warning's worth of wrong, not an
// error's — but it is said.
if gatewayOffSubnet("10.0.0.55", st) {
t.Error("a gateway on the machine's own network was called foreign")
}
if !gatewayOffSubnet("192.168.5.10", st) {
t.Error("a gateway on another network went unremarked")
}
// And what cannot be judged is not judged.
if gatewayOffSubnet("10.0.0.55", site{netmask: "nonsense", gateway: "10.0.0.1"}) {
t.Error("an unreadable netmask produced a verdict anyway")
}
}
// The site is read out of the configuration the way every other setting is.
func TestTheSiteComesOutOfTheConfiguration(t *testing.T) {
cfg := Config{Domain: " example.org ", DNS: "10.0.0.1, 10.0.0.2 ,",
Netmask: "255.255.255.0", Gateway: "10.0.0.1", Timezone: "Europe/Berlin"}
st := cfg.site()
if st.domain != "example.org" {
t.Errorf("the domain came out as %q", st.domain)
}
if len(st.dns) != 2 || st.dns[0] != "10.0.0.1" || st.dns[1] != "10.0.0.2" {
t.Errorf("the resolvers came out as %v", st.dns)
}
if !st.ready() {
t.Errorf("a configuration with all of it is not ready: %v", st.missing())
}
// And one that is not says which parts are missing, in the words of the
// settings somebody would have to write.
half := Config{Domain: "example.org"}.site()
if half.ready() {
t.Error("a configuration with no netmask or gateway says it is ready")
}
if got := half.missing(); len(got) != 2 {
t.Errorf("it names %v as missing", got)
}
if !strings.Contains(Config{}.site().describe(), "not set up") {
t.Errorf("an empty one describes itself as %q", Config{}.site().describe())
}
}
+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
}
+65 -3
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.3.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -131,6 +131,39 @@ 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)")
// Two things again, so both are flags of their own: what to copy and what to
// call the copy. --host and --datastore are the two parts of the placement
// that are not inherited from the template.
var newFrom, newName, newHost, newStore string
var newSpec, newIP, newHostname string
var newSpecs bool
var newOn bool
subNew := flaggy.NewSubcommand("new")
subNew.Description = "Make a new machine from a template"
subNew.String(&newFrom, "", "from", "The template to copy")
subNew.String(&newName, "", "name", "What to call the new machine")
subNew.String(&newHost, "", "host", "Put it on this host, rather than letting its cluster decide")
subNew.String(&newStore, "", "datastore", "Put it on this datastore, rather than the template's own")
subNew.Bool(&newOn, "", "on", "Power it on once it is made")
subNew.String(&newSpec, "", "spec", "Customise the guest with this specification from the vCenter")
subNew.String(&newIP, "", "ip", "Give it this address, with the netmask and gateway from the specification")
subNew.String(&newHostname, "", "hostname", "Name the guest this, rather than after the machine")
subNew.Bool(&newSpecs, "", "specs", "List the customisation specifications this vCenter holds")
var hostCount, hostTelemetry bool
subHost := flaggy.NewSubcommand("host")
subHost.Description = "Host commands"
@@ -158,6 +191,8 @@ func run() error {
flaggy.AttachSubcommand(subVM, 1)
flaggy.AttachSubcommand(subSnap, 1)
flaggy.AttachSubcommand(subPower, 1)
flaggy.AttachSubcommand(subSize, 1)
flaggy.AttachSubcommand(subNew, 1)
flaggy.AttachSubcommand(subHost, 1)
flaggy.AttachSubcommand(subDS, 1)
flaggy.AttachSubcommand(subLog, 1)
@@ -196,7 +231,7 @@ func run() error {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch, cfg.SSH)
return browseVMs(targets, vmMatch, cfg)
case subSnap.Used:
if snapOld {
@@ -265,6 +300,32 @@ 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 subNew.Used:
vc, err := cfg.pick(vcname)
if err != nil {
return err
}
if newSpecs {
return specsCLI(vc)
}
if newFrom == "" || newName == "" {
return errf("new needs both: gvm new --from <template> --name <machine>")
}
return deployCLI(vc, newFrom, newName, deployOpts{
host: newHost, datastore: newStore, powerOn: newOn,
spec: newSpec, ip: newIP, hostname: newHostname, st: cfg.site(),
}, yes)
case subHost.Used:
vc, err := cfg.pick(vcname)
if err != nil {
@@ -321,7 +382,7 @@ func run() error {
if err != nil {
return err
}
return browseVMs(targets, "", cfg.SSH)
return browseVMs(targets, "", cfg)
}
// showConfig prints what gvm made of ~/.gvmrc and the environment. Passwords
@@ -353,6 +414,7 @@ func showConfig(cfg Config) error {
PF("mail %s -> %s via %s:%d\n", orNone(cfg.MailFrom), orNone(cfg.MailTo), orNone(cfg.SMTPHost), cfg.smtpPort())
PF("telemetry %s\n", orNone(cfg.Telemetry))
PF("ssh %s\n", orNone(strings.Join(sshCommand(cfg.SSH, "<machine>"), " ")))
PF("new guest %s\n", cfg.site().describe())
PF("completion %s\n", inventoryAge())
PF("version %s\n", version)
return nil
+24 -4
View File
@@ -69,20 +69,40 @@ 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' ---
# --- what a machine made from a template is told about the network ---
# The site's answers, written once: every machine made from a template gets the
# same domain, the same resolvers, the same mask and the same gateway, and only
# its own name and address differ. Those two are typed:
#
# gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55
#
# Linux only — a Windows guest needs a Sysprep, which is a licence key and an
# administrator password, and that belongs in a customisation specification in
# the vCenter (gvm new --spec). `gvm config` shows what would be written and
# names whatever is still missing.
# domain = example.org
# dns = 10.0.0.1, 10.0.0.2
# netmask = 255.255.255.0
# gateway = 10.0.0.1
# timezone = Europe/Berlin
# --- 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:
#
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
# GVM_TELEMETRY, GVM_SSH
# GVM_TELEMETRY, GVM_SSH, GVM_DOMAIN, GVM_DNS, GVM_NETMASK, GVM_GATEWAY,
# GVM_TIMEZONE
# GVM_VCENTER_<NAME>_<FIELD>, e.g. GVM_VCENTER_V308_PASSWORD
#
# which is the way to keep a password out of a file altogether — under cron,
+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)
}
}
+538 -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,542 @@ func TestSimEventsWithoutAConnection(t *testing.T) {
}
}
// Making a machine from a template, against a server that answers: the source
// has to be a template, the placement is worked out rather than asked for, and
// the machine that comes out is a machine and not another template.
func TestSimDeployFromATemplate(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
// An ordinary machine is refused before anything is sent: gvm copies
// templates, and a clone of a running machine is a different operation with
// different consequences.
src, err := sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf: %v", err)
}
if src.template {
t.Fatal("the simulator handed back a template where a machine was asked for")
}
if _, err := startDeploy(s, src, deployTarget{}, "copy01", deployOpts{}); err == nil {
t.Error("a machine was copied as though it were a template")
}
// Make it one. A template has no resource pool of its own from here on,
// which is the whole reason the placement has to be worked out.
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot stop the machine to template it: %v", err)
}
if err := vm.MarkAsTemplate(s.ctx); err != nil {
t.Fatalf("cannot mark it as a template: %v", err)
}
src, err = sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf after templating: %v", err)
}
if !src.template {
t.Fatal("a machine marked as a template does not read as one")
}
target, err := targetFor(s, src, deployOpts{})
if err != nil {
t.Fatalf("targetFor: %v", err)
}
if target.where == "" {
t.Error("the placement has nothing to say where it would run")
}
if target.pool.Value == "" {
t.Error("no resource pool was worked out, so nothing could run")
}
// A name that is already taken is refused before the clone starts, rather
// than several seconds in by vCenter.
if _, err := startDeploy(s, src, target, "DC0_C0_RP0_VM1", deployOpts{}); err == nil {
t.Error("a name that is already in use was accepted")
}
task, err := startDeploy(s, src, target, "made-from-template", deployOpts{})
if err != nil {
t.Fatalf("startDeploy: %v", err)
}
if err := waitTask(s.ctx, task, cloneWait, "making it"); err != nil {
t.Fatalf("the clone did not finish: %v", err)
}
// It exists, it is a machine rather than a template, and it is where it was
// said it would be.
made, err := s.vm("made-from-template")
if err != nil {
t.Fatalf("the new machine cannot be found: %v", err)
}
var mvm mo.VirtualMachine
if err := made.Properties(s.ctx, made.Reference(), []string{"summary", "resourcePool"}, &mvm); err != nil {
t.Fatalf("cannot read what was made: %v", err)
}
if mvm.Summary.Config.Template {
t.Error("what came out is another template, not a machine")
}
if mvm.ResourcePool == nil || *mvm.ResourcePool != target.pool {
t.Errorf("it landed in %v, not in the pool it was given (%v)", mvm.ResourcePool, target.pool)
}
if mvm.Summary.Runtime.PowerState != types.VirtualMachinePowerStatePoweredOff {
t.Errorf("it was started, though nothing asked for that: %s", mvm.Summary.Runtime.PowerState)
}
}
// Telling the guest what it is, against a server that holds real customisation
// specifications: gvm writes the two facts that are about this one machine into
// the one the vCenter keeps, and leaves the site's answers alone.
func TestSimCustomisationFromAVCenterSpec(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
names, err := specNames(s)
if err != nil {
t.Fatalf("specNames: %v", err)
}
if !contains(names, "vcsim-linux-static") || !contains(names, "vcsim-windows-static") {
t.Fatalf("the simulator's specifications are not what this test is written against: %v", names)
}
// A Linux specification with a netmask in it: both facts go in, and the
// netmask and gateway that came with it stay.
spec, err := customizationFor(s, deployOpts{spec: "vcsim-linux-static", ip: "10.0.0.55"}, deploySource{name: "tpl"}, "web05")
if err != nil {
t.Fatalf("customizationFor: %v", err)
}
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname is %q", got)
}
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") || !strings.Contains(got, "255.255.255.0") {
t.Errorf("the address line is %q", got)
}
// The machine's own name is the hostname unless something else is said.
spec, err = customizationFor(s, deployOpts{spec: "vcsim-linux-static", hostname: "web05.fhi"}, deploySource{name: "tpl"}, "web05")
if err != nil {
t.Fatalf("customizationFor with a hostname: %v", err)
}
if got := hostNameOf(spec); got != "web05.fhi" {
t.Errorf("--hostname was ignored: %q", got)
}
// Windows has its computer name somewhere else, and it is found there.
spec, err = customizationFor(s, deployOpts{spec: "vcsim-windows-static"}, deploySource{name: "tpl"}, "WEB05")
if err != nil {
t.Fatalf("a Windows specification was refused: %v", err)
}
if got := hostNameOf(spec); got != "WEB05" {
t.Errorf("the Windows computer name is %q", got)
}
// The one whose adapter takes its address from DHCP has no netmask to give
// a fixed address, and says so rather than making one up.
if _, err := customizationFor(s, deployOpts{spec: "vcsim-linux", ip: "10.0.0.55"}, deploySource{name: "tpl"}, "web05"); err == nil {
t.Error("an address was written into a DHCP specification")
}
// Without an address that same specification is perfectly usable.
if _, err := customizationFor(s, deployOpts{spec: "vcsim-linux"}, deploySource{name: "tpl"}, "web05"); err != nil {
t.Errorf("a DHCP specification was refused with no address asked for: %v", err)
}
// A name that is not there says what is.
_, err = customizationFor(s, deployOpts{spec: "no-such-spec"}, deploySource{name: "tpl"}, "web05")
if err == nil {
t.Fatal("a specification that does not exist was accepted")
}
if !strings.Contains(err.Error(), "vcsim-linux-static") {
t.Errorf("it did not say what there is: %v", err)
}
// And the whole way through: a template, deployed with a customisation.
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot stop the machine: %v", err)
}
if err := vm.MarkAsTemplate(s.ctx); err != nil {
t.Fatalf("cannot mark it as a template: %v", err)
}
src, err := sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf: %v", err)
}
target, err := targetFor(s, src, deployOpts{})
if err != nil {
t.Fatalf("targetFor: %v", err)
}
opts := deployOpts{spec: "vcsim-linux-static", ip: "10.0.0.56"}
task, err := startDeploy(s, src, target, "customised01", opts)
if err != nil {
t.Fatalf("startDeploy: %v", err)
}
if err := waitTask(s.ctx, task, cloneWait, "making it"); err != nil {
t.Fatalf("the clone did not finish: %v", err)
}
if _, err := s.vm("customised01"); err != nil {
t.Errorf("the customised machine cannot be found: %v", err)
}
}
// 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) {
+25 -6
View File
@@ -310,15 +310,34 @@ func confirm(question string, yes bool) (bool, error) {
func confirmDestructive(vc VCenter, headline string, facts [][2]string, consequence string, yes bool) (bool, error) {
P()
PF("%s %s\n", Crb("⚠ "), Cwb(headline))
P()
all := append([][2]string{{"vCenter", vc.Name + " " + vc.URL}, {"datacenter", vc.Datacenter}}, facts...)
for _, f := range all {
PF(" %-14s %s\n", f[0], f[1])
}
P()
printFacts(vc, facts)
for _, l := range wrap(consequence, 72) {
PF(" %s\n", Cr(l))
}
P()
return confirm("continue?", yes)
}
// confirmFacts is that page without the warning, for something that makes a
// thing rather than destroying one. Deploying a machine is not a decision to be
// talked out of in red; it is one to be shown the placement of first, because
// "a new machine appeared somewhere on the estate" is not an outcome anybody
// should get from a keystroke.
func confirmFacts(vc VCenter, headline string, facts [][2]string, yes bool) (bool, error) {
P()
PF("%s\n", Cwb(headline))
printFacts(vc, facts)
return confirm("continue?", yes)
}
// printFacts is the block both of them show: the server first, because the same
// machine name exists on more than one, then whatever this particular question
// is about.
func printFacts(vc VCenter, facts [][2]string) {
P()
all := append([][2]string{{"vCenter", vc.Name + " " + vc.URL}, {"datacenter", vc.Datacenter}}, facts...)
for _, f := range all {
PF(" %-14s %s\n", f[0], f[1])
}
P()
}
+47 -29
View File
@@ -45,10 +45,10 @@ var sortOrders = []sortOrder{
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key is not a letter because every letter with a mnemonic in it is
// taken, and a hash is what a count is written with anyway. `--sort
// snapshots` on the command line needs no shell quoting.
{key: '#', name: "snapshots", natural: true,
// The key carries no mnemonic — every letter that does was taken — so it is
// simply one that is free and easy to reach. The name is what the command
// line takes: `--sort snapshots`, or `--sort snaps`.
{key: 'z', name: "snapshots", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
@@ -233,32 +233,47 @@ func (b *browser) sortLabel() string {
return arrow + " " + b.order().name
}
// sortLegend is the choices, on the two lines they are grouped into: what the
// sortLegend is the choices, laid out for a terminal of this width: one line
// where they fit on one, and otherwise the two groups they fall into — what the
// machine is doing and wants doing to it, then what it is made of and where it
// lives. Terse by necessity — it shares the bottom of the screen with nothing
// but itself — and the title says what the order is anyway, so nobody who
// misses it is lost.
// lives.
//
// The second line is indented under the first one's entries rather than under
// its label, so the two read as one list and not as a sentence continued.
func sortLegend() []string {
// One line is the better answer and the usual one; two is what a narrow
// terminal gets instead of a legend that runs off the right-hand edge, hiding
// the very choices it exists to offer. Decided here, at render time, so a
// window that is dragged wider gets the one line back — the same way the table
// itself is fitted (fitColumns) and the sheet is wrapped.
//
// Terse either way: it shares the bottom of the screen with nothing but itself,
// and the title says what the order is anyway, so nobody who misses it is lost.
func sortLegend(cols int) []string {
const label = "sort: "
lines := []string{label}
entries := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
entry := string(o.key) + "·" + shortName(o.name)
if o.legendBreak && len(lines) == 1 {
lines = append(lines, SR(" ", len(label)))
}
at := len(lines) - 1
if !strings.HasSuffix(lines[at], " ") {
lines[at] += " "
}
lines[at] += entry
entries = append(entries, string(o.key)+"·"+shortName(o.name))
}
// Reverse is not an order of its own and goes at the end.
entries = append(entries, string(sortReverse)+"·reverse")
if one := label + strings.Join(entries, " "); len([]rune(one)) <= cols {
return []string{one}
}
// Two, broken where the meaning breaks. The second line is indented under
// the first one's entries rather than under its label, so the two read as
// one list and not as a sentence continued.
at := len(sortOrders)
for i, o := range sortOrders {
if o.legendBreak {
at = i
break
}
}
return []string{
label + strings.Join(entries[:at], " "),
SR(" ", len(label)) + strings.Join(entries[at:], " "),
}
// Reverse is not an order of its own and goes at the end of the last line.
lines[len(lines)-1] += " " + string(sortReverse) + "·reverse"
return lines
}
// shortName is the legend's spelling: the title has room for the whole name, one
@@ -293,11 +308,14 @@ func shortName(name string) string {
// choice leaves the order alone: this is the one prompt in the list that is
// reached by accident, and doing nothing is the right answer to a stray key.
func (b *browser) sortPrompt() {
// A menu, not a warning: the colour is the ordinary value colour and there
// is no yes/no hint. Where it takes two lines the second one goes in place
// of the help line, which says nothing that applies while a menu is up.
lines := sortLegend()
b.prompt = &prompt{text: lines[0], col: colValue}
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
cols, _ := termSize()
lines := sortLegend(cols)
b.prompt = &prompt{text: lines[0], col: colPrompt}
if len(lines) > 1 {
b.prompt.more = strings.Join(lines[1:], " ")
}
+42 -5
View File
@@ -221,7 +221,7 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
// Every order is offered, and the legend fits a terminal of eighty.
func TestSortLegend(t *testing.T) {
lines := sortLegend()
lines := sortLegend(80)
legend := strings.Join(lines, "\n")
for _, o := range sortOrders {
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
@@ -247,6 +247,35 @@ func TestSortLegend(t *testing.T) {
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
}
// One line wherever one line will do — which is every terminal wide enough
// for it, and the usual case. A legend on two lines is what a narrow
// terminal gets instead of one that runs off the edge.
wide := sortLegend(200)
if len(wide) != 1 {
t.Errorf("a wide terminal gets the legend on %d lines:\n%s", len(wide), strings.Join(wide, "\n"))
}
if n := len([]rune(wide[0])); n > 200 {
t.Errorf("the one-line legend is %d columns wide", n)
}
// And every choice is on it, so nothing is reachable only when the terminal
// happens to be narrow.
for _, o := range sortOrders {
if !strings.Contains(wide[0], string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the one-line legend does not offer %q for %s: %s", string(o.key), o.name, wide[0])
}
}
if !strings.Contains(wide[0], string(sortReverse)+"·reverse") {
t.Errorf("the one-line legend does not offer the reverse: %s", wide[0])
}
// The width at which it gives up on one line is the width of the legend
// itself, and not a number written down somewhere.
if got := sortLegend(len([]rune(wide[0]))); len(got) != 1 {
t.Error("the legend broke in two at exactly its own width")
}
if got := sortLegend(len([]rune(wide[0])) - 1); len(got) != 2 {
t.Error("the legend stayed on one line one column too narrow for it")
}
// Distinct letters, or one of them would be unreachable.
seen := map[rune]bool{sortReverse: true}
for _, o := range sortOrders {
@@ -314,10 +343,10 @@ func TestSortBySnapshotCount(t *testing.T) {
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
rows[2].snaps = []snapEntry{aged("a", 1)}
if got := orderOf(t, rows, '#', true); got != "three one none" {
if got := orderOf(t, rows, 'z', true); got != "three one none" {
t.Errorf("most snapshots first gave %q", got)
}
if got := orderOf(t, rows, '#', false); got != "none one three" {
if got := orderOf(t, rows, 'z', false); got != "none one three" {
t.Errorf("fewest first gave %q", got)
}
}
@@ -356,14 +385,22 @@ func TestSortByIssues(t *testing.T) {
}
// orderOf sorts the rows given by one order and returns the names in order.
//
// A key that is not an order at all is fatal here rather than left to sort by
// name: the browser's default order is index nought, so a test naming a letter
// that has been renamed would go on passing while checking the name order.
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
t.Helper()
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
found := false
for i, o := range sortOrders {
if o.key == key {
b.sortBy = i
b.sortBy, found = i, true
}
}
if !found {
t.Fatalf("%q is not one of the sort orders", string(key))
}
b.applySort()
var names []string
@@ -384,7 +421,7 @@ func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
rows[1].snaps = []snapEntry{aged("march", 200)}
if got := orderOf(t, rows, '#', true); got != "many-new one-ancient" {
if got := orderOf(t, rows, 'z', true); got != "many-new one-ancient" {
t.Errorf("by count: %q", got)
}
if got := orderOf(t, rows, 'o', true); got != "one-ancient many-new" {
+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.1
1.3.0