23 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 b7252d7a6d [mike@mwxm4]
The name has to be free, the address is shown — and the helper is read
properly, which is what the first version got wrong.

Measured rather than assumed this time: `dns -s` on a name that is not there
prints "ERROR: host '...' not found" and exits 0. So the helper says what is
wrong in its output, not in its status, and gvm now reads it that way. The
first version believed the status, which took every refusal for an agreement
and — the other way about — made a free name look like an answer nobody could
parse. An appliance that cannot be reached is an error and not a free name:
concluding "nobody has this name" from a server that is down is the last thing
to do before asking it for an address.

A name the appliance already knows is refused before anything is added to it.
What `dns -a` would do with one is either refuse in worse words, or hang a
second address on somebody else's host record:

    web05 already exists (web05.fhi.mpg.de at 141.14.140.182) — give the
    machine another name, or free that one with 'dns -d web05'

The address is said where it stays. In the confirmation, as before, and now
also in the line left on the screen afterwards and in what `gvm new` prints,
because it is a number somebody writes down:

    web05 is being made at 141.14.140.182 — ubuntu-tpl shows how far along it is

The stand-in the tests run against has the real tool's two habits now: errors
in the output, beginning with ERROR, and exit 0 while saying so. The old
stand-in answered in exit statuses, which is exactly why it let the broken
version pass. Checked the other way as well — with the status-believing code
put back, the tests fail on "answered something that is not a host record".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:42:14 +02:00
Michael WesemannandClaude Opus 5 da56a5d048 [mike@mwxm4]
An address fetched rather than looked up: `gvm new ... --ip auto`, and "auto"
typed at the same question in the list.

dns.go shells out to the site's Infoblox helper — found on the path as "dns",
or named as dnstool in ~/.gvmrc. Everything that tool knows stays its own: the
appliance, the credentials, the default domain. A second copy of any of it
inside gvm would be a second thing to keep right.

It is only offered where such a tool is there. `gvm config` now says whether
one was found, because "why was I not offered that" is the question that
follows an option quietly not being there — the same lesson as the
customisation picker last week.

Three things are deliberate, and each is a rule about acting on something
outside gvm:

* The record is read back rather than taken from the sentence the helper prints
  on its way past. "OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'"
  is written for a person and gets reworded between versions; the record is the
  appliance's own answer, and where the two disagree the record is what the
  machine will actually have.
* It is fetched before the confirmation, so the question says the address
  rather than a promise of one.
* And given back where it is not used. A deployment abandoned at that question,
  or one the vCenter then refuses, releases it rather than leaving a record for
  a machine that was never made. That is what deployStep carries — the note,
  the record and the way to give it back travelling together, because a
  confirmation with eight arguments is one nobody can call correctly.

The name asked for is the machine's own short name — the helper puts the record
in the site's default domain itself — which is also the hostname the guest gets,
so the two cannot disagree.

Every call passes -y: the helper asks before it changes anything when it has a
terminal, and gvm has already asked.

Tested against a stand-in written by the test, never the real tool: `dns -a`
takes an address out of the institute's Infoblox and `dns -d` gives one back,
and a suite that did either would be editing the network every time it ran.
What is checked is everything around the call — what is asked for, what is made
of the answer, and what happens when the answer is no.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:46:13 +02:00
Michael WesemannandClaude Opus 5 2da48d9b25 [mike@mwxm4]
"address for web05 (empty = as  says):" — a sentence with a hole in it.

The hole is where the name of a specification nobody had chosen would have
gone, and it was there because the road was being worked out from the other
fields rather than carried. The interactive half settles which road before it
asks for an address, so at that moment spec, ip and hostname are all still
empty — which read as "no customisation at all", took the branch written for a
vCenter specification, and printed its name, which was "".

So the road is a decision now and not an inference: customNone, customSite,
customSpec, set where it is made. The command line reads it off the flags,
which is where the decision genuinely is there; the list sets it in the picker,
before anything has been typed.

The question is also plainer than it was. "(empty = as linux-static says)"
reads as a sentence with a word missing even when the name is in it:

    address for web05, or empty for DHCP:
    address for web05, or empty to leave it to linux-static:

The test drives the real question on both roads and looks for the hole. It was
checked against the old code first, which produced Mike's line exactly:
"address for web05 (empty = as  says):".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 17:02:06 +02:00
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
Michael Wesemann b902059402 [mike@mwxm4] 2026-09-08 17:00:17 +02:00
Michael Wesemann dda9dc1e74 [mike@mwxm4] 2026-09-08 15:50:23 +02:00
Michael Wesemann d3fa1790a1 [mike@mwxm4] 2026-09-07 17:01:28 +02:00
Michael Wesemann 9b0d218174 [mike@mwxm4] 2026-09-07 11:37:41 +02:00
49 changed files with 13014 additions and 257 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/
tmp/
gvm
.gvmrc
.claude/
+858 -37
View File
File diff suppressed because it is too large Load Diff
+570 -43
View File
@@ -51,8 +51,52 @@ func (m menuItem) isSeparator() bool { return m.key == 0 && m.label == "" }
// actions: what it is, and whether it is running. They are the facts the choice
// underneath depends on, and having them on the same screen means not having to
// remember them from the sheet one keystroke ago.
//
// In sheet order, which is the order they are shown in.
var menuFacts = []string{"state", "guest", "hostname", "address"}
// factOrder is which of them to keep when there is not room for all four —
// most worth keeping first, the same idea as the table's expendable columns.
//
// The state comes first because every choice below depends on it. The address
// and the hostname come next: they say which machine is about to be powered off,
// which is worth having in front of one. The guest's operating system decides
// nothing here.
var factOrder = []string{"state", "address", "hostname", "guest"}
// keepFacts is as many of the facts as fit, still in sheet order.
//
// Giving them up one at a time rather than all at once is what a short terminal
// gets out of this: below twenty rows the menu and all four facts no longer fit
// on the screen together, and none of them is a worse answer than three.
func keepFacts(info []sheetLine, room int) []sheetLine {
if room >= len(info) {
return info
}
if room <= 0 {
return nil
}
keep := map[string]bool{}
for _, label := range factOrder {
if len(keep) >= room {
break
}
for _, l := range info {
if l.label == label {
keep[label] = true
break
}
}
}
out := make([]sheetLine, 0, len(keep))
for _, l := range info {
if keep[l.label] {
out = append(out, l)
}
}
return out
}
// sheetPick takes named lines out of a sheet, in the order asked for, skipping
// the ones this machine has nothing to say about. The lines come from vmDetail
// rather than being formatted again here, so the menu and the sheet cannot end up
@@ -84,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 {
@@ -103,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"
@@ -140,6 +235,12 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
{key: 'D', label: "remove ALL snapshots", why: noSnaps, hint: noSnapsHint,
run: func(b *browser, r vmRow) { b.removeAll(r, len(snaps)) }},
separator(),
// What the machine is, between what it has been and what it is doing.
// Both entries end in "..." for the same reason the snapshot ones do:
// they ask for something before anything happens.
sizeItem('c', "change the vCPU count ...", r, sz, sizeCPUs),
sizeItem('m', "change the memory ...", r, sz, sizeMemory),
separator(),
pwr('o', "power on", opPowerOn),
pwr('s', "shut down the guest", opShutdownGuest),
pwr('b', "reboot the guest", opRebootGuest),
@@ -149,6 +250,16 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
}
}
// sizeItem is one of the two hardware entries. What greys it out comes from
// sizeObjection asked its weaker question — not whether some particular number
// can be set, but whether any can right now — because the number has not been
// typed yet when the menu is drawn.
func sizeItem(key rune, label string, r vmRow, sz sizing, k sizeKind) menuItem {
short, long := sizeObjection(r, sz, k, 0)
return menuItem{key: key, label: label, why: long, hint: short,
run: func(b *browser, r vmRow) { b.resize(r, k) }}
}
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
// do the same for anyone who would rather read than remember.
func (b *browser) menuKey(k key) {
@@ -204,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 (
@@ -215,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 }
@@ -241,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)
}
}
@@ -378,6 +516,294 @@ 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{},
deployStep{note: "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{}, deployStep{note: 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{}, deployStep{})
case "-":
b.deployAddress(r, src, target, name,
deployOpts{how: customSite, st: b.site, dns: b.dns})
default:
b.deployAddress(r, src, target, name,
deployOpts{how: customSpec, spec: kinds[i], st: b.site, dns: b.dns})
}
})
}
// 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 is an answer, and it means two different things: with gvm's own
// specification it leaves the adapter on DHCP, and with one of the
// vCenter's it leaves whatever that one says. The question says which, in
// words rather than in the shorthand it used to use — "(empty = as
// linux-static says)" reads as a sentence with a word missing even when the
// name is there.
//
// And where the site has a tool that hands addresses out, the word for
// that is offered too — an address one does not have to look up first is
// the whole point of it being there.
leave := "empty for DHCP"
if opts.how == customSpec {
leave = "empty to leave it to " + opts.spec
}
ask := SF("address for %s, %s: ", name, leave)
if opts.dns != "" {
ask = SF("address for %s, %q for one from dns, %s: ", name, autoIP, leave)
}
ip, ok := b.input(ask)
if !ok {
b.setStatus(colDim, "nothing done")
return
}
opts.ip = strings.TrimSpace(ip)
// Fetched here rather than at the confirmation, so that the question can
// say the address the machine will have. give hands it back where the
// deployment does not happen after all.
opts, rec, give, err := fetchAddress(opts, name)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
b.deployAsk(r, src, t, name, opts, deployStep{rec: rec, give: give})
}
// 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, step deployStep) {
// 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 step.note != "" {
parts = append(parts, step.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
if step.rec.Name != "" {
with += " (" + step.rec.Name + ", from dns)"
}
}
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) {
step.giveBack() // nothing was made, so nothing keeps a fetched address
b.setStatus(colDim, "nothing done")
return
}
b.working(SF("starting %s ...", name))
if _, err := startDeploy(r.sess, src, t, name, opts); err != nil {
step.giveBack()
b.setStatus(colErr, err.Error())
return
}
// The address is in the line that survives, not only in the question that
// was answered a moment ago: it is the one thing here somebody writes down.
at := ""
if opts.ip != "" && opts.customising() {
at = " at " + opts.ip
}
b.done(SF("%s is being made%s — %s shows how far along it is", name, at, 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.
@@ -429,12 +855,22 @@ func (b *browser) refreshRow() error {
if r.sess == nil {
return errf("no connection to %s", r.vc.Name)
}
// The same properties the sweep reads, so a re-read row is the same kind of
// row as its neighbours: one that lost its snapshots or its task on being
// refreshed would quietly disagree with the rest of the table.
var fresh mo.VirtualMachine
vm := object.NewVirtualMachine(r.sess.client.Client, r.ref)
if err := vm.Properties(r.sess.ctx, r.ref, []string{"summary", "guest"}, &fresh); err != nil {
if err := vm.Properties(r.sess.ctx, r.ref, sweepProps, &fresh); err != nil {
return err
}
r.vm = fresh
r.snaps = snapshotsIn(fresh.Snapshot)
r.task = nil
if busy := runningTasks(r.sess, []mo.VirtualMachine{fresh}); len(busy) > 0 {
if t, ok := busy[r.ref]; ok {
r.task = &t
}
}
return nil
}
@@ -490,11 +926,12 @@ func (b *browser) renderMenu() {
// The actions are what the menu is for; the facts above them are a courtesy.
// On a terminal too short for both, the facts are what goes — a menu whose
// entries have scrolled off the top is worse than one without a header.
info := b.menuInfo
if 3+len(info)+len(b.menu) > rows-2 {
info = nil
}
// entries have scrolled off the top is worse than one without a header — and
// they go one at a time, least useful first (keepFacts).
//
// The three is the title, the blank line under it, and the blank line under
// the facts; the two at the end is the status line and the help line.
info := keepFacts(b.menuInfo, rows-2-3-len(b.menu))
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
@@ -547,7 +984,8 @@ func (b *browser) renderMenu() {
if b.status != "" {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim, "lowercase asks the guest, uppercase acts at the hypervisor"})
segLine(&sb, cols, seg{colDim,
"of the power pairs, lowercase asks the guest and uppercase the hypervisor"})
}
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
b.write(sb.String())
@@ -557,11 +995,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
@@ -569,21 +1002,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")
@@ -702,3 +1135,97 @@ func wrap(s string, width int) []string {
}
return out
}
// ------------------------------------------------------- the sheet's own keys
//
// The four below change nothing, on the machine or on the vCenter: they read its
// history, copy its address, open it somewhere else. That is why they are on
// letters of the sheet itself rather than in the action menu, which is for the
// things one has to be sure about before pressing.
// showEvents puts the machine's recent history at the foot of its sheet and
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
// machine is one call, and this is another.
func (b *browser) showEvents(r vmRow) {
b.working(SF("reading the events of %s ...", r.name))
lines, err := eventsOf(r)
if err != nil {
b.setStatus(colErr, err.Error())
return
}
b.events, b.eventsOf = lines, r.id()
if len(lines) == 0 {
b.setStatus(colDim, "vCenter has no recent events for "+r.name)
} else {
b.setStatus(colInfo, SF("%s of %s", plural(len(lines), "event"), r.name))
}
b.openDetail() // rebuilt, so the sheet carries them
b.scrollToSection("events")
}
// hasAddress reports whether there is somewhere to connect to, and says so where
// the key was pressed when there is not. A guest that is not talking reports no
// address, and ssh or copy doing nothing at all would read as gvm having hung —
// the greyed-out menu entries these two replaced said as much in their own line.
func (b *browser) hasAddress(r vmRow) bool {
if r.sshTarget() != "" {
return true
}
b.setStatus(colWarn, SF("%s has no address or hostname — its guest is not reporting one", r.name))
return false
}
// sshTo logs in to the guest. The terminal goes back to what it was for as long
// as that lasts (guest.go), and the list is redrawn afterwards.
func (b *browser) sshTo(r vmRow) {
target := r.sshTarget()
argv := sshCommand(b.ssh, target)
err := b.runInTerminal(argv)
switch {
case err == nil:
b.setStatus(colDim, "back from "+target)
case interrupted(err):
// Ctrl-C during a login is somebody changing their mind, not a fault:
// gvm now survives it (holdTerminalSignals) and says so in the colour
// of an ordinary remark.
b.setStatus(colDim, "the login to "+target+" was interrupted")
default:
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
}
}
// copyAddress puts what `h` would connect to where the next paste will find it.
//
// What it says is longer than "copied web01.example" was, and deliberately: a
// clipboard is invisible, so the line has to name what went into it — the
// hostname or the address, since the sheet shows both — and which clipboard it
// is. Where the escape sequence was the only route it also says so, because that
// is the case where it may quietly not have arrived.
func (b *browser) copyAddress(r vmRow) {
target, kind := r.sshTargetIs()
if where := b.toClipboard(target); where != "" {
b.setStatus(colInfo, SF("copied its %s %s to the clipboard (%s)", kind, target, where))
return
}
b.setStatus(colWarn, SF("sent its %s %s to the terminal's own clipboard — it has to allow that (OSC 52)", kind, target))
}
// openVSphere opens the machine's page in the vSphere client, and says the URL
// either way: a workstation with no browser to hand off to still gets the one
// thing that was wanted, and so does anyone running gvm over ssh.
func (b *browser) openVSphere(r vmRow) {
url := vsphereURL(r)
if url == "" {
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
return
}
if err := openURL(url); err != nil {
b.toClipboard(url) // the same two routes as `y`
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
return
}
b.setStatus(colInfo, "opened "+url)
}
+167 -9
View File
@@ -3,6 +3,7 @@ package main
import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
@@ -136,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)
@@ -146,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))
@@ -158,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")
}
@@ -169,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))
@@ -394,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
}
@@ -597,19 +598,23 @@ func TestMenuFactsSkipWhatIsUnknown(t *testing.T) {
func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
t.Setenv("COLUMNS", "100")
// The facts are given up one at a time, least useful first, so what is
// checked here is the one that goes third: with room for three facts the
// hostname is still there, with room for two it is not.
for _, c := range []struct {
rows string
wantFacts bool
}{
{"30", true}, // room for both
{"20", true}, // exactly enough: 1 title + 1 + 4 facts + 1 + 11 choices + status + help
{"18", false}, // two short, so the facts go
{"30", true}, // room for both, all four facts
{"22", true}, // room for three of them, the hostname among them
{"21", false}, // room for two: the state and the address
{"16", false}, // no room at all, so the choices have the screen
} {
t.Setenv("LINES", c.rows)
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
b.menu = b.buildMenu(r, nil)
b.menu = b.buildMenu(r, nil, testSizing())
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
frame := renderToPipe(t, b, b.renderMenu)
@@ -644,3 +649,156 @@ func renderToPipe(t *testing.T, b *browser, draw func()) string {
}
return string(out)
}
// A control sequence has to be swallowed whole. Reading a fixed number of bytes
// left the rest of a longer one in the stream, where the next read took it for
// typing: Ctrl-Up put "5A" into the filter and F5 put a tilde in it, having first
// jumped to the top of the list.
func TestEscapeSequencesAreConsumedWhole(t *testing.T) {
for _, c := range []struct {
send string
want specialKey
note string
}{
{"\x1b[A", keyUp, "up"},
{"\x1b[B", keyDown, "down"},
{"\x1b[C", keyRight, "right"},
{"\x1b[D", keyLeft, "left"},
{"\x1b[H", keyHome, "home"},
{"\x1b[F", keyEnd, "end"},
{"\x1b[Z", keyShiftTab, "shift-tab"},
{"\x1b[3~", keyDelete, "delete"},
{"\x1b[5~", keyPgUp, "page up"},
{"\x1b[6~", keyPgDn, "page down"},
// Modified arrows and function keys: not answered, but not leaked either.
{"\x1b[1;5A", keyNone, "ctrl-up"},
{"\x1b[1;2D", keyNone, "shift-left"},
{"\x1b[15~", keyNone, "F5"},
{"\x1b[200~", keyNone, "a bracketed paste opening"},
{"\x1b[<0;10;20M", keyNone, "a mouse report"},
} {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
kr := newKeyReader(r)
w.WriteString(c.send + "x") // an "x" behind it, to see what was left over
if got := kr.next(); got.special != c.want {
t.Errorf("%s (%q): decoded as %v, want %v", c.note, c.send, got.special, c.want)
}
// Whatever the sequence was, the very next key must be the x — nothing of
// the sequence may arrive as text.
next := kr.next()
if next.special != keyRune || next.r != 'x' {
t.Errorf("%s (%q): the next key is %v/%q, want the x — part of the sequence leaked",
c.note, c.send, next.special, next.r)
}
w.Close()
r.Close()
}
}
// A lone Esc is still a lone Esc: it is how every dangerous question is abandoned.
func TestLoneEscapeIsStillEscape(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
kr := newKeyReader(r)
w.WriteString("\x1b")
if got := kr.next(); got.special != keyEsc {
t.Errorf("a lone Esc decoded as %v", got.special)
}
}
// Every vCenter task gvm waits for has to be waited for with a bound. This is a
// property of the source rather than of anything a test can provoke — a task that
// hangs is exactly what no simulator will do — so the source is what is checked.
//
// Taking a snapshot was the one that got away: it waited on the session's own
// context, which has no deadline, and would have frozen the interactive list
// mid-draw with no key being read.
func TestEveryTaskWaitIsBounded(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
found := 0
for _, name := range files {
if strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(string(src), "\n") {
code := strings.TrimSpace(line)
if !strings.Contains(code, ".Wait(") || strings.HasPrefix(code, "//") {
continue
}
if strings.Contains(code, "wg.Wait()") {
continue // a WaitGroup, not a vCenter task
}
if strings.Contains(code, "cmd.Wait()") {
continue // an exec.Cmd — a browser being handed a URL (guest.go)
}
found++
// The one place a task may be waited on is inside waitTask, which
// gives it a deadline of its own.
if name != "power.go" || !strings.Contains(code, "task.Wait(wctx)") {
t.Errorf("%s:%d waits on a task outside waitTask: %s", name, i+1, code)
}
}
}
if found == 0 {
t.Error("no task wait found at all — this check has stopped checking anything")
}
}
// A menu is allowed to be built from less than it wanted, but never to look as
// certain as one that was. openMenu clears the status line as its last act —
// the menu is a fresh screen — and a warning set on the way there was being
// wiped before a single frame was drawn.
func TestTheMenuKeepsWhatItHadToWarnAbout(t *testing.T) {
// A row with no session: the configuration cannot be read, which is the
// warning this is about.
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
// openMenu leaves early without a session at all, so the warning is checked
// where it is raised: a sizing that could not be read must both grey the
// entries out and leave something on the status line.
if _, err := sizingOf(nil, r.ref); err == nil {
t.Error("a machine with no connection read its configuration anyway")
}
menu := b.buildMenu(r, nil, sizing{})
for _, k := range []rune{'c', 'm'} {
for _, m := range menu {
if m.key != k {
continue
}
if m.available() {
t.Errorf("%q is offered on a machine whose configuration is unknown", string(k))
}
if m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
}
}
// testSizing is a plausible machine's hardware for the menu tests: four vCPUs
// in one socket, eight gigabytes, and none of the three hot-plug settings — the
// way most machines are actually built, and the case where the two hardware
// entries are only offered on a machine that is switched off.
func testSizing() sizing {
return sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
}
+562 -59
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 ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll ^a actions ^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
@@ -89,10 +98,35 @@ type vmRow struct {
name string
host string // ESXi host, short
vm mo.VirtualMachine
// What the sweep learned about the machine besides its summary. Both are
// carried on the row rather than asked for when they are shown: a column
// that has to make a call to fill itself in is a column that makes the list
// 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
// each hold a "web01", and following the selection by name alone moved the cursor
// to the other server's machine whenever sorting or filtering brought that one up
// first. A reference is unique within its server, so the two together are unique.
func (r vmRow) id() string { return r.vc.Name + "/" + r.ref.Value }
func (r vmRow) power() types.VirtualMachinePowerState { return r.vm.Summary.Runtime.PowerState }
// 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:
@@ -194,11 +228,95 @@ func loadColor(pct float64, known bool) string {
return colSize
}
// The ages at which a snapshot stops being today's work. A snapshot taken for
// this afternoon's patch run should be gone this evening; a week is where it
// stops being that and starts being something nobody remembers taking. Thirty
// days is also what `snap --old` reports on by default, so a red count in the
// table means exactly "this machine is in that report".
const (
snapStaleDays = 7
snapOldDays = 30
)
// snapCount is how many snapshots the sweep found on the machine.
func (r vmRow) snapCount() int { return len(r.snaps) }
// oldest is the snapshot that has been there longest, which is the one that
// decides whether the machine needs attention. false when it has none, or when
// none of them came back with a date.
func (r vmRow) oldest() (snapEntry, bool) {
var best snapEntry
found := false
for _, e := range r.snaps {
if e.when.IsZero() {
continue
}
if !found || e.when.Before(best.when) {
best, found = e, true
}
}
return best, found
}
// snapAge is the age of that snapshot in days, in the shape the sort orders and
// the colour thresholds want. A machine without snapshots has no age rather than
// an age of zero — nothing to clean up is not the same as cleaned up a moment ago.
func (r vmRow) snapAge() (float64, bool) {
e, ok := r.oldest()
if !ok {
return 0, false
}
d, ok := e.age()
if !ok {
return 0, false
}
return d.Hours() / 24, true
}
// snapCell is the count as the table shows it, and snapColor ages it: past a
// week the number turns yellow, past a month red. The count is what the column
// says and the colour is how urgent it is, which is why the sheet spells the
// date out — a colour cannot be read in a pipe.
func (r vmRow) snapCell() string {
if r.snapCount() == 0 {
return "-"
}
return Itoa(r.snapCount())
}
func (r vmRow) snapColor() string {
if r.snapCount() == 0 {
return colOff
}
days, ok := r.snapAge()
switch {
case !ok:
return colChosen // there are snapshots, but no date to judge them by
case days >= snapOldDays:
return colFull
case days >= snapStaleDays:
return colBusy
}
return colSize
}
// taskCell is what is being done to the machine, or nothing at all.
func (r vmRow) taskCell() string {
if r.task == nil {
return ""
}
return r.task.cell()
}
// haystack is what the filter matches against: everything on the line, so typing
// an address or a host name narrows the list just as well as a name does.
// an address or a host name narrows the list just as well as a name does — and
// nothing that is not, because the filter's hit is picked out in the row and a
// match on something invisible would leave a row in the list with no reason
// showing anywhere on it.
func (r vmRow) haystack() string {
return strings.ToLower(strings.Join([]string{
r.name, r.vc.Name, r.ip(), r.host, r.guestOS(), r.powerShort(),
r.taskCell(),
}, " "))
}
@@ -215,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
@@ -232,28 +355,112 @@ 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: 8,
{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.
//
// It is given up early — before the address, which is the column the widths
// above were tuned around — because it is the one column here that has
// somewhere else to be said. A snapshot old enough to matter is in ^w, in
// `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: 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: 3},
{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: 4,
{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: 7,
{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: 5},
{header: "MEM%", width: 4, expendable: 6,
{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),
expendable: 1},
}
// taskColumn and whyColumn are the two columns that are not always there.
//
// Every other column holds a property of a machine and can say "-" when the
// machine has none. These two hold an exception, and an exception has no "-":
// a column that is blank down two hundred rows is thirteen characters of width
// spent on nothing. So they exist only while there is something in them — the
// task column when anything at all is being done on the cluster, the reason
// column only in the listing that is made of reasons (^i, --issues).
//
// A column appearing because somebody started a clone is not the layout
// 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: 8,
cell: vmRow.taskCell, color: fixed(colBusy)}
// The reason outranks every other column but the machine's name and its power
// state: in a listing whose every row is there because of it, giving it up
// first — which is what inheriting the guest operating system's rank would have
// done — leaves a list of machines with no reason showing on any of them. It is
// 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: 12,
cell: vmRow.issueCell, color: vmRow.issueColor}
// The figures the issues listing leaves out. A machine is in that list because
// something is wrong with it, and how hard its processors happen to be working
// at this second says nothing about any of the reasons — it is four columns of
// arithmetic between the machine's name and the answer to the question that was
// asked. They are one keystroke away in the ordinary list, and on the machine's
// own sheet.
var issueHides = []string{"CPU", "CPU%", "MEM", "MEM%"}
// listColumns is the table for this particular listing: the standing columns,
// with the two conditional ones put in where they belong. The task goes next to
// the power state, because both answer "what is this machine doing"; the reason
// takes the guest operating system's place, which is the least read column there
// is and the only one wide enough to hold a sentence.
func listColumns(rows []vmRow, why bool) []browseColumn {
busy := false
for _, r := range rows {
if r.task != nil {
busy = true
break
}
}
trend := anyTrend(rows)
out := make([]browseColumn, 0, len(browseColumns)+2)
for _, c := range browseColumns {
if why {
if c.header == "GUEST OS" {
out = append(out, whyColumn)
continue
}
if contains(issueHides, c.header) {
continue
}
}
out = append(out, c)
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
}
// fitColumns decides which columns a terminal of this width shows: give up the
// least useful until what is left fits, then hand the width left over to the ones
// allowed to grow. The columns keep the order they are declared in; what changes
@@ -266,21 +473,26 @@ var browseColumns = []browseColumn{
// improvement — makes a narrower terminal show *more* columns than a wider one,
// and a column that appears as the window shrinks is worse than a column that is
// simply gone. Where a column deserves to survive longer, its rank says so.
func fitColumns(width int) []browseColumn {
shown := make([]bool, len(browseColumns))
func fitColumns(width int) []browseColumn { return fitColumnsOf(browseColumns, width) }
// fitColumnsOf is that, for a table that is not the standing one: the listing
// may have a task column in it, or a reason column instead of the guest's
// operating system (see listColumns).
func fitColumnsOf(cols []browseColumn, width int) []browseColumn {
shown := make([]bool, len(cols))
for i := range shown {
shown[i] = true
}
for shownWidth(shown) > width {
i := leastUseful(shown)
for shownWidth(cols, shown) > width {
i := leastUseful(cols, shown)
if i < 0 {
break // nothing left that may be given up
}
shown[i] = false
}
chosen := make([]browseColumn, 0, len(browseColumns))
for i, c := range browseColumns {
chosen := make([]browseColumn, 0, len(cols))
for i, c := range cols {
if shown[i] {
chosen = append(chosen, c)
}
@@ -289,7 +501,7 @@ func fitColumns(width int) []browseColumn {
// A terminal too narrow even for what may not be given up: the name, cut to
// whatever there is. Better one column of truth than a row that wraps.
if tableWidth(chosen) > width {
first := browseColumns[0]
first := cols[0]
first.width = max(width, 1)
return []browseColumn{first}
}
@@ -325,12 +537,12 @@ func tableWidth(cs []browseColumn) int {
return w
}
func shownWidth(shown []bool) int {
func shownWidth(cols []browseColumn, shown []bool) int {
n, w := 0, 0
for i, on := range shown {
if on {
n++
w += browseColumns[i].width
w += cols[i].width
}
}
if n == 0 {
@@ -341,10 +553,10 @@ func shownWidth(shown []bool) int {
// leastUseful is the column to give up next, or -1 when every one that is left
// may not be.
func leastUseful(shown []bool) int {
func leastUseful(cols []browseColumn, shown []bool) int {
at, worst := -1, 0
for i, on := range shown {
e := browseColumns[i].expendable
e := cols[i].expendable
if !on || e == 0 {
continue
}
@@ -375,6 +587,22 @@ type sweep struct {
lost []string // their names alone, for the title
}
// sweepProps is what one pass reads off every machine. It is one property
// collector call per vCenter whatever is in this list, so the question for each
// entry is not "does it cost a round trip" but "is it small enough to carry for
// every machine in the inventory".
//
// summary, guest the table and most of the sheet
// snapshot the snapshot column, the age report and --issues; a
// tree of names and dates, and empty for most machines
// triggeredAlarmState what vCenter itself is complaining about
// recentTask what is being done to the machine (tasks.go)
//
// layoutEx is deliberately not here: it lists every file of every machine, which
// is a great deal of wire for a figure only the snapshot report wants, and that
// report asks for it separately, for the machines that actually have snapshots.
var sweepProps = []string{"summary", "guest", "snapshot", "triggeredAlarmState", "recentTask"}
// gatherVMs asks every server at once and returns what came back. A server that
// does not answer costs a line in the report, not the listing.
func gatherVMs(targets []VCenter) (sweep, error) {
@@ -419,6 +647,11 @@ func gatherVMs(targets []VCenter) (sweep, error) {
}
return s.rows[a].vc.Name < s.rows[b].vc.Name
})
// Every sweep leaves the names behind for the shell to complete against
// (complete.go). Nothing reads them back but the completion, and it costs
// one small file write per run of gvm.
saveInventory(s.answered, s.rows)
return s, nil
}
@@ -430,19 +663,28 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
if err != nil {
return nil, nil, err
}
rows, err := sweepOne(vc, s)
return rows, s, err
}
vms, err := s.vms("summary", "guest")
// 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 {
hostName[h.Reference()] = shortHost(h.Name)
}
busy := runningTasks(s, vms)
s.alarms = s.alarmNames(triggeredAlarms(vms))
rows := make([]vmRow, 0, len(vms))
for _, vm := range vms {
@@ -456,9 +698,31 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
host = n
}
}
rows = append(rows, vmRow{vc: vc, sess: s, ref: vm.Reference(), name: name, host: host, vm: vm})
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: name, host: host, vm: vm,
snaps: snapshotsIn(vm.Snapshot)}
if t, ok := busy[vm.Reference()]; ok {
r.task = &t
}
rows = append(rows, r)
}
return rows, s, nil
return rows, nil
}
// triggeredAlarms is every alarm definition this sweep saw complaining, once
// each. Nothing is asked of the server when nothing is alarming, which is the
// ordinary case.
func triggeredAlarms(vms []mo.VirtualMachine) []types.ManagedObjectReference {
seen := map[types.ManagedObjectReference]bool{}
var out []types.ManagedObjectReference
for _, vm := range vms {
for _, a := range vm.TriggeredAlarmState {
if !seen[a.Alarm] {
seen[a.Alarm] = true
out = append(out, a.Alarm)
}
}
}
return out
}
func closeSessions(sessions []*session) {
@@ -476,8 +740,12 @@ type browser struct {
view []int // indexes into rows: what the filter left
filter string
sel int // index into view
scroll int
// issuesOnly narrows the list to the machines with something wrong with them
// (issues.go). It is a second filter rather than a mode of its own: the typed
// filter still applies inside it, so "^w web" is the broken web servers.
issuesOnly bool
sel int // index into view
scroll int
sortBy int // which of sortOrders the rows are in (sort.go)
sortDesc bool // that order reversed
@@ -485,6 +753,25 @@ type browser struct {
answered []string // the vCenters this list actually holds
lost []string // and the ones it does not, because they did not answer
// The events of one machine, once somebody has asked for them (actions.go).
// They are kept against the machine they belong to, so arrowing on to the
// next machine does not show it somebody else's history.
events []eventLine
eventsOf string // the id of the machine they are of
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
dns string // the site's address helper, or "" where this machine has none
// Live mode (live.go): the list re-reading itself on a timer rather than on
// a keystroke. hist is what the trend column is drawn from, kept here
// because every sweep throws the rows away and builds new ones.
live bool
liveNext time.Time
liveGap time.Duration // what the pending tick was scheduled with, for the title
hist map[string][]float64
saidLive string // the last changed line, so a quiet tick clears only its own
detail []sheetLine // non-nil while a machine's sheet is on screen
dtitle string
dvc string // the vCenter, in the title, in the colour its column has
@@ -498,10 +785,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
@@ -514,7 +803,12 @@ type browser struct {
}
// browseVMs is the command: gather, then hand the terminal over to the loop.
func browseVMs(targets []VCenter, filter 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
@@ -523,7 +817,8 @@ func browseVMs(targets []VCenter, filter string) error {
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
}
b := &browser{targets: targets, filter: filter}
b := &browser{targets: targets, filter: filter, ssh: cfg.SSH,
site: cfg.site(), dns: dnsTool(cfg.DNSTool)}
defer func() { closeSessions(b.sessions) }()
PF("asking %s ...\n", vcNames(targets))
@@ -592,7 +887,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
}
@@ -604,6 +911,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:
@@ -625,6 +934,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:
@@ -661,6 +972,8 @@ func (b *browser) listKey(k key) bool {
b.openDetail()
case keyCtrlO:
b.sortPrompt()
case keyCtrlW:
b.toggleIssues()
case keyCtrlA, keyCtrlS:
// Nothing acts on a machine from the list. The cursor sits on a row that
// is one line of a table, and a table of two hundred machines is read by
@@ -672,6 +985,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])
@@ -696,11 +1013,19 @@ func (b *browser) detailKey(k key) {
switch k.special {
case keyEsc, keyEnter, keyBackspace, keyLeft:
b.detail, b.dscroll = nil, 0
// The events go with the visit, not with the machine: coming back to a
// sheet half an hour later and finding half-hour-old events under a
// label that says nothing about when they were read would be the one
// stale thing on an otherwise freshly read screen. They are one
// keystroke away again.
b.events, b.eventsOf = nil, ""
b.setStatus("", "")
case keyCtrlA:
b.openMenu()
case keyCtrlS:
b.snapshot()
case keyRune:
b.detailRune(k.r)
case keyUp:
b.dscroll = max(b.dscroll-1, 0)
case keyDown:
@@ -716,18 +1041,47 @@ 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.
func (b *browser) refilter() {
keep := ""
if cur := b.current(); cur != nil {
keep = cur.name
keep = cur.id()
}
needle := strings.ToLower(strings.TrimSpace(b.filter))
b.view = b.view[:0]
for i, r := range b.rows {
if b.issuesOnly && !r.hasIssues() {
continue
}
if needle == "" || strings.Contains(r.haystack(), needle) {
b.view = append(b.view, i)
}
@@ -736,7 +1090,7 @@ func (b *browser) refilter() {
b.sel, b.scroll = 0, 0
if keep != "" {
for i, idx := range b.view {
if b.rows[idx].name == keep {
if b.rows[idx].id() == keep {
b.sel = i
break
}
@@ -744,11 +1098,54 @@ func (b *browser) refilter() {
}
}
// toggleIssues turns the issues filter on and off. Turning it on says how many
// machines there are to answer for, because none at all is the answer one hopes
// for and an empty screen on its own does not read as good news.
func (b *browser) toggleIssues() {
b.issuesOnly = !b.issuesOnly
b.refilter()
if !b.issuesOnly {
b.setStatus(colInfo, SF("all %d machines", len(b.rows)))
return
}
switch n := len(withIssues(b.rows)); n {
case 0:
b.setStatus(colOK, "nothing to report on any of them")
case 1:
b.setStatus(colWarn, "1 machine wants looking at")
default:
b.setStatus(colWarn, SF("%d machines want looking at", n))
}
}
// columns is the table as this list is showing it right now: the standing
// columns, a task column while anything is running, and the reason column in
// place of the guest's operating system while the issues filter is on.
//
// It is asked of every row, not of the ones the filter left, so that narrowing
// 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) {
@@ -774,6 +1171,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 {
@@ -934,12 +1332,24 @@ type prompt struct {
text string
hint string
col string
// 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
@@ -970,7 +1380,7 @@ func segLine(sb *strings.Builder, cols int, segs ...seg) {
func (b *browser) renderList() {
cols, rows := termSize()
cs := fitColumns(cols - gutter)
cs := fitColumnsOf(b.columns(), cols-gutter)
const head = 3 // title, filter, column headers
const foot = 2 // status, help
@@ -997,6 +1407,11 @@ func (b *browser) renderList() {
{colDim, " on "},
{colWhere, strings.Join(b.answered, ", ")}, // the colour the VC column has
}
// A filtered list that does not say so is a lie told by omission: the count
// in front of it is of every machine, and the rows underneath are not.
if b.issuesOnly {
title = append(title, seg{colDim, " "}, seg{colWarn, "issues only"})
}
if len(b.lost) > 0 {
title = append(title,
seg{colDim, " "},
@@ -1005,6 +1420,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 == "" {
@@ -1047,23 +1474,30 @@ func (b *browser) renderList() {
seg{colDim, b.prompt.hint})
case b.status != "":
segLine(&sb, cols, seg{b.statusCol, b.status})
case len(b.view) == 0 && b.issuesOnly && b.filter == "":
segLine(&sb, cols, seg{colOK, "nothing to report on any of them — ^w for all of them again"})
case len(b.view) == 0:
segLine(&sb, cols, seg{colDim, "nothing matches"})
default:
segLine(&sb, cols)
}
sb.WriteString(colDim + truncate(listHelp, cols) + attrOff + scrEOL)
help, helpCol := listHelp, colDim
if b.prompt != nil && b.prompt.more != "" {
help, helpCol = b.prompt.more, b.prompt.col
}
sb.WriteString(helpCol + truncate(help, cols) + attrOff + scrEOL)
b.parkCursor(&sb, cols, rows)
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
@@ -1263,6 +1697,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
@@ -1270,9 +1711,31 @@ func (b *browser) openDetail() {
b.dwhere = SF(" · %s · %s", r.vc.Datacenter, r.host)
snaps, snapCol := snapshotLines(*r)
b.detail = vmDetail(*r, snaps, snapCol)
if b.eventsOf == r.id() {
b.detail = append(b.detail, eventSheet(b.events)...)
}
b.dscroll = 0
}
// eventSheet is the history as sheet lines: the label on the first, each line in
// the colour of its own severity. It is appended to the sheet rather than built
// into vmDetail because vmDetail asks nothing of the network and this is the one
// section that had to be fetched.
func eventSheet(events []eventLine) []sheetLine {
if len(events) == 0 {
return nil
}
out := make([]sheetLine, 0, len(events))
for i, e := range events {
label := ""
if i == 0 {
label = "events"
}
out = append(out, sheetLine{label: label, value: e.text, col: e.col})
}
return out
}
// vmDetail is the parameter sheet of one machine. snaps is what snapshotLines
// found, passed in rather than fetched here so that the sheet itself asks no
// questions and can be built from a machine that was never connected to.
@@ -1325,6 +1788,25 @@ func vmDetail(r vmRow, snaps []string, snapCol string) []sheetLine {
// eye should find without reading.
kv("state", join(state), r.powerColor())
// What is wrong with the machine, and what is being done to it: the two
// facts that were true a minute ago rather than since the machine was built,
// and the only reason anyone opens a sheet in a hurry. Each reason keeps its
// own colour — a filesystem at 97 % and a missing VMware Tools are not the
// same news, and painting the pair in one colour would say they were.
for i, is := range r.issueList() {
col, label := colBusy, ""
if is.bad {
col = colFull
}
if i == 0 {
label = "issues"
}
out = append(out, sheetLine{label: label, value: is.text, col: col})
}
if r.task != nil {
kv("task", r.task.line(), colBusy)
}
kv("guest", r.guestOS(), colValue)
if g := r.vm.Guest; g != nil {
kv("hostname", g.HostName, colValue)
@@ -1479,23 +1961,44 @@ func appendIf(list []string, values ...string) []string {
// snapshotLines asks this one machine for its snapshots, drawn as the tree they
// are. The colour comes back with them: a name someone chose is not the same kind
// of thing as an absence of one, or as a failure to look.
//
// Where the machine cannot be asked, what the sweep found is shown instead,
// with a line saying that is what it is. The alternative is a sheet reading
// "not read" beside a table that says the machine has three of them, which
// leaves the operator to work out which of the two to believe — and the answer
// would be "both": the sweep did find three, and this could not confirm it.
func snapshotLines(r vmRow) ([]string, string) {
if r.sess == nil {
return []string{"not read"}, colOff
return staleSnapshots(r, "not read: no connection to "+r.vc.Name)
}
entries, err := snapshotsOf(r.sess, r.ref)
if err != nil {
return []string{err.Error()}, colErr
return staleSnapshots(r, err.Error())
}
if len(entries) == 0 {
return []string{"none"}, colOff
}
return snapshotTree(entries), colChosen
}
// staleSnapshots is what the last sweep found, under the reason it could not be
// asked again. Nothing here is safe to act on — everything that removes or
// reverts a snapshot reads the tree itself, over a connection it has — so this
// is a report and says so.
func staleSnapshots(r vmRow, why string) ([]string, string) {
if len(r.snaps) == 0 {
return []string{why}, colOff
}
return append([]string{why + "; as the last sweep found them:"},
snapshotTree(r.snaps)...), colWarn
}
func snapshotTree(entries []snapEntry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.line()
}
return out, colChosen
return out
}
func uptime(d time.Duration) string {
+585 -6
View File
@@ -1,11 +1,14 @@
package main
import (
"io"
"os"
"slices"
"strings"
"testing"
"time"
"github.com/fatih/color"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
@@ -58,7 +61,12 @@ func testRow(name string, on bool, ip string) vmRow {
func testBrowser(names ...string) *browser {
b := &browser{targets: []VCenter{{Name: "v308"}}}
for i, n := range names {
b.rows = append(b.rows, testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1)))
r := testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1))
// A reference of its own for each: testRow hands out one fixed vm-42,
// which is fine for a machine on its own and useless for a list, where
// the selection follows a machine by its reference.
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + Itoa(i)}
b.rows = append(b.rows, r)
}
b.refilter()
return b
@@ -243,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))
@@ -905,8 +1042,9 @@ func TestColumnLadder(t *testing.T) {
// each rung are given, so a width change moves the boundary and not only the
// example.
const (
all = "NAME VC PWR IP HOST CPU CPU% MEM MEM% GUEST OS"
nine = "NAME VC PWR IP HOST CPU CPU% MEM MEM%"
all = "NAME VC PWR SNAP IP HOST CPU CPU% MEM MEM% GUEST OS"
ten = "NAME VC PWR SNAP IP HOST CPU CPU% MEM MEM%"
nine = "NAME VC PWR SNAP IP CPU CPU% MEM MEM%"
eight = "NAME VC PWR IP CPU CPU% MEM MEM%"
seven = "NAME VC PWR CPU CPU% MEM MEM%"
six = "NAME VC PWR CPU% MEM MEM%"
@@ -920,9 +1058,12 @@ func TestColumnLadder(t *testing.T) {
width int
want string
}{
{200, all}, {109, all},
{108, nine}, {89, nine},
{88, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
{200, all}, {115, all},
{114, ten}, {95, ten},
{94, nine}, {83, nine},
// The snapshot count is what a terminal of eighty gives up to keep the
// address, which is what the name column's minimum was tuned for.
{82, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
{76, seven}, {60, seven},
{59, six}, {55, six},
{54, five}, {46, five},
@@ -1021,3 +1162,441 @@ func TestPrintedCellsArePaddedNotColoured(t *testing.T) {
t.Errorf("a coloured cell is %d columns wide", len([]rune(stripEscapes(painted))))
}
}
// Into a pipe nothing may be cut. Fitting the printed table to the interactive
// minimum width truncated machine names at twenty-two characters, silently and
// with an ellipsis, in output whose whole purpose is to be read by something
// else.
func TestPipedListingCutsNothing(t *testing.T) {
t.Setenv("NO_COLOR", "1") // read by fatih/color at init, so also forced below
noColor := color.NoColor
color.NoColor = true
defer func() { color.NoColor = noColor }()
long := "a-machine-with-a-name-of-forty-three-chars"
rows := []vmRow{
testRow(long, true, "10.0.0.5"),
testRow("web01", true, "10.0.0.6"),
}
out := captureStdout(t, func() { printList(rows, listColumns(rows, false)) })
if !strings.Contains(out, long) {
t.Errorf("the long name was cut:\n%s", out)
}
if strings.Contains(out, "…") {
t.Errorf("something was truncated:\n%s", out)
}
// Every column still lines up: the widths grew, they did not go away.
var widths []int
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
widths = append(widths, len([]rune(line)))
}
for i := 1; i < len(widths); i++ {
if widths[i] > widths[0]+2 && widths[0] < len(long) {
t.Errorf("line %d is %d wide, the heading %d — the columns do not line up",
i, widths[i], widths[0])
}
}
}
// captureStdout runs f with stdout on a pipe and hands back what it wrote.
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
saved := os.Stdout
os.Stdout = w
done := make(chan string, 1)
go func() {
out, _ := io.ReadAll(r)
done <- string(out)
}()
f()
w.Close()
os.Stdout = saved
return <-done
}
// The selection follows the machine, not its name: two vCenters may each hold a
// "web01", and following the name alone moved the cursor to the other server's
// machine whenever sorting brought that one up first.
func TestSelectionFollowsTheMachineNotTheName(t *testing.T) {
same := func(vc, moref string, on bool) vmRow {
r := testRow("web01", on, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: moref}
return r
}
b := &browser{rows: []vmRow{same("v308", "vm-1", true), same("v108", "vm-9", false)}}
b.refilter()
b.move(1) // the second one: same name, other vCenter
cur := b.current()
if cur.vc.Name != "v108" {
t.Fatalf("the cursor is on %s, expected v108", cur.vc.Name)
}
if cur.id() == b.rows[0].id() {
t.Fatal("two machines on two servers share an identity")
}
// Sorting by power puts the running one first; the cursor must stay on the
// stopped machine it was on, not jump to the namesake now under it.
for i, o := range sortOrders {
if o.key == 'p' {
b.sortBy, b.sortDesc = i, o.natural
}
}
b.applySort()
if got := b.current(); got.vc.Name != "v108" {
t.Errorf("after sorting the cursor sits on %s/%s, want the v108 machine",
got.vc.Name, got.name)
}
}
// ---------------------------------------------------- the conditional columns
// The task column is there while anything is being done on the cluster and not
// otherwise. Every other column holds a property and can say "-"; this one
// holds an exception, and thirteen characters of blank down two hundred rows is
// thirteen characters spent on nothing.
func TestTheTaskColumnIsThereWhenThereIsATask(t *testing.T) {
quiet := []vmRow{testRow("web01", true, "10.0.0.5")}
if headers(listColumns(quiet, false)) != headers(browseColumns) {
t.Errorf("a quiet cluster shows %s", headers(listColumns(quiet, false)))
}
busy := []vmRow{testRow("web01", true, "10.0.0.5"), testRow("db01", true, "10.0.0.6")}
busy[1].task = &runningTask{what: "clone", progress: 40}
got := headers(listColumns(busy, false))
if !strings.Contains(got, "TASK") {
t.Fatalf("a busy cluster shows %s", got)
}
// Next to the power state: both answer "what is this machine doing".
if !strings.Contains(got, "PWR TASK") {
t.Errorf("the task column is not beside the power state: %s", got)
}
// And the machine that is not busy has an empty cell rather than a dash,
// which would read as a value of its own.
if cell := busy[0].taskCell(); cell != "" {
t.Errorf("an idle machine's task cell is %q", cell)
}
}
// In the listing that is made of reasons, the reason takes the place of the
// guest's operating system: the least read column, and the only one wide enough
// to hold a sentence.
func TestTheReasonColumnReplacesTheGuestOS(t *testing.T) {
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
got := headers(listColumns(rows, true))
if strings.Contains(got, "GUEST OS") {
t.Errorf("the issues listing still shows the guest os: %s", got)
}
if !strings.Contains(got, "WHY") {
t.Errorf("the issues listing has no reason column: %s", got)
}
// And the four figures go with it: how hard a machine's processors happen
// to be working says nothing about what is wrong with it, and they are four
// columns between its name and the answer.
for _, gone := range issueHides {
if strings.Contains(got, gone) {
t.Errorf("the issues listing still shows %s: %s", gone, got)
}
}
// What is left is what identifies the machine, plus the reason.
if want := "NAME VC PWR SNAP IP HOST WHY"; got != want {
t.Errorf("the issues listing shows\n %s\nwant\n %s", got, want)
}
}
// The ladder still only ever takes columns away, with the task column in the
// table as well: a column that comes back as the terminal is dragged narrower
// is worse than a column that is simply gone.
func TestTheLadderStillOnlyDropsWithATaskColumn(t *testing.T) {
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
rows[0].task = &runningTask{what: "clone"}
table := listColumns(rows, false)
var wider []string
for width := 10; width <= 220; width++ {
var here []string
for _, c := range fitColumnsOf(table, width) {
here = append(here, c.header)
}
for _, h := range wider {
if !slices.Contains(here, h) && h != "NAME" {
t.Fatalf("%d columns wide lost %s, which a narrower terminal showed: %v", width, h, here)
}
}
wider = here
}
}
// A terminal of eighty keeps the address, which is what the name column's
// minimum was tuned for. The snapshot count is what it gives up instead.
func TestATerminalOfEightyStillKeepsTheAddress(t *testing.T) {
got := headers(fitColumns(78))
if !strings.Contains(got, "IP") {
t.Errorf("a terminal of eighty shows %s", got)
}
if strings.Contains(got, "SNAP") {
t.Errorf("a terminal of eighty shows the snapshot count as well: %s", got)
}
if !strings.Contains(headers(fitColumns(83)), "SNAP") {
t.Errorf("a wider terminal does not show it either: %s", headers(fitColumns(83)))
}
}
func headers(cs []browseColumn) string {
var h []string
for _, c := range cs {
h = append(h, c.header)
}
return strings.Join(h, " ")
}
// The count is what the column says and its age is the colour it is said in.
// A colour cannot be read in a pipe, which is why the sheet spells the date out.
func TestTheSnapshotColumn(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if r.snapCell() != "-" || r.snapColor() != colOff {
t.Errorf("a machine with no snapshots shows %q", r.snapCell())
}
for _, c := range []struct {
days int
want string
}{
{1, colSize}, {snapStaleDays, colBusy}, {snapOldDays, colFull},
} {
r.snaps = []snapEntry{aged("s", c.days)}
if r.snapColor() != c.want {
t.Errorf("one snapshot of %d days is coloured wrongly", c.days)
}
if r.snapCell() != "1" {
t.Errorf("one snapshot shows as %q", r.snapCell())
}
}
// The oldest is what decides, not the newest or the first in the tree.
r.snaps = []snapEntry{aged("new", 1), aged("old", 200), aged("middling", 20)}
if r.snapCell() != "3" {
t.Errorf("three snapshots show as %q", r.snapCell())
}
if r.snapColor() != colFull {
t.Error("a machine whose oldest snapshot is 200 days old is not painted red")
}
if e, ok := r.oldest(); !ok || e.name != "old" {
t.Errorf("the oldest snapshot came back as %v", e.name)
}
if days, ok := r.snapAge(); !ok || int(days) != 200 {
t.Errorf("the age came back as %v", days)
}
// Snapshots with no date at all: there is something to clean up, but
// nothing to judge it by, so it is neither yellow nor red.
r.snaps = []snapEntry{{name: "undated"}}
if _, ok := r.snapAge(); ok {
t.Error("an undated snapshot reported an age")
}
if r.snapColor() != colChosen {
t.Error("an undated snapshot is aged as though it had a date")
}
}
// The sheet says what the table paints: what is wrong with the machine and what
// is being done to it, above everything that has been true since it was built.
func TestTheSheetLeadsWithWhatIsWrong(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true
r.task = &runningTask{what: "consolid", progress: 12}
var labels []string
var issues, task string
for _, l := range vmDetail(r, nil, "") {
if l.label != "" {
labels = append(labels, l.label)
}
if l.label == "issues" {
issues = l.value
}
if l.label == "task" {
task = l.value
}
}
if !strings.Contains(issues, "consolidating") {
t.Errorf("the sheet's issues line reads %q", issues)
}
if !strings.Contains(task, "consolid") || !strings.Contains(task, "12") {
t.Errorf("the sheet's task line reads %q", task)
}
order := strings.Join(labels, " ")
if !strings.HasPrefix(order, "state issues task guest") {
t.Errorf("the sheet begins %q", order)
}
}
// Each reason keeps its own colour: a filesystem at 97 % and a missing VMware
// Tools are not the same news.
func TestTheSheetColoursEachReasonForItself(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true // red
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // yellow
var cols []string
for _, l := range vmDetail(r, nil, "") {
if l.label == "issues" || (len(cols) > 0 && l.label == "") {
if l.col == colFull || l.col == colBusy {
cols = append(cols, l.col)
continue
}
break
}
}
if len(cols) != 2 || cols[0] != colFull || cols[1] != colBusy {
t.Errorf("the reasons came out coloured %v", cols)
}
}
// Every screen, drawn with a machine that has something to say on all of them:
// what is wrong with it, what is being done to it, snapshots, and a history
// that has been fetched. It is the check that each screen actually shows what
// it was given — the reason column was once first in the queue to be given up,
// which left a listing of nothing but machines with the reasons cut off.
func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
t.Setenv("COLUMNS", "120")
t.Setenv("LINES", "30")
b := testBrowser("web01", "db01")
b.answered = []string{"v308"}
b.rows[1].task = &runningTask{what: "clone", progress: 40}
b.rows[1].snaps = []snapEntry{aged("base", 63), aged("hotfix", 3)}
b.rows[1].vm.Summary.Runtime.ConsolidationNeeded = true
b.applySort()
list := stripEscapes(renderToPipe(t, b, b.renderList))
for _, want := range []string{"TASK", "clone 40%", "SNAP", "db01"} {
if !strings.Contains(list, want) {
t.Errorf("the list does not show %q:\n%s", want, list)
}
}
b.issuesOnly = true
b.refilter()
issues := stripEscapes(renderToPipe(t, b, b.renderList))
for _, want := range []string{"WHY", "consolidating", "issues only"} {
if !strings.Contains(issues, want) {
t.Errorf("the issues listing does not show %q:\n%s", want, issues)
}
}
if strings.Contains(issues, "web01") {
t.Errorf("the issues listing kept a healthy machine:\n%s", issues)
}
b.issuesOnly = false
b.refilter()
for i, r := range b.rows {
if r.name == "db01" {
b.sel = i
}
}
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
b.eventsOf = b.current().id()
b.openDetail()
sheet := stripEscapes(renderToPipe(t, b, b.renderDetail))
for _, want := range []string{"issues", "consolidating", "task", "clone", "events", "esx03"} {
if !strings.Contains(sheet, want) {
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, 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", "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
}
+359
View File
@@ -0,0 +1,359 @@
// complete.go — shell completion, and the inventory cache behind it.
//
// The names one types at gvm are machine names, and they are long, and there
// are hundreds of them on three servers. Completing them is what makes the
// non-interactive half usable — `gvm -v v308 snap -l dbse<tab>` — but it cannot
// be done by asking the vCenters: a shell completion runs on every Tab and has
// to answer in milliseconds, and three logins take seconds.
//
// So it answers out of what gvm last saw. Every sweep of the machine list
// leaves the names behind in the cache directory, per vCenter and with the time
// on them, and `--complete-vms` reads that file and nothing else. The cache is
// therefore always exactly as fresh as the last time somebody looked at the
// list — which is the right currency for a Tab key, and no currency at all for
// anything that acts on a machine. Nothing else in gvm reads this file: every
// command resolves the name it was given against the server itself.
//
// `gvm config` says how old it is, because a completion that quietly offers a
// machine deleted last month is a small mystery worth being able to explain.
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/integrii/flaggy"
)
// completionFlagNames are the options answered before the flag parser, the same
// way the update options are (gvm.go). They are what the generated scripts call
// on every Tab, so they must work on a machine whose configuration is broken —
// and must never print anything but the candidates. Nobody types them, which is
// why they are deliberately not in the help.
var completionFlagNames = []string{"--complete-vms", "--complete-vcenters"}
// isCompletionFlag reports whether this argument is one of them, so that the
// help can be checked against what is actually answered — the same guard the
// update options have (see the tests).
func isCompletionFlag(arg string) bool { return contains(completionFlagNames, arg) }
// completionFlags answers those options and reports whether it did.
func completionFlags() bool {
args := os.Args[1:]
for i, a := range args {
if !contains(completionFlagNames, a) {
continue
}
// The word after the option, skipping the "--" the completion scripts
// put in front of it so that a prefix beginning with a dash cannot be
// taken for an option of gvm's own.
rest := ""
for _, a := range args[i+1:] {
if a == "--" {
continue
}
rest = a
break
}
switch a {
case "--complete-vms":
for _, name := range cachedNames(rest) {
P(name)
}
case "--complete-vcenters":
for _, name := range cachedVCenters() {
P(name)
}
}
return true
}
return false
}
// ------------------------------------------------------------------ the cache
// inventoryPath is where the names are kept: the cache directory, beside the
// update note, and never in the configuration — losing it costs one Tab that
// offers nothing.
func inventoryPath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, selfUpdate.asset, "inventory"), nil
}
// cacheEntry is one machine as the cache remembers it.
type cacheEntry struct {
vc string
when time.Time
name string
}
// saveInventory writes the machines of the servers that answered.
//
// The servers that did not are left exactly as they were: a vCenter that is
// down, or that this run was not asked about (`-v v308`), must not lose its
// machines out of the cache — the point of completion is to work when things
// are not working. Best effort throughout: a cache that cannot be written is
// not worth a word on the screen, let alone an error.
func saveInventory(answered []string, rows []vmRow) {
path, err := inventoryPath()
if err != nil {
return
}
fresh := map[string]bool{}
for _, name := range answered {
fresh[name] = true
}
kept := make([]cacheEntry, 0, len(rows))
for _, e := range loadInventory() {
if !fresh[e.vc] {
kept = append(kept, e)
}
}
now := time.Now()
for _, r := range rows {
kept = append(kept, cacheEntry{vc: r.vc.Name, when: now, name: r.name})
}
var sb strings.Builder
for _, e := range kept {
// One line per machine: the server, when it was read, and the name.
// Tab separated because a machine name may hold a space and never a tab.
sb.WriteString(e.vc + "\t" + e.when.Format(time.RFC3339) + "\t" + e.name + "\n")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
tmp := path + ".new"
if os.WriteFile(tmp, []byte(sb.String()), 0o600) != nil {
return
}
if os.Rename(tmp, path) != nil {
os.Remove(tmp)
}
}
// loadInventory reads it back. A line that does not parse is dropped rather
// than reported: this file is a convenience and a broken one means one Tab
// without an answer.
func loadInventory() []cacheEntry {
path, err := inventoryPath()
if err != nil {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var out []cacheEntry
for _, line := range strings.Split(string(data), "\n") {
f := strings.Split(line, "\t")
if len(f) != 3 || f[0] == "" || f[2] == "" {
continue
}
when, err := time.Parse(time.RFC3339, f[1])
if err != nil {
continue
}
out = append(out, cacheEntry{vc: f[0], when: when, name: f[2]})
}
return out
}
// cachedNames are the machine names that begin with the prefix, once each and
// in order. Once each because the same name on two vCenters is one thing to
// type; in order because a completion list that moves about is a completion
// list nobody reads.
func cachedNames(prefix string) []string {
seen := map[string]bool{}
var out []string
for _, e := range loadInventory() {
if seen[e.name] || !strings.HasPrefix(strings.ToLower(e.name), strings.ToLower(prefix)) {
continue
}
seen[e.name] = true
out = append(out, e.name)
}
sort.Strings(out)
return out
}
// cachedVCenters are the servers the cache has seen, which is what `-v`
// completes against. It comes out of the cache and not out of ~/.gvmrc on
// purpose: reading the configuration would seal a password standing in the
// clear in it, and a Tab key must not rewrite a file.
func cachedVCenters() []string {
seen := map[string]bool{}
var out []string
for _, e := range loadInventory() {
if seen[e.vc] {
continue
}
seen[e.vc] = true
out = append(out, e.vc)
}
sort.Strings(out)
return out
}
// inventoryAge is what `gvm config` says about the cache: how many machines it
// holds and how long ago each server was read.
func inventoryAge() string {
entries := loadInventory()
if len(entries) == 0 {
return "-"
}
newest := map[string]time.Time{}
var order []string
for _, e := range entries {
if _, seen := newest[e.vc]; !seen {
order = append(order, e.vc)
}
if e.when.After(newest[e.vc]) {
newest[e.vc] = e.when
}
}
sort.Strings(order)
parts := make([]string, 0, len(order))
for _, vc := range order {
parts = append(parts, SF("%s %s ago", vc, uptime(time.Since(newest[vc]))))
}
return SF("%s from %s", plural(len(entries), "machine"), strings.Join(parts, ", "))
}
// ----------------------------------------------------------------- the scripts
// vmFlags are the options that take a machine name and vcFlags the ones that
// take a vCenter — the only thing about gvm's own command line that the
// completion has to be told, because it is the only thing flaggy's generated
// script cannot know: it knows every option there is, and nothing about what
// any of them means.
//
// completionOptionsAreReal (see the tests) checks each one against gvm.go, so
// an option renamed there cannot leave a completion quietly offering the wrong
// thing.
var (
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
"--off", "--reset", "--vm", "--from"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
// installedFunction is the completion function flaggy's own script installs,
// read off the line where it installs it — "compdef _gvm gvm" in zsh,
// "complete -F _gvm_complete gvm" in bash.
//
// Taken from the script rather than written down here, because the name is
// flaggy's to choose: it builds it out of the parser's name, and a version that
// built it differently would leave the addendum below calling a function that
// does not exist, which in a shell is a completion that silently offers
// nothing.
func installedFunction(script string) string {
for _, line := range strings.Split(script, "\n") {
f := strings.Fields(line)
switch {
case len(f) >= 2 && f[0] == "compdef":
return f[1]
case len(f) >= 3 && f[0] == "complete" && f[1] == "-F":
return f[2]
}
}
return ""
}
// completionRequest recognises `gvm completion <shell>` — the subcommand flaggy
// offers and lists in the help, answered here instead so that the script can
// carry the machine names as well. A shell this does not know is left to
// flaggy, whose own message names the ones it can write.
func completionRequest(args []string) (shell string, ok bool) {
if len(args) < 2 || !strings.EqualFold(args[0], "completion") {
return "", false
}
return strings.ToLower(args[1]), true
}
// completionScript is flaggy's script for that shell with the names put on top:
//
// eval "$(gvm completion zsh)"
// gvm completion bash > /etc/bash_completion.d/gvm
//
// flaggy generates the half that is about gvm's own command line, from the
// parser itself, so no list here can fall behind the options that exist. This
// adds the half that is about the estate: after an option that takes a machine,
// the machines; after -v, the servers. Both go through --complete-vms, which
// reads the cache and never a vCenter.
func completionScript(shell, flaggyScript string) (string, bool) {
names, ok := map[string]func(string) string{"zsh": zshNames, "bash": bashNames}[shell]
if !ok {
return "", false
}
// Without a function of flaggy's to fall back to there is nothing to add
// to: half a completion — machine names and no options — would be worse
// than the whole of flaggy's, which is what this then leaves in place.
delegate := installedFunction(flaggyScript)
if delegate == "" {
return flaggyScript, true
}
return flaggyScript + names(delegate), true
}
func zshNames(delegate string) string {
return strings.Join([]string{
"",
"# gvm: the machines and the servers, from what gvm last saw",
"_gvm_names() {",
" local prev=${words[CURRENT-1]} cur=${words[CURRENT]}",
" case $prev in",
" " + strings.Join(vmFlags, "|") + ")",
" compadd -- ${(f)\"$(gvm --complete-vms -- ${cur} 2>/dev/null)\"}; return;;",
" " + strings.Join(vcFlags, "|") + ")",
" compadd -- ${(f)\"$(gvm --complete-vcenters 2>/dev/null)\"}; return;;",
" esac",
" " + delegate + " \"$@\"",
"}",
"compdef _gvm_names gvm",
"",
}, "\n")
}
func bashNames(delegate string) string {
return strings.Join([]string{
"",
"# gvm: the machines and the servers, from what gvm last saw",
"_gvm_names() {",
" local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]}",
" case $prev in",
" " + strings.Join(vmFlags, "|") + ")",
" COMPREPLY=($(compgen -W \"$(gvm --complete-vms -- \"$cur\" 2>/dev/null)\" -- \"$cur\")); return;;",
" " + strings.Join(vcFlags, "|") + ")",
" COMPREPLY=($(compgen -W \"$(gvm --complete-vcenters 2>/dev/null)\" -- \"$cur\")); return;;",
" esac",
" " + delegate,
"}",
"complete -F _gvm_names gvm",
"",
}, "\n")
}
// flaggyCompletion is flaggy's own script for that shell, out of the parser as
// it stands — every subcommand and every option, without a list here to fall
// behind them. Empty for a shell flaggy does not write, which is the caller's
// signal to let flaggy answer for itself.
func flaggyCompletion(shell string) string {
switch shell {
case "zsh":
return flaggy.GenerateZshCompletion(flaggy.DefaultParser)
case "bash":
return flaggy.GenerateBashCompletion(flaggy.DefaultParser)
}
return ""
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/integrii/flaggy"
)
// cacheHome points the cache directory at a temporary one, so a test never
// reads or writes the cache of the person running it. os.UserCacheDir goes by
// HOME on macOS and by XDG_CACHE_HOME on Linux, so both are set.
func cacheHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache"))
return dir
}
func cacheRows(vc string, names ...string) []vmRow {
var rows []vmRow
for _, n := range names {
rows = append(rows, vmRow{vc: VCenter{Name: vc}, name: n})
}
return rows
}
func TestInventoryCacheRoundTrip(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
if got := cachedNames(""); !slices.Equal(got, []string{"db01", "web01"}) {
t.Errorf("the cache gave back %v", got)
}
if got := cachedVCenters(); !slices.Equal(got, []string{"v308"}) {
t.Errorf("the servers came back as %v", got)
}
if got := cachedNames("web"); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the prefix web matched %v", got)
}
// A shell completes what has been typed so far, whichever case it is in.
if got := cachedNames("WEB"); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the prefix WEB matched %v", got)
}
if got := cachedNames("nothing-like-this"); len(got) != 0 {
t.Errorf("a prefix that matches nothing gave %v", got)
}
}
// A sweep of one server must not lose the others' machines. The point of
// completing out of a cache is that it works when a vCenter is down — or when
// the last command was `gvm -v v308 vm -l`.
func TestASweepOfOneServerKeepsTheOthers(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308", "v108"},
append(cacheRows("v308", "web01"), cacheRows("v108", "old01")...))
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "web02"))
if got := cachedNames(""); !slices.Equal(got, []string{"old01", "web01", "web02"}) {
t.Errorf("after a sweep of one server the cache holds %v", got)
}
if got := cachedVCenters(); !slices.Equal(got, []string{"v108", "v308"}) {
t.Errorf("the servers came back as %v", got)
}
}
// A machine that has gone is gone from the cache of the server it was on.
func TestTheCacheForgetsWhatTheServerHasForgotten(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "temp01"))
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
if got := cachedNames(""); !slices.Equal(got, []string{"web01"}) {
t.Errorf("the cache still holds %v", got)
}
}
// A cache that cannot be read is one Tab without an answer, never an error.
func TestABrokenCacheIsSilent(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
path, err := inventoryPath()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("nonsense\nv308\tnot-a-date\tweb01\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := cachedNames(""); len(got) != 0 {
t.Errorf("a broken cache offered %v", got)
}
if got := inventoryAge(); got != "-" {
t.Errorf("a broken cache is described as %q", got)
}
}
// The file holds machine names read off a vCenter, and the cache directory is
// not private, so it is written the way the configuration is.
func TestTheCacheIsNotWorldReadable(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
path, _ := inventoryPath()
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Errorf("the cache is mode %v", st.Mode().Perm())
}
}
// `gvm config` says how old the cache is, because a completion offering a
// machine deleted last month should be explicable.
func TestConfigSaysHowOldTheCacheIs(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
got := inventoryAge()
if !strings.Contains(got, "2 machines") || !strings.Contains(got, "v308") {
t.Errorf("inventoryAge = %q", got)
}
if !strings.Contains(got, "ago") {
t.Errorf("inventoryAge does not say when: %q", got)
}
}
// The scripts complete against the cache and never against a vCenter: a Tab key
// that logs in three times is a Tab key nobody presses twice.
func TestTheCompletionScriptsAskGvmAndNothingElse(t *testing.T) {
for _, shell := range []string{"zsh", "bash"} {
script, ok := completionScript(shell, "# flaggy's half\n_half() {\n}\ncompdef _half gvm\n")
if !ok {
t.Fatalf("no script for %s", shell)
}
if !strings.Contains(script, "--complete-vms") || !strings.Contains(script, "--complete-vcenters") {
t.Errorf("the %s script does not ask gvm for the names:\n%s", shell, script)
}
// flaggy's half is carried, not replaced: that is the half that knows
// every subcommand and every option.
if !strings.Contains(script, "# flaggy's half") {
t.Errorf("the %s script threw flaggy's own half away", shell)
}
if !strings.Contains(script, "_gvm_names") {
t.Errorf("the %s script does not install itself", shell)
}
if !strings.Contains(script, "_half") {
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
}
if !strings.Contains(script, "_half") {
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
}
}
// A shell neither half knows is left to flaggy, whose own message names the
// ones it can write.
if _, ok := completionScript("klingon", ""); ok {
t.Error("a script was written for a shell nobody has")
}
}
// The addendum falls back to the function flaggy's own script installs, whose
// name is read off the script rather than written down twice. If flaggy ever
// names it differently the wrapper follows it there.
func TestTheAddendumDelegatesToFlaggysOwnFunction(t *testing.T) {
for _, c := range []struct{ script, want string }{
{"_gvm() {\n}\ncompdef _gvm gvm\n", "_gvm"},
{"_gvm_complete() {\n}\ncomplete -F _gvm_complete gvm\n", "_gvm_complete"},
{"_thing() {\n}\ncompdef _some_other_name thing\n", "_some_other_name"},
{"nothing installs anything here\n", ""},
} {
if got := installedFunction(c.script); got != c.want {
t.Errorf("installedFunction found %q, want %q", got, c.want)
}
}
// And on the real thing: flaggy builds the name out of the parser's name,
// which is the only part of the parser this depends on.
flaggy.SetName("gvm")
for _, shell := range []string{"zsh", "bash"} {
generated := flaggyCompletion(shell)
delegate := installedFunction(generated)
if delegate == "" {
t.Fatalf("nothing could be found to delegate to in flaggy's %s script:\n%s", shell, generated)
}
if !strings.Contains(generated, delegate+"()") {
t.Errorf("flaggy's %s script installs %s without defining it", shell, delegate)
}
script, _ := completionScript(shell, generated)
if !strings.Contains(script, delegate) {
t.Errorf("the %s addendum does not fall back to %s", shell, delegate)
}
}
}
// A shell script that is not valid shell is worse than none: the shell says so
// on every Tab. Both are checked with the shell's own parser, where there is one.
func TestTheCompletionScriptsAreValidShell(t *testing.T) {
flaggy.SetName("gvm")
for _, c := range []struct{ shell, flag string }{{"zsh", "-n"}, {"bash", "-n"}} {
if _, err := exec.LookPath(c.shell); err != nil {
t.Logf("no %s here to check with", c.shell)
continue
}
script, ok := completionScript(c.shell, flaggyCompletion(c.shell))
if !ok {
t.Fatalf("no %s script", c.shell)
}
path := filepath.Join(t.TempDir(), "completion."+c.shell)
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(c.shell, c.flag, path).CombinedOutput()
if err != nil {
t.Errorf("the %s script does not parse: %v\n%s\n%s", c.shell, err, out, script)
}
}
}
// `gvm completion <shell>` is flaggy's own subcommand, answered a step earlier.
// It has to be recognised exactly as flaggy would recognise it, or the two
// disagree about what the command line said.
func TestCompletionRequest(t *testing.T) {
for _, c := range []struct {
args []string
shell string
ok bool
}{
{[]string{"completion", "zsh"}, "zsh", true},
{[]string{"completion", "BASH"}, "bash", true},
{[]string{"Completion", "zsh"}, "zsh", true},
{[]string{"completion"}, "", false}, // flaggy asks which shell
{[]string{"vm", "-l"}, "", false},
{nil, "", false},
} {
shell, ok := completionRequest(c.args)
if ok != c.ok || shell != c.shell {
t.Errorf("completionRequest(%v) = %q, %v; want %q, %v", c.args, shell, ok, c.shell, c.ok)
}
}
}
// Every option the names are offered after is an option gvm actually has. The
// two lists cannot be one — a shell script has to name them as strings — so
// this is what keeps them from drifting apart. The subcommands and the rest of
// the options need no such check: flaggy writes those out of the parser itself.
func TestCompletionOptionsAreReal(t *testing.T) {
src, err := os.ReadFile("gvm.go")
if err != nil {
t.Fatal(err)
}
text := string(src)
for _, f := range append(append([]string{}, vmFlags...), vcFlags...) {
if !strings.Contains(text, `"`+strings.TrimLeft(f, "-")+`"`) {
t.Errorf("the completion offers %s, which gvm.go does not declare", f)
}
}
}
// A prefix beginning with a dash is still a prefix: the scripts put "--" in
// front of it so it cannot be taken for an option of gvm's own.
func TestACompletionPrefixMayLookLikeAnOption(t *testing.T) {
cacheHome(t)
saveInventory([]string{"v308"}, cacheRows("v308", "-odd-name", "web01"))
if got := cachedNames("-odd"); !slices.Equal(got, []string{"-odd-name"}) {
t.Errorf("the prefix -odd matched %v", got)
}
}
+334 -8
View File
@@ -30,6 +30,83 @@ type Config struct {
SMTPHost string // relay to hand it to
SMTPPort string // its port (default 25)
Telemetry string // URL `host -t` posts to; unset turns the posting off
SSH string // the command the sheet's `h` runs; %h is the machine
// 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
DNSTool string // the site's own address helper; unset means "dns" on the path
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
@@ -66,6 +143,18 @@ func (v VCenter) missing() []string {
return miss
}
// password is the password to log in with, opened if it was sealed. Asked for
// where it is used rather than when the file is read, so nothing is opened that
// is not needed and a value that will not open is reported against the vCenter it
// belongs to.
func (v VCenter) password() (string, error) {
secret, err := unseal(v.Password)
if err != nil {
return "", errf("%s: %w", v.Name, err)
}
return secret, nil
}
// skipVerify reports whether this server's certificate is to be taken on
// trust. It defaults to off, which is the one behaviour change of the rewrite:
// the old code passed insecure=true to every single connection, so a vCenter
@@ -118,6 +207,16 @@ func (c Config) pick(name string) (VCenter, error) {
return VCenter{}, c.notConfigured(incomplete)
}
// A list where one server is wanted is refused rather than half obeyed.
// The machine listing takes -v v308,v108; a snapshot, a power operation and
// the event log are about one server, and taking the first of a list would
// be picking a production cluster on the operator's behalf.
if strings.Contains(name, ",") {
return VCenter{}, fmt.Errorf("this command works on one vCenter at a time, "+
"and -v was given %d (%s) — a list of servers is for 'gvm vm' and 'gvm vm -l'",
len(splitList(name)), name)
}
if name == "" {
name = c.Default
}
@@ -145,14 +244,28 @@ func (c Config) pick(name string) (VCenter, error) {
}
// targets is what the commands that sweep every server work on: all of them
// when -v was not given, the named one when it was.
// when -v was not given, and otherwise the ones -v named — one, or several
// separated by commas, in the order they were given. An unknown name among
// them is an error rather than a shorter list.
func (c Config) targets(name string) ([]VCenter, error) {
if name != "" {
v, err := c.pick(name)
if err != nil {
return nil, err
var out []VCenter
seen := map[string]bool{}
for _, one := range splitList(name) {
v, err := c.pick(one)
if err != nil {
return nil, err
}
if seen[v.Name] {
continue // named twice; it is still one server and one login
}
seen[v.Name] = true
out = append(out, v)
}
return []VCenter{v}, nil
if len(out) == 0 {
return nil, fmt.Errorf("-v was given nothing to work on (known: %s)", c.names())
}
return out, nil
}
ok, incomplete := c.usable()
if len(ok) == 0 {
@@ -164,6 +277,18 @@ func (c Config) targets(name string) ([]VCenter, error) {
return ok, nil
}
// splitList takes -v apart. Empty pieces are dropped, so a trailing comma or a
// space after one is a typo that costs nothing.
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func (c Config) notConfigured(incomplete []string) error {
if len(incomplete) > 0 {
return fmt.Errorf("no usable vCenter in %s: %s", configFile(), strings.Join(incomplete, "; "))
@@ -180,6 +305,19 @@ func (c Config) smtpPort() int {
return 25
}
// telemetryURL is where the numbers are posted, when they are asked to be.
// Asked for here rather than at each command, so that a missing setting is one
// message and not one per subcommand that grew a -t.
func (c Config) telemetryURL(wanted bool) (string, error) {
if !wanted {
return "", nil
}
if c.Telemetry == "" {
return "", errf("no 'telemetry' url in %s", configFile())
}
return c.Telemetry, nil
}
// mailReady reports whether `log -m` has everything it needs.
func (c Config) mailReady() error {
var miss []string
@@ -221,11 +359,81 @@ func loadConfig() Config {
m := parseConfig(string(data))
applyConfig(&c, m)
warnConfigPerms(path, m)
sealPasswords(path, string(data))
}
applyEnv(&c)
return c
}
// sealPasswords rewrites any password still standing in the clear in the file,
// and says which. Nothing else about the file changes: the key, the spacing, the
// comments, the order and the blank lines are all left exactly as they were, and
// a line that is already sealed or commented out is not touched.
//
// The rewrite goes through a file alongside and a rename, so that a gvm
// interrupted here leaves the configuration whole rather than half of it.
func sealPasswords(path, data string) {
lines := strings.Split(data, "\n")
var done []string
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 {
continue
}
key := strings.ToLower(strings.TrimRight(trimmed[:sep], " \t"))
f := vcenterFieldRe.FindStringSubmatch(key)
if f == nil || f[2] != "password" {
continue
}
// The line is taken apart so that everything but the value can be put
// back: what stood in front of it, and any comment behind it. A note
// somebody wrote next to their password is theirs, not gvm's to delete.
raw := trimmed[sep+1:]
lead := len(raw) - len(strings.TrimLeft(raw, " \t"))
body := raw[lead:]
value := stripInlineComment(strings.TrimRight(body, " \t"))
tail := body[len(value):]
if q := strings.Trim(value, "\"'"); q != value {
value = q // a quoted password; the sealed word needs no quotes
}
if value == "" || sealed(value) {
continue
}
word, err := seal(value)
if err != nil {
PE("could not seal the password of "+f[1], err.Error())
return
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(key):sep] // whatever alignment was there
lines[i] = indent + trimmed[:len(key)] + gap + string(trimmed[sep]) + raw[:lead] + word + tail
done = append(done, f[1])
}
if len(done) == 0 {
return
}
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), configMode); err != nil {
PE("could not seal the passwords in "+path, err.Error())
return
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
PE("could not seal the passwords in "+path, err.Error())
return
}
PO(SF("password of %s sealed in %s", strings.Join(done, ", "), path))
}
// parseConfig reads `key = value` (or `key: value`) lines, ignoring blank ones
// and '#' comments. Keys are lower-cased, values unquoted.
func parseConfig(s string) map[string]string {
@@ -279,6 +487,13 @@ func applyConfig(c *Config, m map[string]string) {
set("smtphost", &c.SMTPHost)
set("smtpport", &c.SMTPPort)
set("telemetry", &c.Telemetry)
set("ssh", &c.SSH)
set("domain", &c.Domain)
set("dns", &c.DNS)
set("dnstool", &c.DNSTool)
set("netmask", &c.Netmask)
set("gateway", &c.Gateway)
set("timezone", &c.Timezone)
applyVCenters(c, m)
}
@@ -344,6 +559,13 @@ func applyEnv(c *Config) {
env("GVM_SMTPHOST", &c.SMTPHost)
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_DNSTOOL", &c.DNSTool)
env("GVM_NETMASK", &c.Netmask)
env("GVM_GATEWAY", &c.Gateway)
env("GVM_TIMEZONE", &c.Timezone)
applyVCenterEnv(c)
}
@@ -421,6 +643,12 @@ func writeConfigTemplate(path string) {
b.WriteString("# Format: 'key = value' (or 'key: value'); '#' starts a comment.\n")
b.WriteString("# GVM_* environment variables override these settings.\n")
b.WriteString("#\n")
b.WriteString("# A password written here in the clear is sealed on the next run and\n")
b.WriteString("# replaced by a 'gvmenc1:...' word, so it does not stand in this file\n")
b.WriteString("# where a backup or a glance over your shoulder would pick it up.\n")
b.WriteString("# 'gvm config -p <vcenter>' asks for one instead, and then it never\n")
b.WriteString("# touches the disk unsealed at all.\n")
b.WriteString("#\n")
b.WriteString("# One 'vcenter.<name>.*' block per server. <name> is what -v selects.\n")
b.WriteString("# 'insecure = true' skips certificate verification — needed for a vCenter\n")
b.WriteString("# with a self-signed certificate, and the reason it is written down here\n")
@@ -440,13 +668,29 @@ func writeConfigTemplate(path string) {
fmt.Fprintf(&b, "vcenter.%s.insecure = true\n\n", v.Name)
}
b.WriteString("# --- mail for `gvm log -m` ---\n")
b.WriteString("# --- mail for `gvm log -m` and `gvm snap --old -m` ---\n")
b.WriteString("# mailfrom = root@fhi.mpg.de\n")
b.WriteString("# mailto = you@example.com\n")
b.WriteString("# smtphost = m0.fhi-berlin.mpg.de\n")
b.WriteString("# smtpport = 25\n\n")
b.WriteString("# --- where `gvm host -t` posts its numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n")
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
b.WriteString("# --- 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")
b.WriteString("# An address can also be fetched rather than typed, where the site has a\n")
b.WriteString("# tool for it: 'gvm new ... --ip auto'. Unset, gvm looks for 'dns' on the\n")
b.WriteString("# path and offers the option only where it finds it.\n")
b.WriteString("# dnstool = /usr/local/bin/dns\n\n")
b.WriteString("# --- how the sheet's 'h' logs in to a guest ---\n")
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
b.WriteString("# not written anywhere. Unset means '" + defaultSSH + "'.\n")
b.WriteString("# ssh = ssh -l someone %h\n")
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
PE("could not create "+path, err.Error())
@@ -477,3 +721,85 @@ func contains(list []string, s string) bool {
}
return false
}
// setPassword asks for a vCenter's password and writes it into ~/.gvmrc sealed.
//
// The point of doing it here rather than in an editor: a password typed into the
// file stands there in the clear until the next run of gvm seals it, and by then
// it has been through the editor's swap file and whatever backs the home
// directory up. Typed here it never touches the disk unsealed.
func setPassword(cfg Config, name string) error {
var target VCenter
for _, v := range cfg.VCenters {
if strings.EqualFold(v.Name, name) {
target = v
}
}
if target.Name == "" {
return errf("no vCenter called %q in %s", name, configFile())
}
if err := haveTerminal(); err != nil {
return errf("a password has to be typed, and there is no terminal to type it on (%v)", err)
}
secret := Inputpw(SF("password for %s (%s)", target.Name, target.User))
if secret == "" {
P("nothing done")
return nil
}
if again := Inputpw("again"); again != secret {
return errf("the two did not match — nothing written")
}
word, err := seal(secret)
if err != nil {
return err
}
if err := writeSetting(configFile(), "vcenter."+target.Name+".password", word); err != nil {
return err
}
PO(SF("password of %s sealed in %s", target.Name, configFile()))
return nil
}
// writeSetting replaces one setting in the file and leaves everything else as it
// was, appending it when it is not there yet. The same care as sealPasswords: a
// file alongside and a rename, so an interrupted write leaves the configuration
// whole.
func writeSetting(path, key, value string) error {
data, err := os.ReadFile(path)
if err != nil {
return errf("cannot read %s: %w", path, err)
}
lines := strings.Split(string(data), "\n")
written := false
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 || !strings.EqualFold(strings.TrimRight(trimmed[:sep], " \t"), key) {
continue
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(strings.TrimRight(trimmed[:sep], " \t")):sep]
lines[i] = indent + trimmed[:sep-len(gap)] + gap + string(trimmed[sep]) + " " + value
written = true
break
}
if !written {
lines = append(lines, key+" = "+value)
}
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), configMode); err != nil {
return errf("cannot write %s: %w", path, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return errf("cannot write %s: %w", path, err)
}
return nil
}
+192
View File
@@ -0,0 +1,192 @@
// datastore.go — what the datastores are doing.
//
// The gap next to `gvm host`. A cluster is watched by its processor load and its
// memory, and then it falls over because a datastore filled up — which nothing
// in gvm could show, and which is the one figure a snapshot report (snapold.go)
// makes you want to look at next.
//
// Same shape as hoststat: one line per datastore, the numbers in the palette's
// roles, and -t posts the same figures to the monitoring server.
package main
import (
"sort"
"strings"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
var dsColumns = []printColumn{
{header: "DATASTORE", width: 20},
{header: "TYPE", width: 5},
{header: "CAPACITY", width: 9, right: true},
{header: "FREE", width: 9, right: true},
{header: "USED%", width: 6, right: true},
{header: "PROVISIONED", width: 12, right: true},
{header: "OVER%", width: 6, right: true},
{header: "VM", width: 4, right: true},
{header: "STATUS", width: 7},
{header: "STATE", width: 14},
}
// dsstat prints one line per datastore: how big it is, what is left, what has
// been promised out of it, and how many machines live on it.
//
// Every figure comes out of summary, and vSphere only guarantees those while
// the datastore is accessible. An unreachable datastore therefore reports
// dashes rather than zeroes — a datastore that says 0 B free looks like an
// emergency, and a datastore nobody can reach is a different one.
func dsstat(vc VCenter, telemetry string) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
stores, err := s.datastores("name", "summary", "overallStatus", "vm")
if err != nil {
return err
}
sort.Slice(stores, func(a, b int) bool {
return strings.ToLower(dsName(stores[a])) < strings.ToLower(dsName(stores[b]))
})
var capacity, free int64
printRow(dsColumns, "", nil)
for _, ds := range stores {
sum := ds.Summary
used, usedKnown := dsUsedPercent(sum)
over, overKnown := dsOverPercent(sum)
if sum.Accessible {
capacity += sum.Capacity
free += sum.FreeSpace
}
printRow(dsColumns, "", []cell{
{dsName(ds), cWhite.fg()},
{sum.Type, colAside},
dsSize(sum.Capacity, sum.Accessible),
dsSize(sum.FreeSpace, sum.Accessible),
pctCell(used, usedKnown, loadColor(used, usedKnown)),
dsSize(dsProvisioned(sum), sum.Accessible),
pctCell(over, overKnown, overColor(over, overKnown)),
{Itoa(len(ds.Vm)), colSize},
{string(ds.OverallStatus), statusColor(ds.OverallStatus)},
{dsState(sum), dsStateColor(sum)},
})
if telemetry != "" {
post(telemetry, SF("ds,%s,%d,%d,%.2f,%d,%d,%s",
dsName(ds), sum.Capacity, sum.FreeSpace, used,
dsProvisioned(sum), len(ds.Vm), ds.OverallStatus))
}
}
// One line of estate: the figure somebody asks for immediately after
// reading the table, and the reason the table is worth printing at all.
if capacity > 0 {
P()
PF("%s %s of %s free (%s used)\n",
Cwb(plural(len(stores), "datastore")),
Co(units.ByteSize(free).String()),
Co(units.ByteSize(capacity).String()),
Co(SF("%.0f %%", 100.0-100.0/float64(capacity)*float64(free))))
}
return nil
}
// dsName prefers the summary's name over the entity's: they are two different
// vSphere properties and a datastore that was renamed can answer differently to
// each, the same way a host can (see countOn in host.go).
func dsName(ds mo.Datastore) string {
if ds.Summary.Name != "" {
return ds.Summary.Name
}
return ds.Name
}
// dsProvisioned is what has been promised out of the datastore: what is in use
// plus what thin disks are entitled to grow into. Past the capacity that is a
// promise the datastore cannot keep if every machine takes what it was offered,
// which is why it has a column of its own rather than being folded into "used".
func dsProvisioned(sum types.DatastoreSummary) int64 {
return sum.Capacity - sum.FreeSpace + sum.Uncommitted
}
func dsUsedPercent(sum types.DatastoreSummary) (float64, bool) {
if !sum.Accessible || sum.Capacity <= 0 {
return 0, false
}
return 100.0 - 100.0/float64(sum.Capacity)*float64(sum.FreeSpace), true
}
func dsOverPercent(sum types.DatastoreSummary) (float64, bool) {
if !sum.Accessible || sum.Capacity <= 0 {
return 0, false
}
return 100.0 / float64(sum.Capacity) * float64(dsProvisioned(sum)), true
}
// overColor: thin provisioning past the capacity is ordinary and not a fault,
// so a hundred per cent is a word of warning rather than an alarm; half again
// as much as there is, is an alarm.
func overColor(pct float64, known bool) string {
switch {
case !known:
return colOff
case pct >= 150:
return colFull
case pct >= 100:
return colBusy
}
return colSize
}
// dsSize is a byte figure, or a dash where the datastore cannot vouch for it.
func dsSize(b int64, accessible bool) cell {
if !accessible {
return cell{"-", colOff}
}
return cell{units.ByteSize(b).String(), colSize}
}
// pctCell is a percentage in a narrow column: no sign, because the header has
// one, and a dash where there is no figure rather than a nought.
func pctCell(pct float64, known bool, col string) cell {
if !known {
return cell{"-", colOff}
}
return cell{SF("%.0f", pct), col}
}
// dsState is what vSphere says about the datastore itself, as opposed to the
// alarms rolled up in its status: whether it can be reached at all, and whether
// it is being emptied for removal.
func dsState(sum types.DatastoreSummary) string {
var parts []string
if !sum.Accessible {
parts = append(parts, "inaccessible")
}
switch sum.MaintenanceMode {
case "", string(types.DatastoreSummaryMaintenanceModeStateNormal):
default:
parts = append(parts, string(sum.MaintenanceMode))
}
if len(parts) == 0 {
return "ok"
}
return strings.Join(parts, " ")
}
func dsStateColor(sum types.DatastoreSummary) string {
if !sum.Accessible {
return colFull
}
if sum.MaintenanceMode != "" &&
sum.MaintenanceMode != string(types.DatastoreSummaryMaintenanceModeStateNormal) {
return colBusy
}
return colOK
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
func dsSummary(capacity, free, uncommitted int64, accessible bool) types.DatastoreSummary {
return types.DatastoreSummary{
Name: "LocalDS_0", Type: "VMFS", Capacity: capacity,
FreeSpace: free, Uncommitted: uncommitted, Accessible: accessible,
}
}
// What has been promised out of a datastore is what is in use plus what thin
// disks may still grow into — the figure that says whether the datastore can
// keep its promises, and the reason it is a column of its own.
func TestProvisionedAndUsed(t *testing.T) {
sum := dsSummary(1000, 400, 800, true)
if got := dsProvisioned(sum); got != 1400 {
t.Errorf("provisioned = %d, want 1400", got)
}
used, ok := dsUsedPercent(sum)
if !ok || used != 60 {
t.Errorf("used = %v (known %v), want 60", used, ok)
}
over, ok := dsOverPercent(sum)
if !ok || over != 140 {
t.Errorf("over = %v (known %v), want 140", over, ok)
}
}
// A datastore nobody can reach cannot vouch for its own figures, so it reports
// none. A datastore that says 0 B free looks like an emergency; one that cannot
// be reached is a different one.
func TestAnUnreachableDatastoreReportsNothing(t *testing.T) {
sum := dsSummary(1000, 0, 0, false)
if _, ok := dsUsedPercent(sum); ok {
t.Error("an inaccessible datastore reported a usage figure")
}
if _, ok := dsOverPercent(sum); ok {
t.Error("an inaccessible datastore reported an over-commitment figure")
}
if got := dsSize(1000, false); got.text != "-" || got.col != colOff {
t.Errorf("its capacity is shown as %q", got.text)
}
if dsState(sum) != "inaccessible" || dsStateColor(sum) != colFull {
t.Errorf("its state is %q", dsState(sum))
}
}
// Thin provisioning past the capacity is ordinary practice, not a fault: a word
// of warning at a hundred per cent, an alarm at half again as much.
func TestOverCommitmentColours(t *testing.T) {
for _, c := range []struct {
pct float64
want string
}{{50, colSize}, {99, colSize}, {100, colBusy}, {149, colBusy}, {150, colFull}} {
if got := overColor(c.pct, true); got != c.want {
t.Errorf("%.0f %% over-committed is coloured wrongly", c.pct)
}
}
if overColor(0, false) != colOff {
t.Error("an unknown over-commitment is coloured as a figure")
}
}
func TestPercentCellHasNoSignAndNoNought(t *testing.T) {
if got := pctCell(93.4, true, colSize); got.text != "93" {
t.Errorf("a percentage is shown as %q", got.text)
}
if got := pctCell(0, false, colSize); got.text != "-" {
t.Errorf("an unknown percentage is shown as %q", got.text)
}
}
// A datastore in maintenance is being emptied on purpose: worth saying, not
// worth an alarm.
func TestMaintenanceModeIsSaidButNotAlarmed(t *testing.T) {
sum := dsSummary(1000, 500, 0, true)
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateEnteringMaintenance)
if got := dsState(sum); got != "enteringMaintenance" {
t.Errorf("the state reads %q", got)
}
if dsStateColor(sum) != colBusy {
t.Error("entering maintenance is painted as a fault")
}
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateNormal)
if got := dsState(sum); got != "ok" {
t.Errorf("an ordinary datastore reads %q", got)
}
if dsStateColor(sum) != colOK {
t.Error("an ordinary datastore is not painted as ordinary")
}
}
// The name in the summary and the name of the entity are two different vSphere
// properties, and a renamed datastore can answer differently to each — the same
// trap the host counts fell into.
func TestDatastoreNamePrefersTheSummary(t *testing.T) {
ds := mo.Datastore{Summary: types.DatastoreSummary{Name: "new-name"}}
ds.Name = "old-name"
if got := dsName(ds); got != "new-name" {
t.Errorf("dsName = %q", got)
}
ds.Summary.Name = ""
if got := dsName(ds); got != "old-name" {
t.Errorf("with no summary name, dsName = %q", got)
}
}
+781
View File
@@ -0,0 +1,781 @@
// 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.
how custom
spec string // which one, when how is customSpec
hostname string // empty means the machine's own name
ip string // empty leaves the adapter on DHCP; autoIP fetches one
st site // the site's own answers, from the configuration
dns string // the site's address helper, or "" where there is none
}
// autoAddress reports whether the address is to be fetched rather than typed.
func (o deployOpts) autoAddress() bool {
return strings.EqualFold(strings.TrimSpace(o.ip), autoIP)
}
// custom is where a customisation comes from. Two roads, and they are the same
// two everywhere in vSphere: a specification the vCenter already holds, where
// the site's answers live in the vCenter and gvm overrides only this machine's
// two facts; or one gvm writes itself out of ~/.gvmrc, which is the same site
// answers kept somewhere else. The second is the shorter road for a site that
// has no specifications; the first is the only road for Windows.
//
// It is a decision carried rather than one worked out from the other fields.
// Inferring it was a small mistake with a visible end: the interactive half
// settles which road before it asks for an address, so at that moment the
// fields are all still empty — which read as "no customisation at all" and
// produced the question "address for web05 (empty = as says)", with a hole in
// it where the name of a specification that was never chosen would have gone.
type custom int
const (
customNone custom = iota // leave the guest as the template made it
customSite // gvm writes the specification, from the configuration
customSpec // one the vCenter holds, named in spec
)
// customising reports whether anything is to be done to the guest at all.
func (o deployOpts) customising() bool { return o.how != customNone }
// building reports whether gvm is to write the specification itself rather than
// take one from the vCenter.
func (o deployOpts) building() bool { return o.how == customSite }
// 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,
}
}
// deployStep is what the steps before the confirmation left behind: why one of
// them did not happen, what the address tool handed out, and how to give that
// back. Three things that travel together, because they are all answers to
// "what has happened so far" — and a confirmation with eight arguments is one
// nobody can call correctly.
type deployStep struct {
note string // why a step was skipped, said on the confirmation
rec dnsHost // the record the address came from, where one was fetched
give func() // hands that address back
}
// giveBack releases a fetched address, and does nothing where none was.
func (s deployStep) giveBack() {
if s.give != nil {
s.give()
}
}
// fetchAddress turns an address of "auto" into one the site's tool handed out,
// and hands back the name it was registered under and a way to give it back.
//
// The record is made before the confirmation rather than after it, so that the
// question says the address the machine will actually have rather than a
// promise of one. That is worth a record being made for a deployment somebody
// then abandons — as long as it is given back, which is what the returned
// function is for.
func fetchAddress(opts deployOpts, name string) (deployOpts, dnsHost, func(), error) {
nothing := func() {}
if !opts.autoAddress() {
return opts, dnsHost{}, nothing, nil
}
if opts.dns == "" {
return opts, dnsHost{}, nothing, errf("there is no address tool on this machine — put one "+
"on the path as 'dns', name it as dnstool in %s, or give --ip an address", configFile())
}
host := strings.TrimSpace(opts.hostname)
if host == "" {
host = name
}
rec, err := dnsAdd(opts.dns, host)
if err != nil {
return opts, dnsHost{}, nothing, err
}
opts.ip = rec.address()
return opts, rec, func() { dnsRemove(opts.dns, host) }, nil
}
// ------------------------------------------------------- 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},
}
// An address to be fetched is fetched here, before the question, so that the
// question says the address rather than the promise of one. give hands it
// back where the deployment does not happen after all.
opts, rec, give, err := fetchAddress(opts, name)
if err != nil {
return err
}
if rec.Name != "" {
// Said here, where it stays on the screen, and not only inside the
// confirmation: this is a number somebody writes down.
PO(SF("%s: %s", rec.Name, rec.address()))
}
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 {
give()
return err
}
how := opts.spec
if opts.building() {
how = "from " + configFile()
}
facts = append(facts, [2]string{"customise", how}, [2]string{"hostname", hostNameOf(custom)})
address := addressOf(custom)
if rec.Name != "" {
address += SF(" (%s, from %s)", rec.Name, opts.dns)
}
facts = append(facts, [2]string{"address", address})
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 {
give() // nothing was made, so nothing keeps the address
return err
}
task, err := startDeploy(s, src, t, name, opts)
if err != nil {
give()
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
}
made := name + " is made"
if opts.ip != "" && opts.customising() {
made += " at " + opts.ip
}
PO(made)
return nil
}
+661
View File
@@ -0,0 +1,661 @@
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{}, deployStep{note: 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())
}
}
// The question that asks for an address has to read as a sentence on both
// roads. It did not: the interactive half settles which road before it asks,
// so at that moment every field is still empty — which read as "no
// customisation at all" and produced "address for web05 (empty = as says)",
// a sentence with a hole where the name of a specification nobody had chosen
// would have gone.
func TestTheAddressQuestionReadsAsASentence(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "24")
for _, c := range []struct {
what string
opts deployOpts
want string
}{
{"gvm's own specification", deployOpts{how: customSite, st: testSite()}, "empty for DHCP"},
{"one the vCenter holds", deployOpts{how: customSpec, spec: "linux-static"},
"leave it to linux-static"},
} {
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("\x1b") // esc: the question is what is being read, not the answer
b.deployAddress(r, deploySource{name: "ubuntu-tpl"},
deployTarget{where: "prod-cluster"}, "web05", c.opts)
frame := stripEscapes(<-drawn)
pw.Close()
pr.Close()
kw.Close()
if !strings.Contains(frame, c.want) {
t.Errorf("%s: the question does not say %q:\n%s", c.what, c.want, lastLine(frame))
}
// No hole where a name should be, on either road.
for _, hole := range []string{"as says", " says", "to : ", "empty = as"} {
if strings.Contains(frame, hole) {
t.Errorf("%s: the question has a hole in it (%q):\n%s", c.what, hole, lastLine(frame))
}
}
}
}
// The road is carried, not worked out from the other fields — which is what
// made that hole. Before an address or a name has been typed, the decision is
// already the whole answer.
func TestTheRoadIsCarriedNotGuessed(t *testing.T) {
// Chosen, with nothing filled in yet: still a customisation, and still
// gvm's own.
empty := deployOpts{how: customSite, st: testSite()}
if !empty.customising() {
t.Error("choosing gvm's own specification does not count as customising")
}
if !empty.building() {
t.Error("choosing gvm's own specification is not building one")
}
// A specification of the vCenter's, likewise, before an address is typed.
named := deployOpts{how: customSpec, spec: "linux-static"}
if !named.customising() || named.building() {
t.Errorf("a vCenter specification reads as customising=%v building=%v",
named.customising(), named.building())
}
// And nothing chosen is nothing done, whatever else is lying around.
none := deployOpts{st: testSite()}
if none.customising() || none.building() {
t.Error("a deployment nobody asked to customise customises anyway")
}
}
+211
View File
@@ -0,0 +1,211 @@
// dns.go — asking the site's own tool for an address.
//
// A machine made from a template needs a name and an address, and at this site
// the address does not come out of gvm's head: there is an Infoblox behind a
// helper called `dns`, and an address that is not in it is an address nobody
// may use. So gvm asks it rather than inventing one, and only where it is
// there — a copy of gvm on a laptop that has no such tool simply does not offer
// the option.
//
// Everything here shells out. That is deliberate: the helper already knows the
// site's Infoblox, its credentials and its default domain, and a second
// implementation of any of that inside gvm would be a second thing to keep
// right.
//
// What it does is not read-only, and that shapes the two rules this file holds
// to. Nothing is allocated that was not asked for, by name, at the moment it is
// asked for. And what is allocated and then not used is given back: a
// deployment somebody abandons at the confirmation, or one the vCenter refuses,
// must not leave a record behind for a machine that was never made.
package main
import (
"context"
"encoding/json"
"os/exec"
"strings"
"time"
)
// dnsWait is how long the helper is given. It talks to an appliance over the
// network, so it is not instant; it is also not a thing to wait minutes for
// while a deployment is half set up.
const dnsWait = 30 * time.Second
// autoIP is what is typed where an address would go to have one fetched. A
// word rather than a flag of its own: --ip is already "what address does this
// machine get", and "auto" is an answer to that question rather than a
// different question.
const autoIP = "auto"
// dnsTool is the helper to use, or "" where this machine has none.
//
// The configuration may name it outright, for a machine where it is not on the
// path; otherwise it is looked up by name, which is how it is found on the
// machines it is installed on.
func dnsTool(configured string) string {
if c := strings.TrimSpace(configured); c != "" {
if path, err := exec.LookPath(c); err == nil {
return path
}
return "" // named but not there: the option is not offered, and says so
}
path, err := exec.LookPath("dns")
if err != nil {
return ""
}
return path
}
// dnsHost is the part of a host record gvm reads. The helper answers in the
// appliance's own JSON, which has thirty fields; these are the two that say
// what the machine is called and what it may use.
type dnsHost struct {
Name string `json:"name"`
Addresses []struct {
Addr string `json:"ipv4addr"`
} `json:"ipv4addrs"`
}
// address is the first address of the record, or "" where it has none.
func (h dnsHost) address() string {
for _, a := range h.Addresses {
if a.Addr != "" {
return a.Addr
}
}
return ""
}
// dnsName is the short name to ask for. The helper puts the record in the
// site's default domain itself, so what it wants is the name and not a fully
// qualified one — and a machine called web05.example.org would otherwise be
// registered as web05.example.org.example.org.
func dnsName(host string) string {
name, _, _ := strings.Cut(strings.TrimSpace(host), ".")
return name
}
// dnsRun is one call to the helper. -y throughout: it asks before it changes
// anything when it has a terminal, and gvm has already asked.
//
// The output is believed before the exit status, because that is how this tool
// reports: `dns -s` on a name that is not there prints "ERROR: host '...' not
// found" and exits 0 all the same. Reading only the status took every refusal
// for an agreement — and, the other way round, made a name that was free look
// like an answer nobody could parse.
func dnsRun(tool string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), dnsWait)
defer cancel()
out, err := exec.CommandContext(ctx, tool, append(args, "-y")...).CombinedOutput()
text := strings.TrimSpace(string(out))
switch {
case dnsSaidError(text):
// The helper's own words, which say what is wrong far better than any
// status: "host already exists", "no free address in the network".
return text, errf("%s", firstLine(text))
case err != nil:
if text == "" {
return "", errf("%s %s: %w", tool, strings.Join(args, " "), err)
}
return text, errf("%s", firstLine(text))
}
return text, nil
}
// dnsSaidError and dnsNotFound read the two things the helper says about
// itself. "not found" is an answer rather than a failure — it is what a free
// name looks like — and everything else beginning with ERROR is a refusal.
func dnsSaidError(out string) bool {
return strings.HasPrefix(strings.TrimSpace(out), "ERROR")
}
func dnsNotFound(out string) bool {
return dnsSaidError(out) && strings.Contains(out, "not found")
}
// dnsShow reads a host record. A name that is not there comes back as an empty
// record and no error: "is this name free" is a question, and "no such name" is
// its answer rather than a failure to report.
//
// Anything else that goes wrong is an error and stays one. Swallowing those
// would turn an appliance nobody can reach into "the name is free", which is
// the last thing to conclude before asking it for an address.
func dnsShow(tool, host string) (dnsHost, error) {
out, err := dnsRun(tool, "-s", dnsName(host))
if err != nil {
if dnsNotFound(out) {
return dnsHost{}, nil
}
return dnsHost{}, err
}
var rec dnsHost
if err := json.Unmarshal([]byte(out), &rec); err != nil {
return dnsHost{}, errf("%s answered something that is not a host record: %s", tool, firstLine(out))
}
return rec, nil
}
// dnsAdd asks for an address for this name and hands back what was given.
//
// The record is read back rather than taken from what the helper said on the
// way past. Its "OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'" is
// a sentence for a person, and a sentence is a thing that gets reworded between
// versions; the record is the appliance's own answer to the same question. If
// the two ever disagree, the record is what the machine will actually be given.
func dnsAdd(tool, host string) (dnsHost, error) {
name := dnsName(host)
if name == "" {
return dnsHost{}, errf("there is no name to ask for an address for")
}
// Asked first, and refused rather than added to. A name that is already in
// the appliance belongs to something — and what `dns -a` would do with it
// is either refuse, which is this message with worse wording, or hang a
// second address on somebody else's host record, which is worse than
// either. It is also the same question gvm asks the vCenter about the
// machine's name (nameTaken), one answer short of the same answer.
if rec, err := dnsShow(tool, name); err != nil {
return dnsHost{}, err
} else if rec.Name != "" {
taken := rec.Name
if a := rec.address(); a != "" {
taken += " at " + a
}
return dnsHost{}, errf("%s already exists (%s) — give the machine another name, "+
"or free that one with 'dns -d %s'", name, taken, name)
}
said, err := dnsRun(tool, "-a", name)
if err != nil {
return dnsHost{}, err
}
rec, err := dnsShow(tool, name)
if err != nil {
return dnsHost{}, err
}
if rec.address() == "" {
return dnsHost{}, errf("%s said %q, but the record cannot be read back", tool, firstLine(said))
}
return rec, nil
}
// dnsRemove gives an address back. It is called where a deployment did not
// happen after all, so its failure is worth saying and not worth stopping for:
// the machine was not made either way, and what is left behind is a record
// somebody can delete by hand.
func dnsRemove(tool, host string) error {
_, err := dnsRun(tool, "-d", dnsName(host))
return err
}
// firstLine keeps a message from a tool to one line, for a status line that has
// one.
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fakeDNS writes a stand-in for the site's helper and returns it with the file
// it writes down what it was asked in.
//
// Nothing in this file may touch the real one. `dns -a` takes an address out of
// the site's Infoblox and `dns -d` gives one back, and a test suite that did
// either would be editing the institute's network every time somebody ran it.
// So the thing under test is everything around the call — what gvm asks for,
// what it makes of the answer, and what it does when the answer is no.
//
// It keeps the two habits of the real tool that gvm has to live with, both
// measured rather than assumed: it says what is wrong in its output, beginning
// with ERROR, and it exits 0 while doing so — a name that is not there is
// "ERROR: host '...' not found" and a successful run at the same time. A
// stand-in that answered in exit statuses would have let gvm's first version
// pass, which believed them.
func fakeDNS(t *testing.T, body string) (tool, log string) {
t.Helper()
dir := t.TempDir()
tool = filepath.Join(dir, "dns")
log = filepath.Join(dir, "asked")
script := "#!/bin/sh\necho \"$@\" >> " + log + "\n" + body + "\nexit 0\n"
if err := os.WriteFile(tool, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return tool, log
}
// fakeInfoblox is that stand-in with a memory: a name is not there until it has
// been added, and is gone again once it has been deleted. Which is the whole
// shape of what gvm does with it.
func fakeInfoblox(t *testing.T) (tool, log string) {
t.Helper()
state := filepath.Join(t.TempDir(), "records")
return fakeDNS(t, `
state=`+state+`
case "$1" in
-a) if grep -qx "$2" $state 2>/dev/null; then
echo "ERROR: host '$2.fhi.mpg.de' already exists"
else
echo "$2" >> $state
echo "OK: host '$2.fhi.mpg.de' added with IP '141.14.140.182'"
fi ;;
-s) if grep -qx "$2" $state 2>/dev/null; then
printf '{"name":"%s.fhi.mpg.de","ipv4addrs":[{"ipv4addr":"141.14.140.182"}]}\n' "$2"
else
echo "ERROR: host '$2.fhi.mpg.de' not found"
fi ;;
-d) grep -vx "$2" $state > $state.tmp 2>/dev/null
mv $state.tmp $state 2>/dev/null
echo "OK: host '$2.fhi.mpg.de' deleted" ;;
esac`)
}
// asked is what the helper was called with, one call per line.
func asked(t *testing.T, log string) []string {
t.Helper()
out, err := os.ReadFile(log)
if err != nil {
return nil
}
return strings.Split(strings.TrimSpace(string(out)), "\n")
}
// The record is read back rather than taken from the sentence the helper prints
// on its way past: "OK: host '...' added with IP '...'" is written for a person
// and gets reworded between versions, while the record is the appliance's own
// answer to the same question.
func TestAnAddressIsReadBackFromTheRecord(t *testing.T) {
tool, log := fakeInfoblox(t)
rec, err := dnsAdd(tool, "web05")
if err != nil {
t.Fatalf("dnsAdd: %v", err)
}
if got := rec.address(); got != "141.14.140.182" {
t.Errorf("the address came back as %q", got)
}
if rec.Name != "web05.fhi.mpg.de" {
t.Errorf("the name came back as %q", rec.Name)
}
// Asked whether the name is free, then to add it, then what it made — and
// never without -y, which is what keeps it from stopping to ask a question
// nobody is there to answer.
calls := asked(t, log)
if len(calls) != 3 || !strings.HasPrefix(calls[0], "-s web05") ||
!strings.HasPrefix(calls[1], "-a web05") || !strings.HasPrefix(calls[2], "-s web05") {
t.Errorf("it was asked %v", calls)
}
for _, c := range calls {
if !strings.Contains(c, "-y") {
t.Errorf("a call was made that could stop and ask: %q", c)
}
}
}
// A helper that refuses says why in its own words, which are better than any
// gvm could put there: "host already exists", "no free address in the network".
func TestTheHelpersOwnWordsComeBack(t *testing.T) {
tool, _ := fakeDNS(t, `echo "ERROR: no free address in the network 141.14.128.0/20"`)
_, err := dnsAdd(tool, "web05")
if err == nil {
t.Fatal("a helper that refused was taken for one that agreed")
}
if !strings.Contains(err.Error(), "no free address") {
t.Errorf("its words did not come back: %v", err)
}
}
// A name that is already in the appliance is refused before anything is added
// to it — and the refusal says what is in the way and how to clear it. What
// `dns -a` would do with it is either refuse in worse words, or hang a second
// address on somebody else's host record.
func TestANameThatIsTakenIsRefused(t *testing.T) {
tool, log := fakeInfoblox(t)
if _, err := dnsAdd(tool, "web05"); err != nil {
t.Fatalf("the first one went wrong: %v", err)
}
before := len(asked(t, log))
_, err := dnsAdd(tool, "web05")
if err == nil {
t.Fatal("a name that is already there was taken again")
}
for _, want := range []string{"already exists", "141.14.140.182", "dns -d web05"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q: %v", want, err)
}
}
// Refused before anything was asked of it beyond the question.
calls := asked(t, log)
if len(calls) != before+1 || !strings.HasPrefix(calls[before], "-s web05") {
t.Errorf("more than a look was taken: %v", calls[before:])
}
}
// A helper that cannot be reached is not a free name. Swallowing that would
// turn an appliance that is down into "nobody has this name", which is the last
// thing to conclude before asking it for an address.
func TestAnApplianceThatIsDownIsNotAFreeName(t *testing.T) {
tool, _ := fakeDNS(t, `echo "ERROR: cannot reach infoblox.fhi.mpg.de"`)
if _, err := dnsShow(tool, "web05"); err == nil {
t.Error("an appliance that could not be reached reported a free name")
}
if _, err := dnsAdd(tool, "web05"); err == nil {
t.Error("an address was asked for over an appliance that could not be reached")
}
}
// A helper that agrees but leaves no record is not an answer either: a
// deployment would then be given an empty address and the machine would come up
// on nothing.
func TestAnAddressThatCannotBeReadBackIsNoAddress(t *testing.T) {
tool, _ := fakeDNS(t, `case "$1" in
-a) echo "OK: host 'web05.fhi.mpg.de' added with IP '141.14.140.182'" ;;
-s) echo '{"name": "web05.fhi.mpg.de", "ipv4addrs": []}' ;;
esac`)
if _, err := dnsAdd(tool, "web05"); err == nil {
t.Fatal("an empty record was taken for an address")
}
// And something that is not a record at all.
tool, _ = fakeDNS(t, `case "$1" in
-a) echo "OK" ;;
-s) echo 'not json at all' ;;
esac`)
if _, err := dnsAdd(tool, "web05"); err == nil {
t.Fatal("a page of prose was taken for a host record")
}
}
// The helper puts the record in the site's own domain, so what it wants is a
// name and not a fully qualified one — web05.example.org would otherwise be
// registered as web05.example.org.example.org.
func TestOnlyTheNameIsAskedFor(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"web05", "web05"},
{"web05.fhi-berlin.mpg.de", "web05"},
{" web05.example.org ", "web05"},
{"", ""},
} {
if got := dnsName(c.in); got != c.want {
t.Errorf("dnsName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// An address that was fetched and then not used is given back. A deployment
// somebody abandons at the confirmation must not leave a record behind for a
// machine that was never made.
func TestAFetchedAddressIsGivenBack(t *testing.T) {
tool, log := fakeInfoblox(t)
opts := deployOpts{how: customSite, st: testSite(), ip: autoIP, dns: tool}
opts, rec, give, err := fetchAddress(opts, "web05")
if err != nil {
t.Fatalf("fetchAddress: %v", err)
}
if opts.ip != "141.14.140.182" {
t.Errorf("the deployment was given %q as its address", opts.ip)
}
if rec.Name == "" {
t.Error("the record came back without a name to show")
}
give()
calls := asked(t, log)
if n := len(calls); n == 0 || !strings.HasPrefix(calls[n-1], "-d web05") {
t.Errorf("the address was not given back: %v", calls)
}
// And having given it back, the name is free again — which is the whole
// point of giving it back.
if rec, err := dnsShow(tool, "web05"); err != nil || rec.Name != "" {
t.Errorf("the name is still taken after being released: %+v (%v)", rec, err)
}
}
// Everything above happens only where the site has such a tool. Without one,
// "auto" is refused with what to do about it, and — the point of the whole
// arrangement — an ordinary address is untouched either way.
func TestWithoutAToolAutoIsRefusedAndNothingElseChanges(t *testing.T) {
_, _, _, err := fetchAddress(deployOpts{ip: autoIP}, "web05")
if err == nil {
t.Fatal("an address was fetched with no tool to fetch it from")
}
for _, want := range []string{"no address tool", "dnstool"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not mention %q: %v", want, err)
}
}
// A typed address is never handed to a tool, whether there is one or not.
tool, log := fakeDNS(t, `echo "this should never run"; exit 1`)
opts, rec, give, err := fetchAddress(deployOpts{ip: "10.0.0.55", dns: tool}, "web05")
if err != nil {
t.Fatalf("a typed address went wrong: %v", err)
}
if opts.ip != "10.0.0.55" || rec.Name != "" {
t.Errorf("a typed address was changed to %q", opts.ip)
}
give() // must be safe, and must ask nothing
if calls := asked(t, log); len(calls) != 0 {
t.Errorf("the tool was called for an address that was typed: %v", calls)
}
// And no address at all asks nothing either.
if _, _, _, err := fetchAddress(deployOpts{dns: tool}, "web05"); err != nil {
t.Errorf("a deployment with no address at all went wrong: %v", err)
}
if calls := asked(t, log); len(calls) != 0 {
t.Errorf("the tool was called with no address asked for: %v", calls)
}
}
// Which helper to use: the one named in the configuration, or "dns" on the
// path, or none — and a configuration that names one that is not there offers
// nothing rather than quietly using a different one.
func TestWhichHelperIsUsed(t *testing.T) {
tool, _ := fakeDNS(t, `echo hello`)
if got := dnsTool(tool); got != tool {
t.Errorf("a named helper resolved to %q", got)
}
if got := dnsTool(filepath.Join(t.TempDir(), "not-there")); got != "" {
t.Errorf("a named helper that is not there resolved to %q", got)
}
// Found by name on the path, and not found where it is not.
t.Setenv("PATH", filepath.Dir(tool))
if got := dnsTool(""); got != tool {
t.Errorf("the helper on the path resolved to %q", got)
}
t.Setenv("PATH", t.TempDir())
if got := dnsTool(""); got != "" {
t.Errorf("a path with no helper on it resolved to %q", got)
}
}
+632
View File
@@ -0,0 +1,632 @@
// estate.go — the whole estate on one screen: every host of every server that
// answered, grouped by cluster, with what it carries set against what it has.
//
// The machine list answers "what is this machine doing". This answers the
// question that comes before buying or growing anything and that nothing else
// in gvm answers: *where is there still room*. Since `size` can now give a
// machine four more processors, gvm itself raises that question, and a tool that
// raises a question ought to answer it.
//
// Two kinds of number, and the difference between them is the point:
//
// - What is allocated. Every vCPU and every megabyte the machines on a host
// have been promised, added up — which routinely exceeds the host, and is
// meant to. The ratio is the interesting figure: 2.0x of memory on a host is
// a decision somebody made, 8.0x is one somebody forgot.
// - What is in use. What the host itself reports it is actually doing. A host
// at 4x allocation and 30 % load is fine; the same host at 90 % is not, and
// no allocation figure can tell those apart.
//
// The allocations are added up from the rows the list already holds — no machine
// is read twice for this — and matched to hosts by reference, never by name:
// host.go has the scar from doing that by name, where a host added by address
// and renamed later reported zero machines while running dozens.
package main
import (
"sort"
"strings"
"sync"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// estateProps is what a host has to say about itself. "summary" wholesale, the
// way host.go asks for it: it is one property that carries both the hardware
// and the live figures, and asking for the two paths separately would be two
// reads of the same document.
// Deliberately without "vm": it is an array of every machine reference on the
// host, thousands of them across a large estate, and nothing here reads it. What
// each host carries is added up from the rows the list already holds.
var estateProps = []string{"name", "parent", "summary", "runtime.connectionState",
"runtime.inMaintenanceMode", "overallStatus"}
// estateRow is one line of the screen: either a cluster heading or a host.
type estateRow struct {
heading string // a cluster, or the server itself — set only on headings
host string
vms, on int // machines carried, and how many are running
cores int32 // physical
allocs int32 // vCPUs promised to the machines on it
memPhys int64
allocMB int64 // memory promised to them
cpuPct float64
cpuKnown bool
memPct float64
memKnown bool
note string // maintenance, or a connection state that is not "connected"
bad bool // that note is a fault rather than a state of affairs
}
func (e estateRow) isHeading() bool { return e.heading != "" }
// estate is the screen: the rows in display order and where the cursor is.
type estate struct {
rows []estateRow
sel int
scroll int
failed []string // servers that could not be read for it
}
// openEstate builds it and puts it on screen. It is read fresh every time: the
// screen exists to be looked at when a decision is being made, and a cached
// answer to "where is there room" is the wrong kind of wrong.
func (b *browser) openEstate() {
// What the cursor was on, where this is a second reading of the same screen.
// A refresh that puts the cursor back at the top is a refresh one stops
// pressing.
was := ""
if b.estate != nil && b.estate.sel < len(b.estate.rows) {
was = b.estate.rows[b.estate.sel].host
}
// The old screen stays up while the hosts are read, so ^r does not flash
// the machine list underneath for the half second it takes.
b.working("reading the hosts ...")
e := buildEstate(b.sessions, b.rows)
if len(e.rows) == 0 {
b.setStatus(colErr, "no host could be read"+said(e.failed))
return
}
e.sel = e.firstHost()
for i, r := range e.rows {
if !r.isHeading() && r.host == was {
e.sel = i
break
}
}
b.estate = e
if len(e.failed) > 0 {
b.setStatus(colWarn, "without"+said(e.failed))
return
}
b.setStatus("", "")
}
func said(failed []string) string {
if len(failed) == 0 {
return ""
}
return " " + strings.Join(failed, ", ")
}
func (b *browser) closeEstate() { b.estate = nil }
// buildEstate reads every server that is still connected, in parallel, and adds
// up what the list already knows about their machines.
func buildEstate(sessions []*session, rows []vmRow) *estate {
type result struct {
rows []estateRow
vc string
err error
}
res := make([]result, len(sessions))
var wg sync.WaitGroup
for i, s := range sessions {
if s == nil {
continue
}
wg.Add(1)
go func(i int, s *session) {
defer wg.Done()
res[i].vc = s.vc.Name
res[i].rows, res[i].err = hostsOf(s, rows)
}(i, s)
}
wg.Wait()
e := &estate{}
for _, r := range res {
if r.vc == "" {
continue
}
if r.err != nil {
e.failed = append(e.failed, r.vc)
continue
}
e.rows = append(e.rows, r.rows...)
}
return e
}
// hostsOf is one server's hosts, grouped under their clusters and in a stable
// order: clusters by name, hosts by name within them. A standalone host has a
// compute resource of its own for a parent rather than a cluster, and is
// grouped under the server's own name instead of under a heading that would be
// the host's name repeated.
func hostsOf(s *session, rows []vmRow) ([]estateRow, error) {
hosts, err := s.hosts(estateProps...)
if err != nil {
return nil, err
}
// The cluster names, for the headings. A server with no clusters at all
// costs one empty read, which is cheaper than deciding whether to ask.
var clusters []mo.ClusterComputeResource
if err := s.retrieve("ClusterComputeResource", []string{"name"}, &clusters); err != nil {
return nil, err
}
clusterName := make(map[types.ManagedObjectReference]string, len(clusters))
for _, c := range clusters {
clusterName[c.Reference()] = c.Name
}
alloc := allocationsBy(rows, s.vc.Name)
byCluster := map[string][]estateRow{}
for _, h := range hosts {
row := hostRow(h)
if a, ok := alloc[h.Reference()]; ok {
row.vms, row.on, row.allocs, row.allocMB = a.vms, a.on, a.cpus, a.memMB
}
// A host outside a cluster has a compute resource of its own for a
// parent, whose name is the host's name again — which would make a
// heading that says the same thing as the line under it.
group := "standalone"
if h.Parent != nil {
if name, ok := clusterName[*h.Parent]; ok {
group = name
}
}
byCluster[group] = append(byCluster[group], row)
}
groups := make([]string, 0, len(byCluster))
for g := range byCluster {
groups = append(groups, g)
}
sort.Strings(groups)
var out []estateRow
for _, g := range groups {
hs := byCluster[g]
sort.Slice(hs, func(i, j int) bool { return hs[i].host < hs[j].host })
// The server's name is part of the heading, not just of the title: two
// vCenters may each hold a cluster called "prod", and a screen that is
// about where there is room must not put the two under one total.
head := estateRow{heading: s.vc.Name + " · " + g}
for _, h := range hs {
head.vms, head.on = head.vms+h.vms, head.on+h.on
head.cores, head.allocs = head.cores+h.cores, head.allocs+h.allocs
head.memPhys, head.allocMB = head.memPhys+h.memPhys, head.allocMB+h.allocMB
}
out = append(out, head)
out = append(out, hs...)
}
return out, nil
}
// hostRow is what one host says about itself, without what it carries — that
// is added from the list's own rows. Separate so the rules below can be
// exercised without a server.
func hostRow(h mo.HostSystem) estateRow {
row := estateRow{host: shortHost(h.Name)}
// What it has survives losing touch with it — vCenter keeps the hardware
// summary — but what it is *doing* does not: a host that is not answering
// reports zeroed live figures, which would draw an empty bar and a nought.
// That is the picture of an idle host, and bar() exists to keep the two
// apart.
reachable := h.Runtime.ConnectionState == types.HostSystemConnectionStateConnected
if hw := h.Summary.Hardware; hw != nil {
row.cores = int32(hw.NumCpuCores)
row.memPhys = hw.MemorySize
if reachable {
row.cpuPct, row.cpuKnown = cpuPercent(hw, h.Summary.QuickStats)
}
}
used := int64(h.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
if reachable && row.memPhys > 0 {
row.memPct, row.memKnown = 100/float64(row.memPhys)*float64(used), true
}
// What is wrong with it, if anything, in the words vCenter uses. A host in
// maintenance is not broken and says so in its own colour; one that is not
// connected is the reason its figures are missing.
switch {
case h.Runtime.InMaintenanceMode:
row.note = "maintenance"
case !reachable:
row.note, row.bad = string(h.Runtime.ConnectionState), true
case h.OverallStatus == types.ManagedEntityStatusRed:
row.note, row.bad = "red", true
case h.OverallStatus == types.ManagedEntityStatusYellow:
row.note = "yellow"
}
return row
}
// allocation is what one host's machines have been promised.
type allocation struct {
vms, on int
cpus int32
memMB int64
}
// allocationsBy adds the list's own rows up per host. By reference, not by
// name: see the file comment.
func allocationsBy(rows []vmRow, vc string) map[types.ManagedObjectReference]allocation {
out := map[types.ManagedObjectReference]allocation{}
for _, r := range rows {
if r.vc.Name != vc {
continue
}
ref := r.vm.Summary.Runtime.Host
if ref == nil {
continue // a machine vCenter is not currently placing anywhere
}
a := out[*ref]
a.vms++
if r.running() {
a.on++
}
// Only what is running is charged against a host. A machine that is
// switched off has been promised nothing it is using: counting its
// memory would make a host of parked machines look full when the whole
// point of parking them there was that it is not.
if r.running() {
a.cpus += r.vm.Summary.Config.NumCpu
a.memMB += int64(r.vm.Summary.Config.MemorySizeMB)
}
out[*ref] = a
}
return out
}
// firstHost is where the cursor goes: a heading is not selectable, for the same
// reason a menu separator is not.
func (e *estate) firstHost() int {
for i, r := range e.rows {
if !r.isHeading() {
return i
}
}
return 0
}
// lastHost is the other end, for End and for a page jump that overshoots.
func (e *estate) lastHost() int {
for i := len(e.rows) - 1; i >= 0; i-- {
if !e.rows[i].isHeading() {
return i
}
}
return 0
}
// move steps over the headings. The step is taken once and then walked off by
// ones until it is on a host — it is not taken again.
//
// Repeating it is what the menu does, and the menu is only ever stepped by one,
// where repeating and walking are the same thing. Here they are not: a page of
// five over an estate with a heading every fourth row landed page-up from row
// nine on row one and page-down from row three on row eleven, because the
// second leap cleared the rest of the screen. A page key that jumps to the top
// whenever it lands on a cluster name is worse than one that stops short.
//
// Running out of rows stops at the end of the travel rather than doing nothing:
// from the second host there is no row a whole page above, and "no row" has to
// mean the first one.
func (e *estate) move(step int) {
if step == 0 {
return
}
walk := 1
if step < 0 {
walk = -1
}
for i := e.sel + step; i >= 0 && i < len(e.rows); i += walk {
if !e.rows[i].isHeading() {
e.sel = i
return
}
}
if step < 0 {
e.sel = e.firstHost()
return
}
e.sel = e.lastHost()
}
// ------------------------------------------------------------------- the keys
// estateKey drives the screen. Enter is the only thing here that changes
// anything, and what it changes is the filter: the answer to "what is on this
// host" is the machine list narrowed to it, which gvm already knows how to
// draw.
func (b *browser) estateKey(k key) {
e := b.estate
_, rows := termSize()
page := max(rows-6, 1)
switch k.special {
case keyEsc, keyLeft, keyCtrlE:
b.closeEstate()
case keyUp, keyShiftTab:
e.move(-1)
case keyDown, keyTab:
e.move(1)
case keyPgUp:
e.move(-page)
case keyPgDn:
e.move(page)
case keyHome:
e.move(-len(e.rows))
case keyEnd:
e.move(len(e.rows))
case keyCtrlR:
b.openEstate()
case keyEnter:
b.showHost()
}
}
// showHost narrows the machine list to the host under the cursor and goes back
// to it. The filter is the mechanism because it is the honest one: it matches
// the whole row, so what comes up is what anybody would get by typing the same
// thing, and one keystroke — Esc — undoes it.
func (b *browser) showHost() {
e := b.estate
if e.sel < 0 || e.sel >= len(e.rows) {
return
}
row := e.rows[e.sel]
if row.isHeading() {
return
}
b.closeEstate()
b.filter = row.host
b.refilter()
// The count is the host's own, not the filtered view's. The filter is a
// text match over the whole row and carries no server, so it can also catch
// another vCenter's host of the same name, a host whose name this one is a
// prefix of, and a machine named after a host — and a count taken from it
// would then contradict the number on the screen this came from. Saying
// what the filter is keeps the difference visible where it happens.
b.setStatus(colInfo, SF("%s carries %s — filtered on its name, so ^w and esc still apply",
row.host, plural(row.vms, "machine")))
}
// ---------------------------------------------------------------- the drawing
// bar is a load drawn as one block of text. Ten characters, filled to the
// percentage — the figure is beside it, so this is for the eye running down the
// column rather than for reading a number off.
//
// A load that is not known draws nothing at all, and deliberately not an empty
// trough: a host at one per cent fills none of the ten characters either, and
// "almost idle" and "I cannot see this host" must not be the same picture.
func bar(pct float64, known bool) string {
const width = 10
if !known {
return SR(" ", width)
}
full := int(pct / 100 * width)
full = min(max(full, 0), width)
return strings.Repeat("█", full) + strings.Repeat("·", width-full)
}
// ratio is how much of a host has been promised away: 2.0x means twice what it
// has. Under one it is left blank rather than shown as 0.4x — a host with room
// to spare is the ordinary case, and a column of small numbers saying so is
// noise where the whole point is to find the ones over.
func ratio(promised, has float64) string {
if has <= 0 || promised <= 0 || promised < has {
return ""
}
return SF("%.1fx", promised/has)
}
// estateWidths is how wide this screen's columns are, which is the only thing
// that changes with the terminal: a narrow one gives up the bars and tightens
// the two widest columns rather than letting segLine cut the line at the edge
// and take half the screen's meaning with it.
//
// The three lines that make up the table — the header, a cluster's totals and a
// host — lay their figures out through one function, because three format
// strings kept in step by hand are three format strings that drift.
type estateWidths struct {
host int
mem int
pairs bool // the raw allocated/physical pairs, not only their ratios
bars bool
}
// The two right-hand layouts, as the format strings they are, so that the
// header, the rows and the measurement below cannot disagree about them.
const (
estateBars = " %-10s %4s %-10s %4s"
estatePcts = " %5s %5s"
)
// widthsFor picks the widest layout the terminal can hold whole. Three of them,
// giving up the least useful thing first, the way the machine list's columns do:
//
// - everything, with the load drawn as bars;
// - the same figures with the bars dropped to their percentages;
// - and, on a genuinely narrow terminal, only the two ratios and the two
// percentages — which is still both kinds of number, and both kinds is what
// this screen is for. The raw pairs behind them are detail.
func widthsFor(cols int) estateWidths {
for _, w := range []estateWidths{
{host: 20, mem: 17, pairs: true, bars: true},
{host: 16, mem: 15, pairs: true},
{host: 14},
} {
if cols >= w.width() {
return w
}
}
return estateWidths{host: 10}
}
// width is what a layout needs, measured rather than counted: the first version
// of this was a round number picked by eye, and at exactly 100 columns the wide
// layout is 105 wide and lost the figure on its right-hand end — which is the
// one failure the narrower layouts exist to prevent.
func (w estateWidths) width() int {
n := 2 + w.host + len([]rune(w.figures("", "", "", "", "")))
if w.bars {
return n + len([]rune(SF(estateBars, "", "", "", "")))
}
return n + len([]rune(SF(estatePcts, "", "")))
}
// figures is the middle of every line: the header's, a cluster's totals and a
// host's. One function, because three format strings kept in step by hand are
// three format strings that drift.
func (w estateWidths) figures(onvm, vcpu, vratio, mem, mratio string) string {
out := SF(" %6s", onvm)
if w.pairs {
out += SF(" %9s", vcpu)
}
out += SF(" %6s", vratio)
if w.pairs {
out += SF(" %*s", w.mem, mem)
}
return out + SF(" %6s", mratio)
}
// figuresOf is that for a row, heading or host alike: the numbers are the same
// numbers, summed or not.
func (w estateWidths) figuresOf(r estateRow) string {
return w.figures(
SF("%d/%d", r.on, r.vms),
SF("%d/%d", r.allocs, r.cores),
ratio(float64(r.allocs), float64(r.cores)),
SF("%s/%s", units.ByteSize(r.allocMB*1024*1024), units.ByteSize(r.memPhys)),
ratio(float64(r.allocMB*1024*1024), float64(r.memPhys)))
}
func (b *browser) renderEstate() {
cols, rows := termSize()
e := b.estate
visible := max(rows-5, 1)
if e.sel < e.scroll {
e.scroll = e.sel
}
if e.sel >= e.scroll+visible {
e.scroll = e.sel - visible + 1
}
end := min(e.scroll+visible, len(e.rows))
var sb strings.Builder
sb.WriteString(scrClear + scrHide)
hosts, machines := 0, 0
for _, r := range e.rows {
if !r.isHeading() {
hosts++
machines += r.vms
}
}
segLine(&sb, cols,
seg{colTitle, "Estate"},
seg{colDim, " "},
seg{colWhere, strings.Join(b.answered, ", ")},
seg{colDim, " "},
seg{colInfo, SF("%s · %s", plural(hosts, "host"), plural(machines, "machine"))})
segLine(&sb, cols)
w := widthsFor(cols)
head := SF(" %-*s%s", w.host, "CLUSTER / HOST",
w.figures("ON/VM", "vCPU", "CPU x", "MEM ALLOC", "MEM x"))
if w.bars {
head += SF(estateBars, "CPU LOAD", "%", "MEM USED", "%")
} else {
head += SF(estatePcts, "CPU%", "MEM%")
}
segLine(&sb, cols, seg{colHeader, head})
for i := e.scroll; i < end; i++ {
r := e.rows[i]
if r.isHeading() {
segLine(&sb, cols,
seg{colDim, " "},
seg{colLabel, padRight(truncate(r.heading, w.host), w.host)},
seg{colDim, w.figuresOf(r)})
continue
}
pointer, name := " ", colRow
if i == e.sel {
pointer, name = "▸ ", colRowSel
}
line := []seg{
{colPointer, pointer},
{name, padRight(truncate(r.host, w.host), w.host)},
{colSize, w.figuresOf(r)},
}
if w.bars {
line = append(line,
seg{colDim, " "},
seg{loadColor(r.cpuPct, r.cpuKnown), bar(r.cpuPct, r.cpuKnown)},
seg{loadColor(r.cpuPct, r.cpuKnown), SF(" %4s", loadCell(r.cpuPct, r.cpuKnown))},
seg{colDim, " "},
seg{loadColor(r.memPct, r.memKnown), bar(r.memPct, r.memKnown)},
seg{loadColor(r.memPct, r.memKnown), SF(" %4s", loadCell(r.memPct, r.memKnown))})
} else {
line = append(line,
seg{loadColor(r.cpuPct, r.cpuKnown), SF(" %5s", loadCell(r.cpuPct, r.cpuKnown))},
seg{loadColor(r.memPct, r.memKnown), SF(" %5s", loadCell(r.memPct, r.memKnown))})
}
if r.note != "" {
col := colWarn
if r.bad {
col = colErr
}
line = append(line, seg{colDim, " "}, seg{col, r.note})
}
segLine(&sb, cols, line...)
}
for i := end - e.scroll; i < visible; i++ {
sb.WriteString(scrEOL + "\r\n")
}
if b.status != "" {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim,
"allocated / physical, and the ratio where more is promised than there is"})
}
sb.WriteString(colDim + truncate(estateHelp, cols) + attrOff + scrEOL)
b.parkCursor(&sb, cols, rows)
b.write(sb.String())
}
const estateHelp = "↑/↓ move ⏎ its machines ^r read again esc/^e back ^c quit"
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"strings"
"testing"
)
// Whatever the terminal, the table fits it: the screen is two kinds of number
// set against each other, and a line cut at the right-hand edge takes one of
// the two away.
func TestTheEstateFitsEveryTerminal(t *testing.T) {
e := &estate{rows: []estateRow{
{heading: "v308 · a-cluster-with-a-long-name-indeed"},
{host: "esx-with-a-very-long-name-01", vms: 22, on: 21, cores: 32, allocs: 72,
memPhys: 1 << 38, allocMB: 400000, cpuPct: 41, cpuKnown: true, memPct: 72, memKnown: true},
{host: "esx02", vms: 4, on: 0, note: "notResponding", bad: true},
}}
b := &browser{estate: e, answered: []string{"v308"}}
for _, cols := range []string{"60", "79", "80", "99", "100", "104", "105", "132", "200"} {
t.Setenv("COLUMNS", cols)
t.Setenv("LINES", "12")
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
want := atoiOr(cols)
for _, l := range strings.Split(strings.ReplaceAll(frame, "\r", ""), "\n") {
if n := len([]rune(l)); n > want {
t.Errorf("%s columns: a line ran to %d characters: %q", cols, n, l)
}
}
// And whichever layout it chose, both kinds of number are on it.
if !strings.Contains(frame, "MEM%") && !strings.Contains(frame, "MEM USED") {
t.Errorf("%s columns: the used-memory figure is not on the screen:\n%s", cols, frame)
}
}
}
func atoiOr(s string) int {
n := 0
for _, r := range s {
n = n*10 + int(r-'0')
}
return n
}
+225
View File
@@ -0,0 +1,225 @@
package main
import (
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// The ratio is there to find the hosts that have been promised away, so it says
// nothing at all about the ones that have room — a column of "0.4x" down a
// screen of healthy hosts is noise where the point is to spot the one over.
func TestRatioOnlySpeaksWhenThereIsSomethingToSay(t *testing.T) {
for _, c := range []struct {
promised, has float64
want string
}{
{16, 8, "2.0x"},
{12, 8, "1.5x"},
{8, 8, "1.0x"},
{4, 8, ""}, // room to spare, which is the ordinary case
{0, 8, ""}, // nothing on it at all
{8, 0, ""}, // a host whose hardware could not be read
{8, -1, ""}, // and nonsense from the server does not divide
} {
if got := ratio(c.promised, c.has); got != c.want {
t.Errorf("ratio(%.0f, %.0f) = %q, want %q", c.promised, c.has, got, c.want)
}
}
}
// A host at one per cent fills none of the bar, and so does a host nobody can
// see. The two must not draw the same picture.
func TestBarTellsIdleFromUnknown(t *testing.T) {
idle := bar(1, true)
unknown := bar(0, false)
if idle == unknown {
t.Errorf("idle and unknown both drew %q", idle)
}
if strings.TrimSpace(unknown) != "" {
t.Errorf("an unknown load drew %q", unknown)
}
if !strings.HasPrefix(bar(100, true), "██████████") {
t.Errorf("a full host drew %q", bar(100, true))
}
if strings.Contains(bar(50, true), "···········") {
t.Errorf("half drew %q", bar(50, true))
}
// Nonsense from the server does not run off the end of the bar.
for _, pct := range []float64{-10, 140} {
if n := len([]rune(bar(pct, true))); n != 10 {
t.Errorf("%.0f%% drew %d characters", pct, n)
}
}
}
// What a host carries is added up from the rows the list already holds, and
// matched by reference — never by name, which is the mistake host.go carries a
// comment about. Only running machines are charged to it: a parked machine has
// been promised nothing it is using, and counting it would make a host of
// switched-off machines look full.
func TestAllocationsAreCountedByReferenceAndOnlyWhatRuns(t *testing.T) {
host := types.ManagedObjectReference{Type: "HostSystem", Value: "host-99"}
other := types.ManagedObjectReference{Type: "HostSystem", Value: "host-1"}
on := func(name string, running bool, ref types.ManagedObjectReference) vmRow {
r := testRow(name, running, "10.0.0.5")
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
r.vm.Summary.Runtime.Host = &ref
r.vm.Summary.Config.NumCpu = 4
r.vm.Summary.Config.MemorySizeMB = 8192
return r
}
rows := []vmRow{
on("web01", true, host),
on("web02", true, host),
on("parked", false, host), // counted as a machine, charged for nothing
on("elsewhere", true, other),
}
// A machine vCenter is not placing anywhere at all.
homeless := on("limbo", true, host)
homeless.vm.Summary.Runtime.Host = nil
rows = append(rows, homeless)
got := allocationsBy(rows, "v308")
a := got[host]
if a.vms != 3 || a.on != 2 {
t.Errorf("the host carries %d machines, %d on; want 3 and 2", a.vms, a.on)
}
if a.cpus != 8 {
t.Errorf("%d vCPUs charged to it, want 8 — the parked machine is not one", a.cpus)
}
if a.memMB != 16384 {
t.Errorf("%d MB charged to it, want 16384", a.memMB)
}
if got[other].vms != 1 {
t.Errorf("the other host got %d machines", got[other].vms)
}
// Another server's rows are not this server's, even where a reference
// repeats: references are unique within a vCenter and not across them.
if len(allocationsBy(rows, "v309")) != 0 {
t.Error("rows of one server were charged to another")
}
}
// Headings are not selectable, the way a menu separator is not, and the cursor
// starts on a host rather than on the first line.
func TestEstateCursorSkipsTheHeadings(t *testing.T) {
e := &estate{rows: []estateRow{
{heading: "v308 · prod"},
{host: "esx01"},
{host: "esx02"},
{heading: "v308 · standalone"},
{host: "esx09"},
}}
e.sel = e.firstHost()
if e.sel != 1 {
t.Fatalf("the cursor starts at %d, want the first host", e.sel)
}
e.move(1)
if e.sel != 2 {
t.Errorf("down went to %d", e.sel)
}
e.move(1) // over the heading
if e.sel != 4 || e.rows[e.sel].isHeading() {
t.Errorf("down landed on %d (%+v)", e.sel, e.rows[e.sel])
}
e.move(1) // at the end, it stays put
if e.sel != 4 {
t.Errorf("down past the end moved to %d", e.sel)
}
e.move(-1)
if e.sel != 2 {
t.Errorf("up landed on %d", e.sel)
}
e.move(-10) // past the top, and never onto the heading at 0
if e.sel != 1 {
t.Errorf("up past the top landed on %d", e.sel)
}
}
// A page step is taken once and then walked off by ones. Repeating it — which
// is what the menu does, where a step is only ever 1 and the two are the same
// thing — made a page that landed on a cluster name leap another whole page:
// page-up from the tenth row went to the first, and page-down from the fourth
// to the last.
func TestAPageStepDoesNotLeapTwice(t *testing.T) {
rows := []estateRow{
{heading: "A"}, {host: "h1"}, {host: "h2"}, {host: "h3"},
{heading: "B"}, {host: "h5"}, {host: "h6"}, {host: "h7"},
{heading: "C"}, {host: "h9"}, {host: "h10"}, {host: "h11"},
}
e := &estate{rows: rows, sel: 9}
e.move(-5) // lands on the heading at 4, so the row above it
if e.sel != 3 {
t.Errorf("page up from 9 landed on %d (%q), want 3", e.sel, e.rows[e.sel].host)
}
e = &estate{rows: rows, sel: 3}
e.move(5) // lands on the heading at 8, so the row below it
if e.sel != 9 {
t.Errorf("page down from 3 landed on %d (%q), want 9", e.sel, e.rows[e.sel].host)
}
// A step that lands on a host is not walked at all.
e = &estate{rows: rows, sel: 1}
e.move(2)
if e.sel != 3 {
t.Errorf("a step onto a host landed on %d", e.sel)
}
// And the ends still stop at the ends.
e = &estate{rows: rows, sel: 1}
e.move(-5)
if e.sel != 1 {
t.Errorf("page up from the first host landed on %d", e.sel)
}
e = &estate{rows: rows, sel: 11}
e.move(5)
if e.sel != 11 {
t.Errorf("page down from the last host landed on %d", e.sel)
}
}
// A host nobody can reach reports nothing, cached hardware or not. vCenter
// keeps the hardware summary of a host it has lost touch with but zeroes the
// live figures, which would draw an empty bar and a nought — the picture of an
// idle host, which is the one thing bar() exists to keep separate.
func TestAnUnreachableHostReportsNoLoad(t *testing.T) {
for _, c := range []struct {
state types.HostSystemConnectionState
known bool
}{
{types.HostSystemConnectionStateConnected, true},
{types.HostSystemConnectionStateNotResponding, false},
{types.HostSystemConnectionStateDisconnected, false},
} {
h := mo.HostSystem{
Summary: types.HostListSummary{
Hardware: &types.HostHardwareSummary{NumCpuCores: 32, CpuMhz: 2000,
MemorySize: 1 << 38},
QuickStats: types.HostListSummaryQuickStats{},
},
}
h.Runtime.ConnectionState = c.state
h.Name = "esx01"
row := hostRow(h)
if row.cpuKnown != c.known || row.memKnown != c.known {
t.Errorf("%s: cpu known = %v, memory known = %v, want both %v",
c.state, row.cpuKnown, row.memKnown, c.known)
}
// What it has is known either way: that does not depend on reaching it.
if row.cores != 32 {
t.Errorf("%s: the core count was lost with the connection", c.state)
}
if !c.known && strings.TrimSpace(bar(row.cpuPct, row.cpuKnown)) != "" {
t.Errorf("%s: it drew a load bar anyway", c.state)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
// events.go — one machine's recent history.
//
// `gvm log` is the whole vCenter over the last hour, which is the right shape
// for a mail and the wrong one for the question actually being asked in front
// of a machine's sheet: why is this thing off, who rebooted it, what happened
// at four this morning. vCenter keeps the answer per object, so this asks it
// per object.
//
// It is not part of the sheet's own reading. Opening a machine costs one call
// for its snapshots and nothing else, and it stays that way: the events are
// fetched when they are asked for (the action menu's 'e'), for the one machine
// on screen.
package main
import (
"context"
"strings"
"time"
"github.com/vmware/govmomi/event"
"github.com/vmware/govmomi/vim25/types"
)
// How many events are worth having, and how long to wait for them. The page is
// short on purpose: this answers "what just happened to this machine", and
// anything older than the last couple of dozen lines is a question for `log`.
const (
eventPage = 25
eventWait = 20 * time.Second
)
// eventLine is one event as the sheet shows it.
type eventLine struct {
text string
col string
}
// eventsOf reads the machine's most recent events, oldest first — the order a
// history reads in, so the last line is the latest thing that happened.
func eventsOf(r vmRow) ([]eventLine, error) {
if r.sess == nil {
return nil, errf("no connection to %s", r.vc.Name)
}
// Bounded. The underlying collector waits for vCenter to hand over its
// first page, and this runs in the interactive loop: a server that accepts
// the request and then says nothing would otherwise freeze the screen
// mid-draw with no key being read.
ctx, cancel := context.WithTimeout(r.sess.ctx, eventWait)
defer cancel()
var found []types.BaseEvent
err := event.NewManager(r.sess.client.Client).Events(ctx,
[]types.ManagedObjectReference{r.ref}, eventPage, false, false,
func(_ types.ManagedObjectReference, evs []types.BaseEvent) error {
found = append(found, evs...)
return nil
})
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, errf("%s did not answer within %s", r.vc.Name, eventWait)
}
return nil, errf("%s: cannot read the events of %s: %w", r.vc.Name, r.name, err)
}
out := make([]eventLine, 0, len(found))
for _, e := range found {
out = append(out, eventLineOf(e))
}
return out, nil
}
// eventLineOf is one event: when, and what. The severity decides the colour and
// is otherwise left out — "info" down twenty lines is twenty times four
// characters spent saying nothing.
func eventLineOf(e types.BaseEvent) eventLine {
base := e.GetEvent()
msg := strings.TrimSpace(base.FullFormattedMessage)
if msg == "" {
msg = SF("%T", e)
}
// One line per event: a formatted vSphere message can carry newlines, and a
// value with a newline in it would break the sheet's own line counting.
msg = strings.Join(strings.Fields(msg), " ")
return eventLine{
text: base.CreatedTime.Local().Format("02.01. 15:04") + " " + msg,
col: eventColor(severity(e)),
}
}
// eventColor takes log.go's reading of the severity into the palette. Only the
// two that matter are coloured; the rest is history, and history is grey.
func eventColor(sev string) string {
switch strings.ToLower(sev) {
case "error":
return colFull
case "warning":
return colBusy
}
return colAside
}
+280
View File
@@ -0,0 +1,280 @@
// guest.go — the three ways out of a machine's sheet.
//
// Everything else in gvm looks at machines. These look them up: log in to one,
// take its address away with you, open it in the vSphere client. They are the
// keystrokes that stop gvm being a viewer you then have to type an address out
// of by hand — and none of them touches the vCenter at all, which is why they
// are letters of the sheet itself rather than entries in the action menu.
package main
import (
"encoding/base64"
"errors"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// sshTarget is what to connect to: the name the guest calls itself, or its
// address. The name is preferred where there is one — it is what is in the known
// hosts file, and an address that came out of VMware Tools may be one of several.
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
// sshTargetIs is the same, plus which of the two it turned out to be. What was
// copied is worth naming — a sheet shows a hostname and an address, and
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
// working that out a second time somewhere else is how two answers drift apart.
func (r vmRow) sshTargetIs() (target, kind string) {
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
return strings.TrimSpace(g.HostName), "hostname"
}
if ip := r.ip(); ip != "-" {
return ip, "address"
}
return "", ""
}
// defaultSSH is what `h` runs when the configuration says nothing.
const defaultSSH = "ssh root@%h"
// sshCommand is the command line to run, as argv: the configured template with
// the target put where %h stands, or appended when it does not stand anywhere.
//
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
// anything else is a second step once the session is up — and having it in the
// default means the common case needs no configuration file at all. A template
// of one's own overrides it entirely, root and all.
//
// The target is its own argument and never goes through a shell. It comes from
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
// and a guest that called itself `; rm -rf ~` would otherwise be running that
// on the operator's workstation. The template is the operator's own line out of
// their own configuration file, so it is split on spaces and no further:
// quoting is not supported, which is a limit worth having here.
func sshCommand(template, target string) []string {
if strings.TrimSpace(template) == "" {
template = defaultSSH
}
fields := strings.Fields(template)
argv := make([]string, 0, len(fields)+1)
placed := false
for _, f := range fields {
if strings.Contains(f, "%h") {
argv = append(argv, strings.ReplaceAll(f, "%h", target))
placed = true
continue
}
argv = append(argv, f)
}
if !placed {
argv = append(argv, target)
}
return argv
}
// vsphereURL is the machine's page in the vSphere client.
//
// The shape is the H5 client's own: the object's reference and the vCenter's
// instance UUID, which is the serverGuid that client puts in every link. The
// UUID cannot be worked out from the configuration — it is asked of the server
// on connecting (session.instanceUUID) — so a machine read over a connection
// that has gone has no URL rather than a wrong one.
func vsphereURL(r vmRow) string {
if r.sess == nil {
return ""
}
return vsphereLink(r.vc.URL, r.ref.Value, r.sess.instanceUUID())
}
// vsphereLink is the link itself, from the three things it is made of — so the
// shape can be checked without a server, which is the only way it can be
// checked at all: a wrong link opens a client that says "object not found",
// which looks like a vCenter problem rather than a gvm one.
func vsphereLink(vcURL, moref, guid string) string {
if vcURL == "" || moref == "" || guid == "" {
return ""
}
return SF("%s/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:%s:%s/summary",
strings.TrimSuffix(vcURL, "/"), moref, guid)
}
// openerCommand is how this operating system opens a URL. Nothing is opened
// where there is no answer rather than something being guessed at, and the
// caller says the URL out loud instead — which is the useful half anyway.
func openerCommand() string {
switch runtime.GOOS {
case "darwin":
return "open"
case "linux":
return "xdg-open"
}
return ""
}
// openURL hands the URL to the desktop and does not wait for it. A browser
// takes seconds to start and prints its own complaints; neither belongs in a
// full-screen list.
func openURL(url string) error {
opener := openerCommand()
if opener == "" {
return errf("no way to open a browser on %s", runtime.GOOS)
}
cmd := exec.Command(opener, url)
cmd.Stdout, cmd.Stderr = nil, nil
if err := cmd.Start(); err != nil {
return errf("cannot run %s: %w", opener, err)
}
go cmd.Wait() // reaped in the background; nothing here waits on a browser
return nil
}
// osc52 is the escape sequence that puts text in the clipboard of the terminal
// that is being looked at, wherever that terminal is running. It is the only way
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
// end of a login copies into the clipboard of a machine nobody is sitting at.
//
// It is also the way a terminal is free to ignore, and several do until they are
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
// is why it is not the only thing tried. See toClipboard.
func osc52(text string) string {
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
}
// toClipboard puts text where the next paste will find it and reports the way it
// got there, named — "pbcopy" — or empty when the escape sequence was the only
// thing on offer. The caller says so on the status line: a copy nobody can see
// happen is one that has to be described, or the only way to find out whether it
// worked is to paste somewhere and look.
//
// Both routes are used, because either alone leaves somebody with nothing: the
// local command always works where there is one, and the sequence is what
// carries the text home from the far end of an ssh login.
func (b *browser) toClipboard(text string) string {
b.write(osc52(text))
argv := clipTool()
if argv == nil {
return ""
}
if err := runClipTool(argv, text); err != nil {
return ""
}
return filepath.Base(argv[0])
}
// clipTool is the command that puts something in this machine's clipboard, where
// this is the machine the person is sitting at. Over an ssh login it is not:
// there the terminal's own sequence is the only route that ends up where the
// person can paste it, and a local clipboard would be the wrong machine's.
func clipTool() []string {
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
return nil
}
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
if runtime.GOOS == "darwin" {
candidates = [][]string{{"pbcopy"}}
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
// A Linux console or a machine with no session to speak of: there is
// nothing for xclip to hand the text to, and it would sit there waiting.
return nil
}
for _, c := range candidates {
if path, err := exec.LookPath(c[0]); err == nil {
return append([]string{path}, c[1:]...)
}
}
return nil
}
// runClipTool feeds the text to it on standard input, which is how all of them
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
// hostname into a pasted hostname and a return.
func runClipTool(argv []string, text string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
// runInTerminal gives the terminal back, runs a command in it, and takes it
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
// and the keyboard.
//
// The keystroke reader is rebuilt on the way back in: the one that was running
// is reading a file descriptor that closing the terminal has taken away from
// it, and its goroutine ends when that read fails.
func (b *browser) runInTerminal(argv []string) error {
if len(argv) == 0 {
return errf("nothing to run")
}
b.close() // clears the screen, puts the cursor back, hands the tty back
release := holdTerminalSignals()
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
err := cmd.Run()
release()
if err != nil && !interrupted(err) {
// Something to read: the message would be wiped by the next frame, so
// the screen is held until somebody has seen it.
PF("\n%s %v\n", Crb(argv[0]+":"), err)
PF("%s", Cd("press enter to come back to gvm "))
os.Stdin.Read(make([]byte, 1))
}
// Coming back in. If the terminal cannot be taken again there is nothing
// left to draw on, so it is said here, in the ordinary terminal that is
// still on screen — and the loop ends on its own: the reader that was
// running is reading a closed descriptor and reports that as a Ctrl-C.
if oerr := b.open(); oerr != nil {
PE("cannot take the terminal back", oerr.Error())
return oerr
}
return err
}
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
// the list one was going back to was not.
//
// They are caught rather than ignored, and the difference matters: exec resets a
// caught signal to its default in the child, while an ignored one is inherited.
// An ssh that cannot be interrupted while it hangs on a machine that is not
// answering would be worse than what this fixes.
//
// The returned func puts them back the way they were, which is gvm's own raw
// mode reading Ctrl-C as a key like any other.
func holdTerminalSignals() func() {
// Buffered and never read: the signal package sends without blocking and
// drops what does not fit, which is the whole intent — these are being
// swallowed, not handled.
ch := make(chan os.Signal, 4)
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
return func() { signal.Stop(ch) }
}
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
// Ctrl-\) rather than because something went wrong. Nothing is held on the
// screen for it: the person who pressed it knows what happened and wants to be
// back in the list, not reading that ssh got a signal.
func interrupted(err error) bool {
var exit *exec.ExitError
if !errors.As(err, &exit) {
return false
}
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
}
// A shell between gvm and the signal reports it as its own exit status
// instead, in the shells' 128+signal spelling.
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
}
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The name the guest calls itself is what is in the known hosts file; the
// address is the fallback, and a machine whose guest says nothing has neither.
func TestSSHTarget(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := r.sshTarget(); got != "web01.example" {
t.Errorf("sshTarget = %q, want the hostname", got)
}
r.vm.Guest.HostName = ""
if got := r.sshTarget(); got != "10.0.0.5" {
t.Errorf("with no hostname, sshTarget = %q", got)
}
r.vm.Guest = nil
if got := r.sshTarget(); got != "" {
t.Errorf("with no guest information at all, sshTarget = %q", got)
}
}
func TestSSHCommand(t *testing.T) {
for _, c := range []struct {
template string
want []string
}{
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
{"mosh %h", []string{"mosh", "web01"}},
{"ssh root@%h", []string{"ssh", "root@web01"}},
} {
if got := sshCommand(c.template, "web01"); !slices.Equal(got, c.want) {
t.Errorf("sshCommand(%q) = %v, want %v", c.template, got, c.want)
}
}
}
// A clipboard is invisible, so what the status line says about it has to be
// exact: which of the two the machine gave up — the hostname or the address —
// and which clipboard it went into.
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
// Pretending to be an ssh login does two things: it is the case where the
// escape sequence is the only route, and it keeps the tests off the
// clipboard of whoever is running them.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
for _, c := range []struct {
what string
row func() vmRow
want []string
}{
{"a guest that reports its name", func() vmRow {
return testRow("web01", true, "10.0.0.5")
}, []string{"hostname", "web01.example"}},
{"a guest that reports only an address", func() vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.HostName = ""
return r
}, []string{"address", "10.0.0.5"}},
} {
r := c.row()
b := &browser{rows: []vmRow{r}, view: []int{0}}
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = pw
b.copyAddress(r)
pw.Close()
sent, _ := io.ReadAll(pr)
pr.Close()
for _, want := range c.want {
if !strings.Contains(b.status, want) {
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
}
}
// And the sequence carried the same string, base64 and all.
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
if !strings.Contains(string(sent), payload) {
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
}
}
}
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
// far end of a login copies into the clipboard of a machine nobody is sitting
// at, and the terminal's own sequence is the only route home.
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
t.Setenv("SSH_CONNECTION", "")
t.Setenv("SSH_TTY", "/dev/ttys004")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
}
// Whatever the tool is, it takes the text on standard input and gets it verbatim
// — no trailing newline, which in a clipboard turns a pasted hostname into a
// pasted hostname and a return.
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
out := filepath.Join(t.TempDir(), "clipboard")
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
t.Fatalf("runClipTool: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(got) != "web01.example" {
t.Errorf("the clipboard would get %q", got)
}
}
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
// reads but a SIGINT to the whole foreground process group, and gvm is in that
// group. While a child has the screen the signal has to be caught and dropped:
// were it not, this test would kill the test binary rather than fail.
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
release := holdTerminalSignals()
defer release()
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatalf("cannot send myself a %v: %v", sig, err)
}
}
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
time.Sleep(50 * time.Millisecond)
// And the child must still die of it, which is why the signal is caught and
// not ignored: exec resets a caught signal to its default in the child,
// while an ignored one is inherited — signal.Ignore here would leave an ssh
// that cannot be interrupted while it hangs on a machine that is not
// answering.
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
}
}
// And a child that died of that keystroke is told apart from one that failed, so
// the screen is not held with "signal: interrupt" over something somebody meant
// to do.
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
for _, c := range []struct {
script string
want bool
}{
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
{"exit 1", false}, // a remote command that failed
{"exit 255", false}, // ssh's own "could not connect"
{"exit 0", false}, // nothing wrong at all
} {
err := exec.Command("sh", "-c", c.script).Run()
if got := interrupted(err); got != c.want {
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
}
}
// Something that never got as far as a child at all is not an interruption.
if interrupted(errf("nothing to run")) {
t.Error("a plain error was taken for a Ctrl-C")
}
}
// The target is one argument and never a piece of a shell command. It comes
// from the guest — a name the guest chose for itself — so a machine that called
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
// as a command that runs.
func TestTheTargetIsNeverShellCode(t *testing.T) {
nasty := "; rm -rf ~"
got := sshCommand("ssh -l root %h", nasty)
if len(got) != 4 || got[3] != nasty {
t.Fatalf("the target was taken apart: %v", got)
}
for _, arg := range got[:3] {
if strings.Contains(arg, "rm") {
t.Errorf("the target leaked into %q", arg)
}
}
}
// The link is the H5 client's own shape: the object's reference and the
// vCenter's instance UUID, which is the serverGuid that client wants.
func TestVsphereLink(t *testing.T) {
got := vsphereLink("https://v308.example/", "vm-42", "6ff1a05e-1111")
want := "https://v308.example/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:vm-42:6ff1a05e-1111/summary"
if got != want {
t.Errorf("vsphereLink =\n %s\nwant\n %s", got, want)
}
// A missing piece gives no link rather than a wrong one: a link that opens
// a client saying "object not found" looks like a vCenter fault.
for _, c := range [][3]string{
{"", "vm-42", "guid"}, {"https://v308.example", "", "guid"}, {"https://v308.example", "vm-42", ""},
} {
if got := vsphereLink(c[0], c[1], c[2]); got != "" {
t.Errorf("vsphereLink(%q, %q, %q) = %q, want nothing", c[0], c[1], c[2], got)
}
}
}
// A machine that was read over a connection that has gone has no link.
func TestVsphereURLNeedsTheConnection(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := vsphereURL(r); got != "" {
t.Errorf("a row with no session produced %q", got)
}
}
// The clipboard is the terminal's, not the machine's: gvm is run over ssh as
// often as not, and pbcopy would then copy into a clipboard nobody is looking
// at. This is the escape sequence that asks the terminal itself.
func TestOsc52CarriesTheTextItself(t *testing.T) {
got := osc52("10.0.0.5")
if !strings.HasPrefix(got, "\x1b]52;c;") || !strings.HasSuffix(got, "\a") {
t.Fatalf("osc52 = %q", got)
}
payload := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]52;c;"), "\a")
back, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("the payload is not base64: %v", err)
}
if string(back) != "10.0.0.5" {
t.Errorf("the clipboard would get %q", back)
}
}
// The sheet says why a key cannot do anything rather than swallowing it, and the
// two that need somewhere to connect to say exactly that.
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest = nil
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
b := &browser{rows: []vmRow{r}, view: []int{0}}
for _, k := range []rune{'h', 'y'} {
b.setStatus("", "")
b.detailRune(k)
if !strings.Contains(b.status, "no address") {
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
}
}
// With an address there is nothing to object to. Only the check is asked
// here — what follows it is an ssh session and a clipboard.
b.rows[0] = testRow("web01", true, "10.0.0.5")
if !b.hasAddress(b.rows[0]) {
t.Errorf("a machine with an address was refused: %s", b.status)
}
}
// Every letter in the menu reaches exactly one entry, or one of them is
// unreachable — and the snapshot half must not have taken a letter the
// power half already uses.
func TestMenuLettersAreDistinct(t *testing.T) {
b := &browser{}
seen := map[rune]string{}
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.isSeparator() {
continue
}
if other, ok := seen[m.key]; ok {
t.Errorf("%q is the letter for both %q and %q", string(m.key), other, m.label)
}
seen[m.key] = m.label
}
}
+183 -15
View File
@@ -37,6 +37,9 @@ var helpTail = strings.Join([]string{
" --update Fetch the newest release and replace this binary",
" --check-update Look for a newer release, change nothing",
"",
" Shell completion:",
" gvm completion zsh The completion script, machine names included",
"",
" Run 'gvm' with no subcommand to browse the machines interactively.",
}, "\n")
@@ -44,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.0.0"
var version = "1.3.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -53,6 +56,16 @@ func main() {
if updateFlags() {
return
}
// The two options the completion scripts call on every Tab, answered here
// for the same reason: they have to work where the configuration does not,
// they must print nothing but the candidates, and they must not read
// ~/.gvmrc — reading it seals a password standing in it in the clear, and a
// Tab key has no business rewriting a file. (`gvm completion <shell>`, which
// is what writes those scripts, is answered in run(): it needs the flag
// parser to have been built first.)
if completionFlags() {
return
}
err := run()
updateNote() // after the output: a footer, not a headline
@@ -71,7 +84,7 @@ func run() error {
var vcname string
var yes bool
flaggy.String(&vcname, "v", "vcenter", "vCenter to work on (default: 'default' from ~/.gvmrc)")
flaggy.String(&vcname, "v", "vcenter", "vCenter to work on, or several separated by commas for the machine list (default: 'default' from ~/.gvmrc)")
flaggy.Bool(&yes, "y", "yes", "Answer the confirmation of a destructive command in advance (for cron)")
var vmList, vmInteractive bool
@@ -82,14 +95,18 @@ func run() error {
subVM.Bool(&vmInteractive, "i", "interactive", "Browse the machines (the default)")
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
var vmSort string
var vmReverse bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, power, cpu%, mem%, size, cpus, vc, host, ip")
var vmReverse, vmIssues, vmJSON bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, snaps, old, why, size, cpus, vc, host, ip")
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
subVM.Bool(&vmIssues, "", "issues", "Only the machines with something wrong with them (^w in the list)")
subVM.Bool(&vmJSON, "", "json", "Print the listing as a JSON document instead of a table")
// The destructive options deliberately have no short letter: --revert and
// --removeall have to be spelled out, so neither can be reached by a slip of
// one key next to a harmless one.
var snapLs, snapNew_, snapRm, snapRmAll, snapRevertTo, snapName string
var snapOld, snapMail bool
snapDays := snapOldDays
subSnap := flaggy.NewSubcommand("snap")
subSnap.Description = "Snapshot commands"
subSnap.String(&snapLs, "l", "list", "List the snapshots of <vm>")
@@ -98,6 +115,12 @@ func run() error {
subSnap.String(&snapRm, "r", "remove", "Remove one snapshot of <vm>, named with -s")
subSnap.String(&snapRevertTo, "", "revert", "Revert <vm> to the snapshot named with -s (destroys everything since)")
subSnap.String(&snapRmAll, "", "removeall", "Remove all snapshots of <vm>")
// The age report reads every server at once, like `vm -l` and unlike the
// rest of this subcommand: housekeeping is a question about the estate, not
// about one vCenter. -v still narrows it.
subSnap.Bool(&snapOld, "", "old", "Report the snapshots older than -d days, on every vCenter")
subSnap.Int(&snapDays, "d", "days", "How old is old, for --old")
subSnap.Bool(&snapMail, "m", "mail", "Mail that report as well")
var pwOn, pwShutdown, pwReboot, pwOff, pwReset string
subPower := flaggy.NewSubcommand("power")
@@ -108,12 +131,50 @@ 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 — or 'auto' to have the site's dns tool hand one out")
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"
subHost.Bool(&hostCount, "c", "count", "Only the machine counts per host")
subHost.Bool(&hostTelemetry, "t", "telemetry", "Also post the numbers to the monitoring server")
var dsTelemetry bool
subDS := flaggy.NewSubcommand("ds")
subDS.Description = "Datastore commands"
subDS.Bool(&dsTelemetry, "t", "telemetry", "Also post the numbers to the monitoring server")
var logShow, logMail bool
logMinutes := 60
subLog := flaggy.NewSubcommand("log")
@@ -122,16 +183,37 @@ func run() error {
subLog.Bool(&logMail, "m", "mail", "Mail the log as well")
subLog.Int(&logMinutes, "t", "time", "How many minutes back to look")
var cfgPassword string
subConfig := flaggy.NewSubcommand("config")
subConfig.Description = "Show the effective configuration"
subConfig.String(&cfgPassword, "p", "password", "Set the password of <vcenter>, asked for and stored sealed")
flaggy.AttachSubcommand(subVM, 1)
flaggy.AttachSubcommand(subSnap, 1)
flaggy.AttachSubcommand(subPower, 1)
flaggy.AttachSubcommand(subSize, 1)
flaggy.AttachSubcommand(subNew, 1)
flaggy.AttachSubcommand(subHost, 1)
flaggy.AttachSubcommand(subDS, 1)
flaggy.AttachSubcommand(subLog, 1)
flaggy.AttachSubcommand(subConfig, 1)
// `gvm completion <shell>` is flaggy's own subcommand — it is in the help
// because flaggy puts it there, and flaggy would answer it inside Parse.
// It is answered here first, one step earlier, so that the script it writes
// can carry the machine names as well (complete.go). A shell this does not
// know falls through to flaggy, whose message names the ones it can write.
//
// Before Parse and before loadConfig, deliberately: a Tab key must not read
// ~/.gvmrc, because reading it seals any password standing in it in the
// clear, and a completion has no business rewriting a file.
if shell, ok := completionRequest(os.Args[1:]); ok {
if script, ok := completionScript(shell, flaggyCompletion(shell)); ok {
PF("%s", script)
return nil
}
}
flaggy.Parse()
cfg := loadConfig()
@@ -142,12 +224,26 @@ func run() error {
if err != nil {
return err
}
if vmList { // -l prints; anything else browses
return lsvm(targets, vmMatch, vmSort, vmReverse)
// --json and --issues are ways of printing, so they mean -l without
// having to be told twice: browsing a machine list as JSON is not a
// thing, and a full-screen list has ^w for the other one.
if vmList || vmJSON || vmIssues {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch)
return browseVMs(targets, vmMatch, cfg)
case subSnap.Used:
if snapOld {
targets, err := cfg.targets(vcname)
if err != nil {
return err
}
if snapDays < 0 {
return errf("-d wants a number of days, not %d", snapDays)
}
return snapOldReport(cfg, targets, snapDays, snapMail)
}
vc, err := cfg.pick(vcname)
if err != nil {
return err
@@ -204,6 +300,43 @@ 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>")
}
// The flags are the decision here, so the road is read off them: --spec
// names one the vCenter holds, and either of the other two on their own
// means gvm writes it from the configuration.
how := customNone
switch {
case newSpec != "":
how = customSpec
case newIP != "" || newHostname != "":
how = customSite
}
return deployCLI(vc, newFrom, newName, deployOpts{
host: newHost, datastore: newStore, powerOn: newOn, how: how,
spec: newSpec, ip: newIP, hostname: newHostname,
st: cfg.site(), dns: dnsTool(cfg.DNSTool),
}, yes)
case subHost.Used:
vc, err := cfg.pick(vcname)
if err != nil {
@@ -212,15 +345,23 @@ func run() error {
if hostCount {
return vmstat(vc)
}
telemetry := ""
if hostTelemetry {
if cfg.Telemetry == "" {
return errf("no 'telemetry' url in %s", configFile())
}
telemetry = cfg.Telemetry
telemetry, err := cfg.telemetryURL(hostTelemetry)
if err != nil {
return err
}
return hoststat(vc, telemetry)
case subDS.Used:
vc, err := cfg.pick(vcname)
if err != nil {
return err
}
telemetry, err := cfg.telemetryURL(dsTelemetry)
if err != nil {
return err
}
return dsstat(vc, telemetry)
case subLog.Used:
if !logShow && !logMail {
flaggy.ShowHelpAndExit("")
@@ -235,6 +376,9 @@ func run() error {
return vmlog(cfg, vc, logMinutes, logMail)
case subConfig.Used:
if cfgPassword != "" {
return setPassword(cfg, cfgPassword)
}
return showConfig(cfg)
}
@@ -249,7 +393,7 @@ func run() error {
if err != nil {
return err
}
return browseVMs(targets, "")
return browseVMs(targets, "", cfg)
}
// showConfig prints what gvm made of ~/.gvmrc and the environment. Passwords
@@ -268,7 +412,7 @@ func showConfig(cfg Config) error {
mark = Cgb(" (default)") + mark
}
PF("%-6s %s%s\n", Cwb(v.Name), v.URL, mark)
PF(" user %s, datacenter %s, password set\n", v.User, v.Datacenter)
PF(" user %s, datacenter %s, %s\n", v.User, v.Datacenter, passwordState(v))
}
for _, bad := range incomplete {
PF("%s %s\n", Crb("unusable"), bad)
@@ -280,10 +424,34 @@ func showConfig(cfg Config) error {
P()
PF("mail %s -> %s via %s:%d\n", orNone(cfg.MailFrom), orNone(cfg.MailTo), orNone(cfg.SMTPHost), cfg.smtpPort())
PF("telemetry %s\n", orNone(cfg.Telemetry))
PF("ssh %s\n", orNone(strings.Join(sshCommand(cfg.SSH, "<machine>"), " ")))
PF("new guest %s\n", cfg.site().describe())
// Said whether it is there or not: "why was I not offered an address" is
// the question that follows an option quietly not being there, and this is
// where it is answered.
tool := dnsTool(cfg.DNSTool)
if tool == "" {
tool = "none found — 'gvm new --ip auto' wants one, as 'dns' on the path or dnstool here"
}
PF("dns tool %s\n", tool)
PF("completion %s\n", inventoryAge())
PF("version %s\n", version)
return nil
}
// passwordState says what the password is without saying what it is. A sealed one
// is opened and thrown away, because this is the command run after setting things
// up and "it is sealed" is worth nothing if it does not open.
func passwordState(v VCenter) string {
if !sealed(v.Password) {
return Cyb("password in the clear — it is sealed on the next run")
}
if _, err := v.password(); err != nil {
return Crb("password sealed but it does not open — set it again with 'gvm config -p " + v.Name + "'")
}
return "password sealed"
}
func orNone(s string) string {
if s == "" {
return "-"
+17 -1
View File
@@ -26,10 +26,26 @@ func TestHelpMentionsEveryUpdateOptionItAnswers(t *testing.T) {
}
}
// The completion subcommand is documented too — flaggy lists it as well,
// but not that the script it writes carries machine names. The two options
// the scripts themselves call are not documented, the same way the
// background refresh is not: nobody types --complete-vms.
if !strings.Contains(helpTail, "completion") {
t.Error("gvm answers the completion subcommand but the help does not mention it")
}
for _, f := range []string{"--complete-vms", "--complete-vcenters"} {
if strings.Contains(helpTail, f) {
t.Errorf("%s is called by the completion script and has no business in the help", f)
}
}
// And nothing is promised that is not answered.
for _, line := range strings.Split(helpTail, "\n") {
for _, word := range strings.Fields(line) {
if strings.HasPrefix(word, "--") && !isUpdateFlag(word) {
if !strings.HasPrefix(word, "--") {
continue
}
if !isUpdateFlag(word) && !isCompletionFlag(word) {
t.Errorf("the help offers %s, which nothing answers", word)
}
}
+62 -8
View File
@@ -9,9 +9,23 @@
#
# The file holds vCenter passwords, so it wants to be mode 0600 — gvm creates it
# that way and complains when it finds it readable by others.
#
# A password written here in the clear is sealed on the next run of gvm and
# replaced by a "gvmenc1:..." word, so it does not stand in this file where a
# backup, a synced home directory or an editor's swap file would pick it up.
# "gvm config -p v308" asks for one instead and writes it sealed straight away,
# which is the way to set one without it ever being on disk in the clear.
#
# What that is: the password is not in plain sight. What it is not: a vault.
# The key is compiled into gvm and is the same in every copy, so whoever holds
# this file *and* a gvm binary can open the value. The 0600 is what keeps other
# users out.
# The vCenter used when -v is not given. `gvm vm -l` ignores it and asks every
# configured server; every other command works on exactly one.
# The vCenter used when -v is not given. `gvm` and `gvm vm -l` ignore it and ask
# every configured server; the rest work on exactly one.
#
# -v takes a list for the commands that sweep — `-v v308,v108` — and refuses one
# for the commands that act on a single machine.
default = v308
# --- one 'vcenter.<name>.*' block per server ---
@@ -27,7 +41,7 @@ default = v308
vcenter.v308.url = https://v308.fhi.mpg.de/
vcenter.v308.user = administrator@v308.fhi.mpg.de
vcenter.v308.password = <password>
vcenter.v308.password = <password> # sealed on the next run
vcenter.v308.datacenter = PPB
vcenter.v308.insecure = true
@@ -43,22 +57,62 @@ vcenter.v38.password = <password>
vcenter.v38.datacenter = FEL
vcenter.v38.insecure = true
# --- mail, for `gvm log -m` ---
# Without these, `gvm log -m` says so before it queries anything.
# --- mail, for `gvm log -m` and `gvm snap --old -m` ---
# Without these, both say so before they query anything.
mailfrom = root@fhi.mpg.de
mailto = mw@pstbx.org
smtphost = m0.fhi-berlin.mpg.de
smtpport = 25
# --- telemetry, for `gvm host -t` ---
# Where the per-host numbers are posted. Unset (or without -t) nothing is sent.
# --- telemetry, for `gvm host -t` and `gvm ds -t` ---
# Where the per-host and per-datastore numbers are posted. Unset (or without
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# --- 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
#
# The address can be fetched instead of typed, where the site has a tool for it:
#
# gvm new --from ubuntu-tpl --name web05 --ip auto
#
# gvm looks for "dns" on the path and offers that only where it finds one; name
# it here where it lives somewhere else. It is asked for an address under the
# machine's own name, and the address is given back if the deployment does not
# happen after all.
# dnstool = /usr/local/bin/dns
# --- ssh, for the sheet's 'h' ---
# The command that logs in to a machine's guest from its sheet. "%h" is where
# the guest's own hostname — or its address, when it reports no name — is put;
# it is appended when %h is not written anywhere. Unset means "ssh root@%h",
# which is what one logs in to these machines as; a line here replaces it whole,
# root and all.
#
# The target is always one argument and never goes through a shell: it is a name
# the guest chose for itself, and gvm does not run it as a command.
# ssh = ssh -l someone %h
# --- the same settings from the environment ---
# Every setting above has an environment spelling that wins over the file:
#
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
# GVM_TELEMETRY
# GVM_TELEMETRY, GVM_SSH, GVM_DOMAIN, GVM_DNS, GVM_NETMASK, GVM_GATEWAY,
# GVM_TIMEZONE, GVM_DNSTOOL
# 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,
+210
View File
@@ -0,0 +1,210 @@
// issues.go — the machines that want looking at.
//
// A list of two hundred machines is read by running the eye down it, which is
// exactly the wrong way to find the three that are broken: a lost VMware Tools,
// a filesystem at 97 %, a snapshot from March, a machine sitting on a question
// nobody has answered. Every one of those facts is already in the inventory
// sweep and none of them is visible in a table sorted by name.
//
// So this is not a new question put to the vCenters — it is a filter over the
// answer they have already given (^i in the list, `vm -l --issues` on the
// command line), and each machine carries the reason it is in the list.
//
// What counts as an issue is deliberately narrow. A list that cries wolf is one
// nobody opens, so a machine that is switched off is not an issue, a machine
// without VMware Tools is only worth a word while it is running, and a snapshot
// is only old once it has stopped being anybody's afternoon.
package main
import (
"strings"
"github.com/vmware/govmomi/vim25/types"
)
// When a guest filesystem is worth naming. Ninety per cent is where a disk
// stops having room for a surprise; ninety-five is where it stops having room.
// Small partitions sit legitimately close to full — /boot on a Debian is a
// perennial 92 % — which is why the mount point is always named with the figure
// rather than the machine merely being flagged.
const (
fsWarnPct = 90.0
fsBadPct = 95.0
)
// issue is one reason a machine is in the list. bad separates "this is broken"
// from "this wants a look" — the colours of the two are the palette's red and
// yellow, and the order they are reported in is worst first, because the column
// they end up in is the one that gets truncated.
type issue struct {
text string
bad bool
}
// issueList is everything gvm has to say against this machine, worst first.
//
// Everything here is read off the row as the sweep left it. Nothing in this
// function may go to the network: it is called for every machine in the
// inventory, for the table, for the filter and for the report.
func (r vmRow) issueList() []issue {
var bad, warn []issue
add := func(isBad bool, format string, a ...any) {
i := issue{text: SF(format, a...), bad: isBad}
if isBad {
bad = append(bad, i)
return
}
warn = append(warn, i)
}
rt := r.vm.Summary.Runtime
// vCenter cannot see the machine properly. Everything below this line is a
// statement about a machine vSphere is in touch with, so this comes first.
switch rt.ConnectionState {
case types.VirtualMachineConnectionStateConnected, "":
default:
add(true, "%s", string(rt.ConnectionState))
}
// A machine stopped on a question is stopped until somebody answers it, and
// nothing in the ordinary table says so.
if rt.Question != nil {
add(true, "waiting for an answer in vCenter")
}
// Delta disks left behind by a snapshot removal that did not finish. The
// machine runs perfectly well and grows quietly until the datastore is full.
if rt.ConsolidationNeeded {
add(true, "disks need consolidating")
}
// What vCenter itself is complaining about. Its own alarms are the best
// answer to "is something wrong", so they are passed on rather than
// second-guessed — by the name a person gave the alarm, never by its number.
alarms := 0
for _, a := range r.vm.TriggeredAlarmState {
if a.Acknowledged != nil && *a.Acknowledged {
continue // somebody has seen it and said so
}
switch a.OverallStatus {
case types.ManagedEntityStatusRed:
add(true, "alarm: %s", r.alarmLabel(a.Alarm))
alarms++
case types.ManagedEntityStatusYellow:
add(false, "alarm: %s", r.alarmLabel(a.Alarm))
alarms++
}
}
// The overall status is the rollup of those alarms. It is only worth a line
// of its own when no alarm came with it — otherwise the same fact would be
// reported twice, once with a reason and once without.
if alarms == 0 {
switch r.vm.Summary.OverallStatus {
case types.ManagedEntityStatusRed:
add(true, "vCenter says red")
case types.ManagedEntityStatusYellow:
add(false, "vCenter says yellow")
}
}
if r.running() {
if !r.toolsRunning() {
add(false, "no VMware Tools")
}
for _, d := range r.fullDisks() {
add(d.pct >= fsBadPct, "%s %.0f %% full", d.path, d.pct)
}
}
// An old snapshot is the one issue here that is nobody's fault and
// everybody's job. The table's colours change at a week; the report only
// names one once it is a month old, so this list stays worth reading.
if e, ok := r.oldest(); ok {
if days := e.days(); days >= snapOldDays {
add(false, "snapshot %s is %s old", e.name, plural(days, "day"))
}
}
return append(bad, warn...)
}
// fullDisk is one guest filesystem that is nearly full.
type fullDisk struct {
path string
pct float64
}
// fullDisks are the guest's filesystems worth naming. The figures come from
// VMware Tools, so a machine without it simply has none — which is not the same
// as having none that are full, and is why the absence of Tools is its own line.
func (r vmRow) fullDisks() []fullDisk {
g := r.vm.Guest
if g == nil {
return nil
}
var out []fullDisk
for _, d := range g.Disk {
if d.Capacity <= 0 {
continue
}
pct := 100.0 - 100.0/float64(d.Capacity)*float64(d.FreeSpace)
if pct >= fsWarnPct {
out = append(out, fullDisk{path: d.DiskPath, pct: pct})
}
}
return out
}
// alarmLabel is the alarm's own name, or its reference when the names could not
// be read. "alarm-14 is red" is not something anybody can act on, but it is
// still better than not saying that something is.
func (r vmRow) alarmLabel(ref types.ManagedObjectReference) string {
if r.sess != nil {
if name := r.sess.alarms[ref]; name != "" {
return name
}
}
return ref.Value
}
// issues is the reasons as plain text, worst first.
func (r vmRow) issues() []string {
list := r.issueList()
out := make([]string, 0, len(list))
for _, i := range list {
out = append(out, i.text)
}
return out
}
func (r vmRow) hasIssues() bool { return len(r.issueList()) > 0 }
// issueCell is the WHY column: every reason, worst first, in one line for the
// column to truncate from the right. Truncation is why the order matters.
func (r vmRow) issueCell() string { return strings.Join(r.issues(), " · ") }
// issueColor paints the row's worst reason: red where something is broken,
// yellow where something wants a look.
func (r vmRow) issueColor() string {
for _, i := range r.issueList() {
if i.bad {
return colFull
}
}
if len(r.issueList()) > 0 {
return colBusy
}
return colOff
}
// withIssues is the filter itself.
func withIssues(rows []vmRow) []vmRow {
out := make([]vmRow, 0, len(rows))
for _, r := range rows {
if r.hasIssues() {
out = append(out, r)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// aged builds a snapshot entry that was taken so many days ago.
func aged(name string, days int) snapEntry {
when := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
return snapEntry{
ref: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "snapshot-" + name},
name: name,
when: when,
created: when.Local().Format("02.01.2006 15:04"),
}
}
// A machine with nothing wrong with it says nothing. This is the one that
// matters: the whole point of the filter is that it is short.
func TestAHealthyMachineHasNoIssues(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if got := r.issues(); len(got) > 0 {
t.Errorf("a healthy machine reported %v", got)
}
if r.hasIssues() {
t.Error("a healthy machine is in the issues list")
}
if r.issueColor() != colOff {
t.Error("a healthy machine's reason is coloured as though it had one")
}
}
// A machine that is switched off is not a fault, and the things that are only
// true of a running machine are not held against a stopped one.
func TestAStoppedMachineIsNotAnIssue(t *testing.T) {
r := testRow("web01", false, "10.0.0.5")
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100, FreeSpace: 1}}
if got := r.issues(); len(got) > 0 {
t.Errorf("a stopped machine reported %v", got)
}
}
func TestIssuesFound(t *testing.T) {
for _, c := range []struct {
what string
bend func(*vmRow)
want string
bad bool
}{
{"disconnected", func(r *vmRow) {
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateDisconnected
}, "disconnected", true},
{"orphaned", func(r *vmRow) {
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateOrphaned
}, "orphaned", true},
{"a question", func(r *vmRow) {
r.vm.Summary.Runtime.Question = &types.VirtualMachineQuestionInfo{Id: "1"}
}, "waiting for an answer", true},
{"consolidation", func(r *vmRow) {
r.vm.Summary.Runtime.ConsolidationNeeded = true
}, "consolidating", true},
{"a red status", func(r *vmRow) {
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
}, "vCenter says red", true},
{"a yellow status", func(r *vmRow) {
r.vm.Summary.OverallStatus = types.ManagedEntityStatusYellow
}, "vCenter says yellow", false},
{"no tools", func(r *vmRow) {
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{ToolsRunningStatus: "guestToolsNotRunning"}
}, "no VMware Tools", false},
{"a full filesystem", func(r *vmRow) {
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/var", Capacity: 100 << 30, FreeSpace: 3 << 30}}
}, "/var 97 % full", true},
{"a nearly full filesystem", func(r *vmRow) {
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/boot", Capacity: 100 << 30, FreeSpace: 8 << 30}}
}, "/boot 92 % full", false},
{"an old snapshot", func(r *vmRow) {
r.snaps = []snapEntry{aged("before-patch", 63)}
}, "before-patch is 63 days old", false},
} {
r := testRow("web01", true, "10.0.0.5")
c.bend(&r)
list := r.issueList()
found := false
for _, i := range list {
if strings.Contains(i.text, c.want) {
found = true
if i.bad != c.bad {
t.Errorf("%s: bad = %v, want %v (%q)", c.what, i.bad, c.bad, i.text)
}
}
}
if !found {
t.Errorf("%s: nothing said %q, only %v", c.what, c.want, r.issues())
}
if !r.hasIssues() {
t.Errorf("%s: the machine is not in the issues list", c.what)
}
}
}
// A filesystem that is merely fairly full is nobody's business.
func TestAFilesystemWithRoomIsNotReported(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100 << 30, FreeSpace: 20 << 30}}
if got := r.issues(); len(got) > 0 {
t.Errorf("a filesystem at 80 %% reported %v", got)
}
}
// A snapshot is only old once it has stopped being somebody's afternoon. The
// table colours it yellow after a week; the list of things to answer for waits
// for a month, or it fills up with this morning's work.
func TestOnlyAMonthOldSnapshotIsAnIssue(t *testing.T) {
for _, c := range []struct {
days int
want bool
}{{2, false}, {snapStaleDays + 1, false}, {snapOldDays, true}, {90, true}} {
r := testRow("web01", true, "10.0.0.5")
r.snaps = []snapEntry{aged("s", c.days)}
if got := r.hasIssues(); got != c.want {
t.Errorf("a snapshot of %d days: reported = %v, want %v (%v)",
c.days, got, c.want, r.issues())
}
}
}
// vCenter's own alarms are passed on by the name somebody gave them, and an
// alarm that has been acknowledged has been dealt with by a person already.
func TestAlarms(t *testing.T) {
ref := types.ManagedObjectReference{Type: "Alarm", Value: "alarm-14"}
yes := true
r := testRow("web01", true, "10.0.0.5")
r.vm.TriggeredAlarmState = []types.AlarmState{
{Alarm: ref, OverallStatus: types.ManagedEntityStatusRed},
}
// Without the names, the reference is still said: it is little use, but it
// is not silence.
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "alarm-14") {
t.Errorf("an alarm with no name resolved reported %q", got)
}
r.sess = &session{alarms: map[types.ManagedObjectReference]string{ref: "Host memory usage"}}
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "Host memory usage") {
t.Errorf("the alarm's name was not used: %q", got)
}
r.vm.TriggeredAlarmState[0].Acknowledged = &yes
if got := r.issues(); len(got) > 0 {
t.Errorf("an acknowledged alarm still reported %v", got)
}
}
// The rolled-up status is not reported next to the alarm it is the rollup of:
// the same fact twice, once with a reason and once without.
func TestTheStatusIsNotReportedTwice(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
r.vm.TriggeredAlarmState = []types.AlarmState{{
Alarm: types.ManagedObjectReference{Type: "Alarm", Value: "alarm-1"},
OverallStatus: types.ManagedEntityStatusRed,
}}
if got := r.issues(); len(got) != 1 {
t.Errorf("a red machine with one alarm reported %d things: %v", len(got), got)
}
if got := strings.Join(r.issues(), " "); strings.Contains(got, "says red") {
t.Errorf("the rollup was reported beside its own alarm: %q", got)
}
}
// Worst first, because the column they end up in is truncated from the right.
func TestTheWorstReasonComesFirst(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // a word of warning
r.vm.Summary.Runtime.ConsolidationNeeded = true // broken
list := r.issueList()
if len(list) < 2 {
t.Fatalf("expected both reasons, got %v", r.issues())
}
if !list[0].bad {
t.Errorf("the reasons came out warning first: %v", r.issues())
}
if r.issueColor() != colFull {
t.Error("a machine with something broken is not painted as broken")
}
// Only a warning: yellow, not red.
r.vm.Summary.Runtime.ConsolidationNeeded = false
if r.issueColor() != colBusy {
t.Error("a machine with only a warning is painted as broken")
}
if cell := r.issueCell(); !strings.Contains(cell, "no VMware Tools") {
t.Errorf("the reason column says %q", cell)
}
}
func TestWithIssuesKeepsOnlyTheOnesToAnswerFor(t *testing.T) {
good := testRow("web01", true, "10.0.0.5")
bad := testRow("db01", true, "10.0.0.6")
bad.vm.Summary.Runtime.ConsolidationNeeded = true
got := withIssues([]vmRow{good, bad})
if len(got) != 1 || got[0].name != "db01" {
t.Errorf("the filter kept %d machines: %v", len(got), got)
}
}
+180
View File
@@ -0,0 +1,180 @@
// jsonout.go — the machine listing as a document.
//
// `gvm vm -l` is meant to be read; this is the same sweep meant to be parsed —
// by a monitoring check, a report, a spreadsheet. So the shape here is a
// promise, and two decisions follow from that.
//
// It is one object and not an array of machines, because a listing that leaves
// out a vCenter which did not answer is worse than no listing at all: a script
// handed a bare array cannot tell an empty cluster from an unreachable one. The
// servers that answered and the ones that did not are part of the document.
//
// And a figure that is not known is null, never zero. A stopped machine has no
// processor load, a machine whose guest is silent has no address, and a
// spreadsheet that averages a column of zeroes reports a fleet that is idle.
package main
import (
"encoding/json"
"os"
"time"
)
// jsonListing is the whole document.
type jsonListing struct {
Generated string `json:"generated"`
Answered []string `json:"answered"`
Failed []string `json:"failed"`
Count int `json:"count"`
Machines []jsonMachine `json:"machines"`
}
// jsonMachine is one machine. The names are the ones the table's headers stand
// for, spelled out: a document is read by somebody who cannot see the header.
type jsonMachine struct {
Name string `json:"name"`
VCenter string `json:"vcenter"`
Datacenter string `json:"datacenter"`
Host string `json:"host"`
Power string `json:"power"`
Connection string `json:"connection,omitempty"`
Status string `json:"status,omitempty"` // vCenter's own green/yellow/red
Template bool `json:"template"`
Address string `json:"address,omitempty"`
Hostname string `json:"hostname,omitempty"`
Guest string `json:"guest,omitempty"`
ToolsRunning bool `json:"tools_running"`
CPUs int32 `json:"cpus"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryMB int32 `json:"memory_mb"`
MemoryPercent *float64 `json:"memory_percent"`
UptimeSeconds *int32 `json:"uptime_seconds"`
CommittedBytes *int64 `json:"committed_bytes"`
UncommittedBytes *int64 `json:"uncommitted_bytes"`
Snapshots []jsonSnapshot `json:"snapshots"`
OldestSnapshotAt *string `json:"oldest_snapshot_at"`
OldestSnapshotDays *int `json:"oldest_snapshot_days"`
Task *jsonTask `json:"task"`
Issues []string `json:"issues"`
UUID string `json:"uuid,omitempty"`
Instance string `json:"instance_uuid,omitempty"`
Moref string `json:"moref"`
}
type jsonSnapshot struct {
Name string `json:"name"`
Created string `json:"created"`
Days int `json:"days"`
Current bool `json:"current"`
Depth int `json:"depth"`
}
type jsonTask struct {
What string `json:"what"`
Queued bool `json:"queued"`
Progress int32 `json:"progress"`
Since string `json:"since,omitempty"`
}
// printJSON writes the document. Indented, because the first reader of it is
// always a person finding out what the keys are called.
func printJSON(found sweep, rows []vmRow) error {
doc := jsonListing{
Generated: time.Now().Format(time.RFC3339),
Answered: found.answered,
Failed: found.failed,
Count: len(rows),
Machines: make([]jsonMachine, 0, len(rows)),
}
if doc.Answered == nil {
doc.Answered = []string{}
}
if doc.Failed == nil {
doc.Failed = []string{}
}
for _, r := range rows {
doc.Machines = append(doc.Machines, jsonOf(r))
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(doc); err != nil {
return errf("cannot write the listing: %w", err)
}
return nil
}
// jsonOf is one row as a document entry.
func jsonOf(r vmRow) jsonMachine {
sum := r.vm.Summary
cfg := sum.Config
rt := sum.Runtime
m := jsonMachine{
Name: r.name,
VCenter: r.vc.Name,
Datacenter: r.vc.Datacenter,
Host: r.host,
Power: string(r.power()),
Connection: string(rt.ConnectionState),
Status: string(sum.OverallStatus),
Template: cfg.Template,
Guest: r.guestOS(),
ToolsRunning: r.toolsRunning(),
CPUs: cfg.NumCpu,
MemoryMB: cfg.MemorySizeMB,
Issues: r.issues(),
UUID: cfg.Uuid,
Instance: cfg.InstanceUuid,
Moref: r.ref.Value,
Snapshots: []jsonSnapshot{},
}
if m.Issues == nil {
m.Issues = []string{}
}
if ip := r.ip(); ip != "-" {
m.Address = ip
}
if g := r.vm.Guest; g != nil {
m.Hostname = g.HostName
}
if pct, ok := r.cpuLoad(); ok {
m.CPUPercent = &pct
}
if pct, ok := r.memLoad(); ok {
m.MemoryPercent = &pct
}
if r.running() && sum.QuickStats.UptimeSeconds > 0 {
up := sum.QuickStats.UptimeSeconds
m.UptimeSeconds = &up
}
if st := sum.Storage; st != nil {
committed, uncommitted := st.Committed, st.Uncommitted
m.CommittedBytes, m.UncommittedBytes = &committed, &uncommitted
}
for _, e := range r.snaps {
m.Snapshots = append(m.Snapshots, jsonSnapshot{
Name: e.name, Created: e.when.Format(time.RFC3339),
Days: e.days(), Current: e.current, Depth: e.depth,
})
}
if e, ok := r.oldest(); ok {
at, days := e.when.Format(time.RFC3339), e.days()
m.OldestSnapshotAt, m.OldestSnapshotDays = &at, &days
}
if t := r.task; t != nil {
jt := jsonTask{What: t.what, Queued: t.queued, Progress: t.progress}
if !t.since.IsZero() {
jt.Since = t.since.Format(time.RFC3339)
}
m.Task = &jt
}
return m
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/vmware/govmomi/vim25/types"
)
// jsonDoc runs the encoder over a listing and reads it back the way whatever is
// on the other end of the pipe would.
func jsonDoc(t *testing.T, found sweep, rows []vmRow) map[string]any {
t.Helper()
out := captureStdout(t, func() {
if err := printJSON(found, rows); err != nil {
t.Fatal(err)
}
})
var doc map[string]any
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("what came out is not JSON: %v\n%s", err, out)
}
return doc
}
// A figure that is not known is null and never nought. A stopped machine has no
// processor load, and a spreadsheet that averages a column of zeroes reports a
// fleet that is idle.
func TestJSONLeavesTheUnknownNull(t *testing.T) {
on := testRow("web01", true, "10.0.0.5")
off := testRow("db01", false, "")
off.ref = types.ManagedObjectReference{Value: "vm-43"}
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{on, off})
machines := doc["machines"].([]any)
if len(machines) != 2 {
t.Fatalf("the document holds %d machines", len(machines))
}
running := machines[0].(map[string]any)
stopped := machines[1].(map[string]any)
if running["cpu_percent"] == nil {
t.Error("a running machine has no processor load in the document")
}
for _, key := range []string{"cpu_percent", "memory_percent", "uptime_seconds"} {
if stopped[key] != nil {
t.Errorf("a stopped machine reports %s = %v, want null", key, stopped[key])
}
}
if stopped["address"] != nil {
t.Errorf("a machine with no address reports address = %v", stopped["address"])
}
}
// The servers that answered and the ones that did not are part of the document.
// A script handed a bare list of machines cannot tell an empty cluster from an
// unreachable one, which is the difference that matters.
func TestJSONNamesTheServersThatDidNotAnswer(t *testing.T) {
doc := jsonDoc(t, sweep{
answered: []string{"v308"},
failed: []string{"v108: login failed"},
}, nil)
if got := doc["answered"].([]any); len(got) != 1 || got[0] != "v308" {
t.Errorf("answered = %v", got)
}
got := doc["failed"].([]any)
if len(got) != 1 || !strings.Contains(got[0].(string), "v108") {
t.Errorf("failed = %v", got)
}
}
// Both lists are always there, empty rather than absent: a reader that has to
// tell null from [] is a reader that will get it wrong once.
func TestJSONAlwaysHasBothServerLists(t *testing.T) {
doc := jsonDoc(t, sweep{}, nil)
for _, key := range []string{"answered", "failed", "machines"} {
if doc[key] == nil {
t.Errorf("%s is null in an empty listing", key)
}
}
if doc["count"] != float64(0) {
t.Errorf("count = %v", doc["count"])
}
}
// What the table shows in colour and what the document says in words is the
// same judgement, made in one place.
func TestJSONCarriesTheIssuesAndTheSnapshots(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Summary.Runtime.ConsolidationNeeded = true
r.snaps = []snapEntry{aged("before-patch", 63), aged("hotfix", 2)}
r.task = &runningTask{what: "clone", progress: 40}
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{r})
m := doc["machines"].([]any)[0].(map[string]any)
issues := m["issues"].([]any)
if len(issues) == 0 || !strings.Contains(issues[0].(string), "consolidating") {
t.Errorf("issues = %v", issues)
}
if got := m["snapshots"].([]any); len(got) != 2 {
t.Errorf("the document holds %d snapshots", len(got))
}
if got := m["oldest_snapshot_days"]; got != float64(63) {
t.Errorf("oldest_snapshot_days = %v", got)
}
task := m["task"].(map[string]any)
if task["what"] != "clone" || task["progress"] != float64(40) {
t.Errorf("task = %v", task)
}
// And a machine with none of those says so, rather than leaving the reader
// to guess whether the key was simply left out.
quiet := testRow("db01", true, "10.0.0.6")
doc = jsonDoc(t, sweep{}, []vmRow{quiet})
m = doc["machines"].([]any)[0].(map[string]any)
if got := m["issues"].([]any); len(got) != 0 {
t.Errorf("a healthy machine reports issues = %v", got)
}
if m["task"] != nil {
t.Errorf("an idle machine reports task = %v", m["task"])
}
if m["oldest_snapshot_at"] != nil {
t.Errorf("a machine with no snapshots reports oldest_snapshot_at = %v", m["oldest_snapshot_at"])
}
}
// The document is one object, not a bare array: that is what leaves room for
// the servers, and it is the promise a script is written against.
func TestJSONIsOneDocument(t *testing.T) {
out := captureStdout(t, func() {
if err := printJSON(sweep{}, nil); err != nil {
t.Fatal(err)
}
})
if !strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Errorf("the document begins %q", strings.SplitN(out, "\n", 2)[0])
}
}
+452
View File
@@ -0,0 +1,452 @@
// live.go — the list that keeps itself up to date.
//
// Everything else in the interactive half happens because somebody pressed a
// key. This is the part that happens because time passed: `^l` turns it on and
// the list re-reads itself every few seconds, which turns gvm from something one
// looks at into something one leaves open on a second screen.
//
// Three things come with it, and they are here rather than in browse.go because
// they only mean anything together:
//
// 1. The refresh itself, over the connections that are already open. `^r` logs
// in again — that is how a session that has died is recovered — and doing
// that every ten seconds would be three logins a minute for nothing.
// 2. What changed. A table says what is; after a sweep it can say what just
// became, which is the one thing no column can hold: "db01 off · web01 +1
// snapshot". It is the reason to leave the thing open at all.
// 3. The trend column: each machine's recent CPU load as one column of text.
// The samples cannot live on the rows, which every sweep throws away, so
// the browser keeps them and writes the drawing back onto the rows.
//
// Nothing here acts on a machine. A refresh that could start or stop something
// would be a timer with the power to do it, and the one thing a screen left
// open unattended must not have is that.
package main
import (
"strings"
"sync"
"time"
)
// How often the list re-reads itself. Faster while vCenter is doing something:
// a clone's progress that moves once every ten seconds is a figure one waits
// for, and one that moves every two is a thing one watches finish.
//
// Neither is configurable yet. The sweep is a few properties of every machine
// over a connection that is already up, which is cheap enough that ten seconds
// is not a number worth tuning per site — and a setting nobody needs is a
// setting to keep working for ever.
const (
liveEvery = 10 * time.Second
liveBusy = 2 * time.Second
)
// trendLen is how many sweeps the trend column remembers, and therefore how
// wide the column is: a history longer than the drawing would be arithmetic
// nobody sees.
//
// Six rather than eight. The difference is two characters of table, and two
// characters is what decides whether this column is on the screen at all on a
// terminal of 120 with a task column in it — which is the ordinary case it was
// built for. Six sweeps is a minute of history at the quiet interval.
const trendLen = 6
// toggleLive turns it on and off, and says which — a mode that changes what the
// screen does on its own has to announce itself, or a list that moves under
// somebody's hands looks like a fault.
//
// Turning it on refreshes at once rather than in ten seconds' time. The
// keystroke is a request for the current state, not for a subscription that
// begins later.
func (b *browser) toggleLive() {
b.live = !b.live
if !b.live {
b.setStatus(colDim, "live off — ^r to reload by hand")
return
}
b.liveNext, b.liveGap = time.Now(), b.liveInterval()
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
}
// liveReady reports whether a tick may happen now. Only the list and a
// machine's sheet are refreshed underneath somebody: the menu decides what it
// offers from the state it was drawn with, the picker holds a list of snapshots
// that is being chosen from, and both the editor and a question are half-typed
// answers. Redrawing any of those from under a hand is worse than being ten
// seconds out of date.
//
// The estate screen is left out for a different reason: it reads the hosts
// itself, which is work the machine list does not do, and a timer that did it
// every ten seconds would be paying for a screen somebody is reading rather
// than watching. ^r reads it again.
func (b *browser) liveReady() bool {
return b.live && b.menu == nil && b.pick == nil && b.confirm == nil &&
b.edit == nil && b.prompt == nil && b.estate == nil
}
// liveIn is how long until the next tick — the remaining time, not the whole
// interval, so that somebody arrowing through the list steadily cannot postpone
// the refresh for ever.
func (b *browser) liveIn() time.Duration {
d := time.Until(b.liveNext)
if d < 0 {
return 0
}
return d
}
// liveInterval is the gap after this tick: short while anything at all is being
// done on the cluster, so the task column moves while one watches it.
func (b *browser) liveInterval() time.Duration {
for _, r := range b.rows {
if r.task != nil {
return liveBusy
}
}
return liveEvery
}
// liveTick is one refresh. What it must not do is move the screen: the cursor
// stays on the machine it was on, and the viewport stays where it was — a list
// that jumps to put the selection on the last visible line every ten seconds is
// unreadable, and that is what the ordinary refilter would do, since it is
// written for a filter being typed, where going back to the top is right.
func (b *browser) liveTick() {
was := b.rows
scroll := b.scroll
// The error is information, not a reason to stop: resweep may have replaced
// the rows and still have something to report — one server of three did not
// answer — and the rows it left are a list, just a partly older one.
//
// What must not happen is returning here with the rows replaced and the
// view not rebuilt. The view holds indexes into the rows, and the drawing
// follows it without asking: leaving the two disagreeing showed every row
// as a different machine, put the cursor on one the operator was not
// looking at, and panicked outright as soon as the new list was shorter.
err := b.resweep()
b.applySort() // which refilters, so the view describes the rows again
b.scroll = min(scroll, max(len(b.view)-1, 0))
b.sample()
if b.detail != nil {
// The sheet is rebuilt from the machine as it is now, at the line it was
// being read at. It closes itself where that machine has gone.
keep := b.dscroll
b.openDetail()
b.dscroll = keep
}
// (4) The next tick is timed from here, not from before the sweep: a sweep
// that takes longer than the interval would otherwise leave no idle time at
// all, and liveIn would hand nextWithin a zero deadline that races every
// keystroke against an already expired timer.
b.liveGap = b.liveInterval()
b.liveNext = time.Now().Add(b.liveGap)
if err != nil {
b.setStatus(colWarn, "live: "+err.Error())
b.saidLive = ""
return
}
// The changed line is live mode's own, and a quiet tick clears it — but it
// clears nothing else. A message somebody produced by pressing a key is
// theirs: "copied its hostname web01.example" or the reason a snapshot was
// refused must not vanish because ten seconds passed and nothing happened
// on the cluster. Every keystroke already clears the status; a timer has no
// business doing it.
what := changesBetween(was, b.rows)
if what != "" {
b.setStatus(colInfo, what)
b.saidLive = what
return
}
if b.status == b.saidLive {
b.setStatus("", "")
}
b.saidLive = ""
}
// resweep re-reads every machine over the sessions that are already open, in
// parallel across the servers the way the first sweep is.
//
// A server that stops answering costs its own machines, not the screen: its rows
// are kept as they were and it is named in the error. Dropping them would empty
// half a list because one of three vCenters was restarting.
func (b *browser) resweep() error {
type result struct {
rows []vmRow
err error
}
res := make([]result, len(b.sessions))
var wg sync.WaitGroup
for i, s := range b.sessions {
if s == nil {
continue
}
wg.Add(1)
go func(i int, s *session) {
defer wg.Done()
res[i].rows, res[i].err = sweepOne(s.vc, s)
}(i, s)
}
wg.Wait()
previous := b.rows
var rows []vmRow
var failed, answered []string
for i, s := range b.sessions {
if s == nil {
continue
}
if res[i].err != nil {
failed = append(failed, s.vc.Name)
rows = append(rows, rowsOfVC(previous, s.vc)...)
continue
}
answered = append(answered, s.vc.Name)
rows = append(rows, res[i].rows...)
}
if len(rows) == 0 {
if len(failed) == 0 {
return errf("no connection left to re-read the machines over")
}
return errf("no machine could be re-read (%s)", strings.Join(failed, ", "))
}
b.rows = rows
// The title is made of these two, and a refresh that leaves them alone puts
// "412 machines on v308, v309" above a status line saying v309 did not
// answer. One of the two is then a lie, and the status line is the one that
// the next keystroke clears.
b.answered, b.lost = answered, failed
if len(failed) > 0 {
return errf("%s did not answer; showing what was last read of it", strings.Join(failed, ", "))
}
return nil
}
// rowsOfVC keeps one server's rows across a sweep it did not survive, so a
// vCenter that stops answering for a moment does not empty its half of the list.
func rowsOfVC(rows []vmRow, vc VCenter) []vmRow {
var out []vmRow
for _, r := range rows {
if r.vc.Name == vc.Name {
out = append(out, r)
}
}
return out
}
// ------------------------------------------------------------- what changed
// changesBetween is what moved between two sweeps, as one line. A table shows
// what is; this is the only thing on the screen that says what just became, and
// it is why the list is worth leaving open.
//
// Deliberately short: four things and a count of the rest. A line that has to be
// read carefully is one nobody reads at all, and the table underneath it holds
// the detail of every one of them.
func changesBetween(was, now []vmRow) string {
// Indexes, not copies. A vmRow carries the machine's whole property
// document — summary, guest, snapshot tree — and this runs every two
// seconds on a list of hundreds to compare four scalars.
before := make(map[string]int, len(was))
for i := range was {
before[was[i].id()] = i
}
seen := make(map[string]bool, len(now))
name := namer(was, now)
var said []string
for i := range now {
r := &now[i]
seen[r.id()] = true
at, had := before[r.id()]
if !had {
said = append(said, name(*r)+" is new")
continue
}
said = append(said, changesOf(&was[at], r, name)...)
}
for i := range was {
if !seen[was[i].id()] {
said = append(said, name(was[i])+" is gone")
}
}
if len(said) == 0 {
return ""
}
if len(said) > 4 {
return strings.Join(said[:4], " · ") + SF(" · and %d more", len(said)-4)
}
return strings.Join(said, " · ")
}
// namer says how to call a machine on that line. Its name, ordinarily — but a
// name is not what makes a machine that machine, and two vCenters may each hold
// a "web01" (see vmRow.id). Where they do, the server goes in front, and only
// there: "v309 web01 off" is the truth and "web01 off" is a coin toss, while
// putting the server in front of every name would spend the width on the
// ordinary case to pay for the rare one.
//
// The table underneath has a column for this, which is why it is worth so
// little width up here and so much certainty.
func namer(was, now []vmRow) func(vmRow) string {
// Both sweeps, because the lines that say a machine has gone are built out
// of the old one: deciding ambiguity from the new rows alone left "web01 is
// gone" unqualified in exactly the case where one of two web01s went.
//
// Counted by identity rather than by server, so two machines of one name on
// one vCenter — which vSphere allows, in different folders — are ambiguous
// too. The server in front does not separate those two; it does say which
// server to go and look on, which is more than the bare name does.
ids := make(map[string]map[string]bool)
for _, rows := range [][]vmRow{was, now} {
for i := range rows {
n := rows[i].name
if ids[n] == nil {
ids[n] = make(map[string]bool, 1)
}
ids[n][rows[i].id()] = true
}
}
return func(r vmRow) string {
if len(ids[r.name]) > 1 {
return r.vc.Name + " " + r.name
}
return r.name
}
}
// changesOf is what happened to one machine. Only things somebody would want to
// be told: the load moving is what the trend column is for, and a line that
// reported it would never say anything else.
func changesOf(old, now *vmRow, name func(vmRow) string) []string {
var said []string
who := name(*now)
if old.power() != now.power() {
said = append(said, who+" "+now.powerShort())
}
if d := len(now.snaps) - len(old.snaps); d != 0 {
said = append(said, SF("%s %+d snapshot", who, d))
}
switch {
case old.task == nil && now.task != nil:
said = append(said, who+" "+now.task.what)
case old.task != nil && now.task == nil:
said = append(said, who+" "+old.task.what+" done")
}
// What vCenter is complaining about, by the count: the reasons themselves
// are a column away (^w) and several of them at once would fill this line
// on their own.
if o, n := old.issues(), now.issues(); len(o) != len(n) {
switch {
case len(n) == 0:
said = append(said, who+" is clear")
case len(n) > len(o):
said = append(said, who+": "+n[0])
}
}
return said
}
// ------------------------------------------------------------ the trend column
// sample adds this sweep's load to what is remembered of each machine and draws
// it onto the row. Machines that have gone are forgotten here, which is the one
// place that can: a map of every machine ever seen would grow all day.
func (b *browser) sample() {
if b.hist == nil {
b.hist = make(map[string][]float64, len(b.rows))
}
seen := make(map[string]bool, len(b.rows))
for i := range b.rows {
r := &b.rows[i]
id := r.id()
seen[id] = true
// A machine that is not running has no load rather than a load of zero
// (cpuLoad says which), and a zero sampled off a stopped machine would
// draw a floor that never happened.
//
// What it had before goes with it. Keeping it left a machine that was
// switched off ten minutes ago showing a busy history beside a CPU%
// of "-": a shape that was true once, next to a figure saying there is
// nothing to be true about.
if pct, ok := r.cpuLoad(); ok {
h := append(b.hist[id], pct)
if len(h) > trendLen {
h = h[len(h)-trendLen:]
}
b.hist[id] = h
} else {
delete(b.hist, id)
}
r.trend = sparkline(b.hist[id])
}
for id := range b.hist {
if !seen[id] {
delete(b.hist, id)
}
}
}
// sparkBlocks are eight levels in one character each, which is what makes a
// history fit in a column.
var sparkBlocks = []rune("▁▂▃▄▅▆▇█")
// sparkline draws percentages as one piece of text.
//
// The scale is fixed at 0 to 100 and not fitted to the samples. A line that
// scales itself to what it holds makes a machine idling between 1 and 2 per
// cent look exactly like one swinging between 40 and 80 — the shape would be
// the news and the size would be invisible, which is the opposite of what a
// glance down a column is for.
//
// One sample draws nothing: a single block is not a trend, and a column that
// appears full of them the moment gvm starts would be eight characters of width
// spent on saying "hello".
func sparkline(samples []float64) string {
if len(samples) < 2 {
return ""
}
out := make([]rune, 0, len(samples))
for _, pct := range samples {
i := int(pct / 100 * float64(len(sparkBlocks)))
out = append(out, sparkBlocks[min(max(i, 0), len(sparkBlocks)-1)])
}
return string(out)
}
// trendColumn is the third column that is not always there, for the same reason
// the other two are not (see taskColumn): it holds something that is only
// sometimes true — a machine has a history once it has been swept twice — and a
// column of eight dashes down two hundred rows is width spent on nothing.
//
// It goes early on a narrow terminal — everything else in the table is a fact
// about a machine and this is a shape — but not first: the guest's operating
// system is the least read column there is, and eight characters of where a
// machine has just been are worth more than "Ubuntu Linux (64-bit)".
var trendColumn = browseColumn{header: "CPU~", width: trendLen, expendable: 2,
cell: func(r vmRow) string { return r.trend },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }}
// anyTrend reports whether anything has a history to draw yet.
func anyTrend(rows []vmRow) bool {
for _, r := range rows {
if r.trend != "" {
return true
}
}
return false
}
+487
View File
@@ -0,0 +1,487 @@
package main
import (
"os"
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The trend is drawn on a fixed scale, 0 to 100, and not fitted to what it
// holds. A line that scales itself makes a machine idling between 1 and 2 per
// cent look exactly like one swinging between 40 and 80 — the shape would be
// the news and the size invisible, which is the opposite of what a glance down
// a column is for.
func TestSparklineKeepsItsScale(t *testing.T) {
idle := sparkline([]float64{1, 2, 1, 2})
busy := sparkline([]float64{40, 80, 40, 80})
if idle == busy {
t.Errorf("idling and swinging drew the same line: %q", idle)
}
if strings.Trim(idle, "▁") != "" {
t.Errorf("a machine at 1-2%% is not drawn at the floor: %q", idle)
}
// The ends of the scale, and nothing outside it: a percentage over 100 is a
// figure vCenter has been known to hand out, and it must not index past the
// blocks.
for _, c := range []struct {
pct float64
want rune
}{{0, '▁'}, {50, '▅'}, {100, '█'}, {140, '█'}, {-5, '▁'}} {
got := sparkline([]float64{c.pct, c.pct})
if []rune(got)[0] != c.want {
t.Errorf("%.0f%% drew %q, want %q", c.pct, got, string(c.want))
}
}
// One sample is not a trend, and a column full of single blocks the moment
// gvm starts would be width spent on saying hello.
if got := sparkline([]float64{50}); got != "" {
t.Errorf("one sample drew %q", got)
}
if got := sparkline(nil); got != "" {
t.Errorf("no samples drew %q", got)
}
}
// The history is kept per machine, bounded, and forgotten when the machine goes
// — a map of every machine ever seen would grow all day.
func TestSampleRemembersAndForgets(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
// web01 is the running one testBrowser makes, so it is the one with a load.
for i := 0; i < trendLen+5; i++ {
b.sample()
}
var id string
for _, r := range b.rows {
if r.running() {
id = r.id()
}
}
if id == "" {
t.Fatal("no running machine to sample")
}
if got := len(b.hist[id]); got != trendLen {
t.Errorf("the history holds %d samples, want it bounded at %d", got, trendLen)
}
if !anyTrend(b.rows) {
t.Error("nothing was drawn after a dozen sweeps")
}
// A machine that is not running has no load rather than a load of zero, so
// nothing is remembered of it: a sampled zero would draw a floor that never
// happened.
for _, r := range b.rows {
if !r.running() && len(b.hist[r.id()]) != 0 {
t.Errorf("%s is off and has %d samples", r.name, len(b.hist[r.id()]))
}
}
// And the machine going takes its history with it.
b.rows = b.rows[:0]
b.sample()
if len(b.hist) != 0 {
t.Errorf("%d histories outlived their machines", len(b.hist))
}
}
// The trend column is there only once there is something in it, the way the
// task and reason columns are: eight dashes down two hundred rows would be
// width spent on nothing.
func TestTheTrendColumnComesWithTheHistory(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
has := func() bool {
for _, c := range b.columns() {
if c.header == "CPU~" {
return true
}
}
return false
}
if has() {
t.Error("the trend column is there before anything was sampled")
}
b.sample()
if has() {
t.Error("the trend column is there after one sweep, which is not a trend")
}
b.sample()
if !has() {
t.Error("the trend column is missing after two sweeps")
}
// It is the first thing a narrow terminal gives up: everything else in the
// table is a fact about a machine, and this is a shape.
wide := fitColumnsOf(b.columns(), 200)
if wide[len(wide)-1].header == "CPU~" && len(wide) < 2 {
t.Fatal("nothing to compare")
}
narrow := fitColumnsOf(b.columns(), 100)
for _, c := range narrow {
if c.header == "CPU~" {
t.Error("a hundred columns kept the trend and gave up facts for it")
}
}
}
// What changed between two sweeps is the one thing no column can hold. Each
// kind of change has to be named, and the line has to stay short enough to read
// at a glance.
func TestChangesBetweenSweeps(t *testing.T) {
on := func(name string) vmRow {
r := testRow(name, true, "10.0.0.5")
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
return r
}
// Nothing moved.
web := on("web01")
if got := changesBetween([]vmRow{web}, []vmRow{web}); got != "" {
t.Errorf("a sweep with nothing in it said %q", got)
}
// Powered off behind gvm's back.
off := web
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
if got := changesBetween([]vmRow{web}, []vmRow{off}); !strings.Contains(got, "web01 off") {
t.Errorf("a machine that stopped said %q", got)
}
// A snapshot appearing and one going.
snapped := web
snapped.snaps = []snapEntry{{name: "s1"}}
if got := changesBetween([]vmRow{web}, []vmRow{snapped}); !strings.Contains(got, "+1 snapshot") {
t.Errorf("a new snapshot said %q", got)
}
if got := changesBetween([]vmRow{snapped}, []vmRow{web}); !strings.Contains(got, "-1 snapshot") {
t.Errorf("a removed snapshot said %q", got)
}
// A task starting and finishing.
busy := web
busy.task = &runningTask{what: "clone", progress: 40}
if got := changesBetween([]vmRow{web}, []vmRow{busy}); !strings.Contains(got, "web01 clone") {
t.Errorf("a task starting said %q", got)
}
if got := changesBetween([]vmRow{busy}, []vmRow{web}); !strings.Contains(got, "clone done") {
t.Errorf("a task finishing said %q", got)
}
// Machines coming and going.
if got := changesBetween([]vmRow{web}, []vmRow{web, on("db01")}); !strings.Contains(got, "db01 is new") {
t.Errorf("a new machine said %q", got)
}
if got := changesBetween([]vmRow{web, on("db01")}, []vmRow{web}); !strings.Contains(got, "db01 is gone") {
t.Errorf("a machine that went said %q", got)
}
// And the line stays short: four things, then a count.
var many []vmRow
for _, n := range []string{"a", "b", "c", "d", "e", "f"} {
many = append(many, on(n))
}
got := changesBetween(nil, many)
if !strings.Contains(got, "and 2 more") {
t.Errorf("six changes said %q, which does not end in a count", got)
}
if n := strings.Count(got, " · "); n > 4 {
t.Errorf("the line runs to %d pieces: %q", n, got)
}
}
// A tick may not happen underneath a menu, a picker, a confirmation or
// something half-typed: redrawing any of those from under a hand is worse than
// being ten seconds out of date.
func TestLiveHoldsStillForEveryScreenThatAsksSomething(t *testing.T) {
b := testBrowser("web01")
b.live = true
if !b.liveReady() {
t.Fatal("live mode does not tick on the plain list")
}
for _, c := range []struct {
what string
set func()
undo func()
}{
{"a menu", func() { b.menu = []menuItem{{key: 'n'}} }, func() { b.menu = nil }},
{"a picker", func() { b.pick = &picker{} }, func() { b.pick = nil }},
{"a confirmation", func() { b.confirm = &confirmation{} }, func() { b.confirm = nil }},
{"an editor", func() { b.edit = &editor{} }, func() { b.edit = nil }},
{"a question", func() { b.prompt = &prompt{} }, func() { b.prompt = nil }},
} {
c.set()
if b.liveReady() {
t.Errorf("live mode ticks with %s on screen", c.what)
}
c.undo()
}
// The sheet is refreshed, though: watching a machine's memory is a reason
// to have it open.
b.detail = []sheetLine{{label: "cpu", value: "4 vCPU"}}
if !b.liveReady() {
t.Error("live mode does not refresh an open sheet")
}
// And off is off.
b.live = false
if b.liveReady() {
t.Error("live mode ticks while it is switched off")
}
}
// It looks more often while vCenter is doing something: a clone's progress that
// moves every ten seconds is a figure one waits for, and one that moves every
// two is a thing one watches finish.
func TestLiveLooksFasterWhileSomethingIsRunning(t *testing.T) {
b := testBrowser("web01", "db01")
if got := b.liveInterval(); got != liveEvery {
t.Errorf("a quiet cluster is swept every %s, want %s", got, liveEvery)
}
b.rows[1].task = &runningTask{what: "clone", progress: 10}
if got := b.liveInterval(); got != liveBusy {
t.Errorf("a busy cluster is swept every %s, want %s", got, liveBusy)
}
}
// Two machines of one name are two machines, and the changed line has to say
// which of them stopped. A name is not what makes a machine that machine —
// vmRow.id carries the comment — and "web01 off" across three vCenters is a
// coin toss. The server goes in front only where the name is ambiguous: paying
// the width on every line for the rare case would be the wrong trade, and the
// table underneath has a column for it.
func TestTheChangedLineSaysWhichServerWhenItHasTo(t *testing.T) {
twice := func(vc string) vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
return r
}
stopped := func(r vmRow) vmRow {
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
return r
}
a, c := twice("v308"), twice("v309")
got := changesBetween([]vmRow{a, c}, []vmRow{a, stopped(c)})
if !strings.Contains(got, "v309 web01 off") {
t.Errorf("with a web01 on each of two servers it said %q", got)
}
// And one of that name is not dressed up with a server it does not need.
got = changesBetween([]vmRow{a}, []vmRow{stopped(a)})
if got != "web01 off" {
t.Errorf("an unambiguous machine said %q", got)
}
}
// A tick may clear its own line and nothing else. A message somebody produced
// by pressing a key is theirs: the address they just copied, or the reason a
// change was refused, must not vanish because ten seconds passed with nothing
// happening on the cluster.
func TestAQuietTickClearsOnlyItsOwnLine(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
// What a tick that found something leaves behind, then a quiet one.
b.setStatus(colInfo, "db01 off")
b.saidLive = "db01 off"
if b.status == b.saidLive {
b.setStatus("", "")
}
if b.status != "" {
t.Errorf("a quiet tick kept its own stale line: %q", b.status)
}
// And somebody else's message, which it must leave alone.
b.setStatus(colInfo, "copied its hostname web01.example to the clipboard (pbcopy)")
b.saidLive = "db01 off"
if b.status == b.saidLive {
b.setStatus("", "")
}
if !strings.Contains(b.status, "copied") {
t.Errorf("a quiet tick wiped a message it did not write: %q", b.status)
}
}
// A refresh that half failed still leaves the rows and the view describing the
// same list. Returning early with the rows replaced and the view not rebuilt
// showed every row as a different machine, put the cursor on one nobody was
// looking at, and panicked outright as soon as the new list was shorter — in
// renderList, which follows the view without asking.
func TestAHalfFailedRefreshLeavesTheScreenConsistent(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "20")
b := testBrowser("web01", "db01", "app07", "mail02")
b.applySort()
b.sel = len(b.view) - 1
b.live = true
// No sessions: resweep fails outright, which is the harshest version of the
// same path. The rows it could not re-read stay, and the screen still draws.
b.liveTick()
if len(b.view) != len(b.rows) {
t.Errorf("the view describes %d rows of %d", len(b.view), len(b.rows))
}
for _, i := range b.view {
if i < 0 || i >= len(b.rows) {
t.Fatalf("the view points at row %d of %d", i, len(b.rows))
}
}
_ = stripEscapes(renderToPipe(t, b, b.renderList)) // panicked before the fix
// And the tick is scheduled from after the sweep, not from before it: a
// sweep slower than the interval would otherwise leave no idle time at all
// and race every keystroke against an expired timer.
if d := b.liveIn(); d <= 0 {
t.Errorf("the next tick is already due (%s) the moment this one finished", d)
}
if b.liveGap == 0 {
t.Error("the title has no interval to show")
}
}
// A machine that stops stops drawing. Keeping its history left a busy shape
// beside a CPU% of "-": true once, and next to a figure saying there is
// nothing to be true about.
func TestTheTrendGoesWhenTheMachineStops(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
var at int
for i, r := range b.rows {
if r.running() {
at = i
}
}
b.sample()
b.sample()
if b.rows[at].trend == "" {
t.Fatal("a running machine drew nothing after two sweeps")
}
b.rows[at].vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
b.sample()
if got := b.rows[at].trend; got != "" {
t.Errorf("a machine that was switched off still draws %q", got)
}
if n := len(b.hist[b.rows[at].id()]); n != 0 {
t.Errorf("%d samples outlived the machine being switched off", n)
}
}
// The lines that say a machine has gone are built from the old sweep, so
// ambiguity has to be judged over both. Deciding it from the new rows alone
// left "web01 is gone" unqualified in exactly the case the function exists for.
func TestAmbiguityIsJudgedOverBothSweeps(t *testing.T) {
at := func(vc string) vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
return r
}
a, c := at("v308"), at("v309")
got := changesBetween([]vmRow{a, c}, []vmRow{a})
if !strings.Contains(got, "v309 web01 is gone") {
t.Errorf("one of two web01s was deleted and it said %q", got)
}
}
// A refresh that reached nothing at all leaves the title alone, and should:
// the rows on screen are still the ones that server gave, so a title naming it
// is describing them correctly. It is the *partial* failure that must move the
// title, and that one needs a server to answer — see TestSimLiveRefresh.
func TestARefreshThatReachedNothingKeepsTheRowsAndTheirTitle(t *testing.T) {
b := testBrowser("web01")
b.applySort()
b.answered = []string{"v308"}
b.liveTick()
if len(b.rows) != 1 || len(b.answered) != 1 {
t.Errorf("a failed refresh left %d rows titled %v", len(b.rows), b.answered)
}
if !strings.Contains(b.status, "live:") {
t.Errorf("it did not say the refresh failed: %q", b.status)
}
}
// The keystroke reader has to be able to stop waiting, without that breaking
// the one thing it must never break: a control sequence arrives in one burst,
// and a deadline expiring in the middle of "ESC [ A" would turn one arrow key
// into an Esc and a stray letter in the filter.
func TestNextWithinStopsWaitingButNotMidSequence(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
kr := newKeyReader(r)
// Nothing to read: it gives up and says so.
start := time.Now()
if _, ok := kr.nextWithin(50 * time.Millisecond); ok {
t.Error("a key was reported with nothing sent")
}
if waited := time.Since(start); waited < 40*time.Millisecond {
t.Errorf("it gave up after %s, before it was asked to", waited)
}
// A key that is there is decoded as usual.
w.WriteString("q")
k, ok := kr.nextWithin(time.Second)
if !ok || k.special != keyRune || k.r != 'q' {
t.Errorf("nextWithin gave %+v, %v", k, ok)
}
// An arrow key survives it whole, with what follows still intact.
w.WriteString("\x1b[Ax")
k, ok = kr.nextWithin(time.Second)
if !ok || k.special != keyUp {
t.Errorf("the arrow came back as %+v, %v", k, ok)
}
if next := kr.next(); next.special != keyRune || next.r != 'x' {
t.Errorf("what followed the arrow came back as %+v", next)
}
// ^l is the toggle, and nothing else had it.
w.WriteString("\x0c")
if k, ok = kr.nextWithin(time.Second); !ok || k.special != keyCtrlL {
t.Errorf("^l came back as %+v, %v", k, ok)
}
}
// Turning it on asks at once rather than in ten seconds' time, and both states
// say which they are: a list that moves on its own with nothing to explain it
// reads as a fault.
func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
b := testBrowser("web01")
b.toggleLive()
if !b.live {
t.Fatal("^l did not turn live mode on")
}
if !strings.Contains(b.status, "live on") {
t.Errorf("turning it on said %q", b.status)
}
if d := b.liveIn(); d > time.Second {
t.Errorf("the first refresh is %s away, want it now", d)
}
b.toggleLive()
if b.live {
t.Fatal("^l did not turn live mode off")
}
if !strings.Contains(b.status, "live off") {
t.Errorf("turning it off said %q", b.status)
}
}
+40
View File
@@ -63,6 +63,24 @@ func printRow(cols []printColumn, indent string, cells []cell) {
PF("%s%s\n", indent, strings.TrimRight(strings.Join(out, " "), " "))
}
// widen grows every column to the longest thing it holds, header included. For
// output that is going somewhere without a width of its own, where cutting a
// value would lose it rather than merely hide it.
func widen(pcs []printColumn, body [][]cell) {
for i := range pcs {
w := len([]rune(pcs[i].header))
for _, cells := range body {
if i >= len(cells) {
continue
}
if n := len([]rune(cells[i].text)); n > w {
w = n
}
}
pcs[i].width = w
}
}
// pad fits text to width — truncating what is too long — against the left or,
// for numbers, the right.
func pad(text string, width int, right bool) string {
@@ -87,3 +105,25 @@ func paint(text, col string) string {
}
return col + text + attrOff
}
// plainRow is one row with nothing in it but the values — the same cells and
// the same widths as printRow writes to the screen, without the colour.
//
// For output that leaves the machine: the mail a report sends is read in a mail
// client, where an escape sequence is not a colour but four stray characters,
// and fatih/color's answer to "is this a terminal" is about this process's
// stdout and says nothing about where a mail is going.
func plainRow(cols []printColumn, cells []cell) string {
out := make([]string, 0, len(cols))
for i, c := range cols {
text := c.header
if cells != nil {
if i >= len(cells) {
break
}
text = cells[i].text
}
out = append(out, pad(text, c.width, c.right))
}
return strings.TrimRight(strings.Join(out, " "), " ")
}
+460
View File
@@ -0,0 +1,460 @@
// resize.go — changing what a machine has: vCPUs and memory.
//
// This is the third kind of thing gvm does to a machine, after its power and
// its snapshots, and it is the one with the most ways to be refused. vSphere
// will not take just any number:
//
// 1. A running machine can only grow, and only where it was built to. CPU and
// memory hot-add are per-machine settings somebody turned on when the
// machine was made; without them the machine has to be powered off first.
// Memory can never shrink while it runs — there is no hot-remove for it at
// all — and vCPUs only where hot-remove is on as well.
// 2. The vCPU count has to be a multiple of the cores per socket. gvm does not
// quietly change the socket topology to make a number fit: software is
// licensed per socket, and a tool that turns 2 sockets into 4 to satisfy an
// odd vCPU count would be writing somebody an invoice.
// 3. Memory is a whole number of megabytes, in multiples of four.
//
// All of that is asked before anything is sent, in the same shape the power
// operations use: an objection in two lengths, one short enough for the menu's
// own column and one to be read on its own, both out of one function so the
// menu and the message cannot disagree.
package main
import (
"strconv"
"strings"
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// reconfigWait is how long gvm watches a reconfigure before it stops watching.
// The task itself is usually over in a second — the machine is not copied or
// moved, only its configuration is written — so this is a limit on a frozen
// terminal, not on the operation. Like every other wait here, vCenter carries
// on regardless.
const reconfigWait = 5 * time.Minute
// sizeKind is which of the two is being changed. They are separate operations
// with separate rules, not two fields of one: memory can never shrink while a
// machine runs and vCPUs sometimes can, and a menu entry that greys out for
// both reasons at once could not say why.
type sizeKind int
const (
sizeCPUs sizeKind = iota
sizeMemory
)
func (k sizeKind) what() string {
if k == sizeCPUs {
return "vCPU count"
}
return "memory"
}
// sizing is what a machine has and what may be changed while it runs. It comes
// from `config`, which the inventory sweep deliberately does not read — it is
// the whole configuration of a machine, and this wants four fields of it — so
// it is asked for one machine at a time, when somebody is about to change it.
type sizing struct {
cpus int32
coresPerSocket int32
memoryMB int32
cpuHotAdd bool
cpuHotRemove bool
memoryHotAdd bool
known bool // false when the configuration could not be read at all
}
// sizingProps are those four fields and the two figures they are about. Named
// paths rather than "config": the whole configuration of a machine is a large
// document, and this is a menu being drawn.
var sizingProps = []string{
"config.hardware.numCPU",
"config.hardware.numCoresPerSocket",
"config.hardware.memoryMB",
"config.cpuHotAddEnabled",
"config.cpuHotRemoveEnabled",
"config.memoryHotAddEnabled",
}
// sizingOf reads them. A machine whose configuration cannot be read gets a
// sizing that says it knows nothing, and sizeObjection turns that into a
// refusal of its own: not knowing what a machine has is a reason to leave it
// alone, not a reason to send a number and hope.
func sizingOf(s *session, ref types.ManagedObjectReference) (sizing, error) {
if s == nil {
return sizing{}, errf("no connection to read the configuration over")
}
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, sizingProps, &mvm); err != nil {
return sizing{}, errf("%s: cannot read the configuration of %s: %w",
s.vc.Name, vmName(s, ref), err)
}
return sizingFrom(mvm.Config), nil
}
// sizingFrom is the same from the property itself, so the rules below can be
// exercised without a server.
func sizingFrom(cfg *types.VirtualMachineConfigInfo) sizing {
if cfg == nil {
return sizing{}
}
sz := sizing{
cpus: cfg.Hardware.NumCPU,
memoryMB: cfg.Hardware.MemoryMB,
known: true,
}
// The cores per socket and the three flags are pointers: unset means the
// machine predates the setting or vCenter did not send it. Unset is one
// core per socket, which divides into everything, and off.
sz.coresPerSocket = 1
if cfg.Hardware.NumCoresPerSocket != nil {
sz.coresPerSocket = *cfg.Hardware.NumCoresPerSocket
}
if cfg.CpuHotAddEnabled != nil {
sz.cpuHotAdd = *cfg.CpuHotAddEnabled
}
if cfg.CpuHotRemoveEnabled != nil {
sz.cpuHotRemove = *cfg.CpuHotRemoveEnabled
}
if cfg.MemoryHotAddEnabled != nil {
sz.memoryHotAdd = *cfg.MemoryHotAddEnabled
}
return sz
}
// now is the current value of whichever of the two this is, and shown is it in
// the words the sheet uses — "4 vCPU", "8.0GB" — so a message about a change
// and the line above it read as the same figure.
func (sz sizing) now(k sizeKind) int32 {
if k == sizeCPUs {
return sz.cpus
}
return sz.memoryMB
}
func (k sizeKind) shown(v int32) string {
if k == sizeCPUs {
return SF("%d vCPU", v)
}
return units.ByteSize(int64(v) * 1024 * 1024).String()
}
// ------------------------------------------------------------- what was typed
// parseSize turns what somebody typed into the number vSphere wants: a vCPU
// count, or a memory size in megabytes.
//
// Memory is read as gigabytes, because that is the unit the sheet shows and the
// unit anybody says out loud — "give it 16" is never sixteen megabytes. An
// explicit unit overrides that, so 512m is still sayable, and a fraction is
// taken where it lands on a whole megabyte: 1.5g is 1536 MB.
func parseSize(k sizeKind, text string) (int32, error) {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return 0, errf("nothing typed")
}
if k == sizeCPUs {
n, err := strconv.Atoi(t)
if err != nil {
return 0, errf("%q is not a number of vCPUs", text)
}
if n < 1 {
return 0, errf("a machine has at least one vCPU, not %d", n)
}
if n > maxCPUs {
return 0, errf("%d vCPUs is past anything vSphere builds — a typo?", n)
}
return int32(n), nil
}
unit := "g"
for _, suffix := range []string{"mb", "gb", "m", "g"} {
if strings.HasSuffix(t, suffix) {
unit, t = suffix[:1], strings.TrimSpace(strings.TrimSuffix(t, suffix))
break
}
}
v, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, errf("%q is not an amount of memory", text)
}
mb := v
if unit == "g" {
mb = v * 1024
}
if mb < 4 {
return 0, errf("%s is less memory than a machine can have", text)
}
if mb > maxMemoryMB {
return 0, errf("%s is more memory than vSphere takes — a typo?", text)
}
if mb != float64(int64(mb)) {
return 0, errf("%s is not a whole number of megabytes", text)
}
return int32(mb), nil
}
// The two ceilings are not vSphere's exact maxima, which move with every
// release and with the hardware version of the machine. They are there to catch
// a finger that stayed on a key: past these, a number is a typo rather than an
// intention, and everything below them is left to the server to accept or
// refuse with its own reasons.
const (
maxCPUs = 1024
maxMemoryMB = 32 * 1024 * 1024 // 32 TB
)
// -------------------------------------------------------------- the objection
// sizeObjection says why this machine cannot be given that, in two lengths, the
// same as powerObjection. Both the menu and the message come from here.
//
// A want of 0 asks the weaker question the menu asks while it is being drawn:
// not "can it have six" but "is there any number at all it could be given right
// now" — which is what decides whether the entry is offered or greyed out.
func sizeObjection(r vmRow, sz sizing, k sizeKind, want int32) (short, long string) {
// A machine whose configuration could not be read is not one to change. It
// is tempting to send it anyway and let vCenter be the authority on what it
// takes — but gvm would not know what the machine has now, so it could
// neither check the socket rule nor put an honest "from" in the question it
// asks. "8.0GB, up from something I could not read" is not a confirmation.
if !sz.known {
return "configuration unread", SF("what %s has could not be read, so there is nothing "+
"to change it from — try again, or change it in the vSphere client", r.name)
}
running := r.running()
if want == 0 {
switch {
case !running:
return "", ""
case k == sizeCPUs && !sz.cpuHotAdd && !sz.cpuHotRemove:
return "needs it off", SF("%s is running and has neither CPU hot-add nor hot-remove — "+
"power it off to change the vCPU count", r.name)
case k == sizeMemory && !sz.memoryHotAdd:
return "needs it off", SF("%s is running and has no memory hot-add — "+
"power it off to change its memory", r.name)
}
return "", ""
}
now := sz.now(k)
if want == now {
return "unchanged", SF("%s already has %s", r.name, k.shown(now))
}
if k == sizeCPUs {
// The socket topology is the machine's, not gvm's to adjust: a vCPU
// count that does not divide into it is refused with the two counts
// that do, rather than made to fit by changing the number of sockets.
if per := sz.coresPerSocket; per > 1 && want%per != 0 {
below, above := want-want%per, want-want%per+per
// Below a single socket there is no lower count to offer: zero
// processors is not a machine, and parseSize refuses it anyway.
// Naming it would be offering an answer gvm will not take.
fits := SF("%d or %d", below, above)
if below < per {
fits = SF("%d", above)
}
return "not a whole socket", SF("%s has %d cores per socket, so its vCPUs come in "+
"multiples of %d — %s, not %d", r.name, per, per, fits, want)
}
switch {
case running && want > now && !sz.cpuHotAdd:
return "no CPU hot-add", SF("%s is running and CPU hot-add is off — "+
"power it off to give it more than %d vCPU", r.name, now)
case running && want < now && !sz.cpuHotRemove:
return "no CPU hot-remove", SF("%s is running and CPU hot-remove is off — "+
"power it off to take vCPUs away from it", r.name)
}
return "", ""
}
switch {
case want%4 != 0:
return "not a multiple of 4 MB", SF("memory is set in multiples of 4 MB, and %d MB is not one", want)
case running && want < now:
// Not a flag anybody can turn on: vSphere has no memory hot-remove.
return "cannot shrink while on", SF("%s is running, and memory can never be taken away from "+
"a running machine — power it off first", r.name)
case running && !sz.memoryHotAdd:
return "no memory hot-add", SF("%s is running and memory hot-add is off — "+
"power it off to change its memory", r.name)
}
return "", ""
}
// checkSize is that objection as an error, or nil. Asked before the change is
// offered and again immediately before it is sent: between a menu being drawn
// and a confirmation being answered, somebody else may have powered the machine
// on.
func checkSize(r vmRow, sz sizing, k sizeKind, want int32) error {
if _, long := sizeObjection(r, sz, k, want); long != "" {
return errf("%s", long)
}
return nil
}
// hotly reports whether this change is going to happen underneath a running
// guest, which is worth saying afterwards: an operating system does not
// necessarily notice on its own that it has been given another four processors.
func hotly(r vmRow, k sizeKind, sz sizing, want int32) bool {
return r.running() && want > sz.now(k)
}
// ------------------------------------------------------------------ doing it
// runResize sends the change and waits for it. One task per call even when both
// numbers move, because the two are separate operations everywhere else in gvm
// and a single message that half worked would be the worst of both.
func runResize(s *session, r vmRow, sz sizing, k sizeKind, want int32) (message string, err error) {
if err := checkSize(r, sz, k, want); err != nil {
return "", err
}
spec := types.VirtualMachineConfigSpec{}
if k == sizeCPUs {
spec.NumCPUs = want
} else {
spec.MemoryMB = int64(want)
}
vm := object.NewVirtualMachine(s.client.Client, r.ref)
task, err := vm.Reconfigure(s.ctx, spec)
if err != nil {
return "", errf("%s: cannot change the %s of %s: %w", s.vc.Name, k.what(), r.name, err)
}
what := SF("%s of %s", k.what(), r.name)
if err := waitTask(s.ctx, task, reconfigWait, what); err != nil {
return "", err
}
msg := SF("%s: %s → %s", r.name, k.shown(sz.now(k)), k.shown(want))
if hotly(r, k, sz, want) {
msg += " — added while it runs; the guest may have to bring it online"
}
return msg, nil
}
// ---------------------------------------------------------- the command line
// sizeCLI is `gvm size`: with no number it says what the machine has, and with
// one it changes it. The two live in one command because "what has it got" is
// the question one asks immediately before "give it more", and having to
// remember two spellings of the same noun to ask both is a small tax.
func sizeCLI(vc VCenter, vmname, cpus, memory string, yes bool) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
vm, err := s.vm(vmname)
if err != nil {
return err
}
// Read as fresh as the operation is going to be: what is offered and what is
// refused both depend on whether the machine is running right now.
var mvm mo.VirtualMachine
if err := vm.Properties(s.ctx, vm.Reference(), append([]string{"summary", "guest"}, sizingProps...), &mvm); err != nil {
return errf("%s: cannot read %s: %w", vc.Name, vm.Name(), err)
}
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: vm.Name(), vm: mvm}
sz := sizingFrom(mvm.Config)
if cpus == "" && memory == "" {
sizeShow(r, sz)
return nil
}
// Both are read and checked before either is sent. A command line that sets
// the vCPUs and then refuses the memory for not being a multiple of 4 MB has
// half happened, and half of what somebody asked for is the one outcome
// nobody wanted — it is why `power` refuses two operations at once rather
// than carrying out the first.
//
// Checking both against the machine as it is now is sound because neither
// change can make the other impossible: what a resize is refused for is the
// power state, the hot-plug settings and the socket topology, and none of
// the three is touched here.
var changes []struct {
kind sizeKind
want int32
}
for _, c := range []struct {
kind sizeKind
text string
}{{sizeCPUs, cpus}, {sizeMemory, memory}} {
if c.text == "" {
continue
}
want, err := parseSize(c.kind, c.text)
if err != nil {
return err
}
if err := checkSize(r, sz, c.kind, want); err != nil {
return err
}
changes = append(changes, struct {
kind sizeKind
want int32
}{c.kind, want})
}
for _, c := range changes {
ok, err := confirm(SF("%s on %s: %s → %s?", r.name, vc.Name,
c.kind.shown(sz.now(c.kind)), c.kind.shown(c.want)), yes)
if err != nil || !ok {
return err
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
return err
}
PO(msg)
}
return nil
}
// sizeShow prints what the machine has and what could be changed without
// stopping it — the second being the thing one actually wants to know before
// planning the work, and the thing no listing anywhere else says.
func sizeShow(r vmRow, sz sizing) {
if !sz.known {
PE("the configuration of " + r.name + " could not be read")
return
}
PF("%-14s %s\n", "machine", r.name+" ("+r.powerLong()+")")
PF("%-14s %s\n", "vCPU", SF("%d, in %d per socket", sz.cpus, sz.coresPerSocket))
PF("%-14s %s\n", "memory", sizeMemory.shown(sz.memoryMB))
PF("%-14s %s\n", "while it runs", liveChanges(sz))
}
// liveChanges is that last line: which of the three hot-plug settings this
// machine was built with, said as what they let one do rather than as the names
// of the flags.
func liveChanges(sz sizing) string {
var can []string
if sz.cpuHotAdd {
can = append(can, "add vCPUs")
}
if sz.cpuHotRemove {
can = append(can, "remove vCPUs")
}
if sz.memoryHotAdd {
can = append(can, "add memory")
}
if len(can) == 0 {
return "nothing — it has to be powered off to be changed"
}
return strings.Join(can, ", ")
}
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// sizedRow is a machine in a given power state, for the rules below. Only the
// power state is read off it: everything else a resize depends on comes from
// the sizing, which is read separately from the machine's configuration.
func sizedRow(state types.VirtualMachinePowerState) vmRow {
return vmRow{
name: "web01",
vc: VCenter{Name: "v308"},
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Runtime: types.VirtualMachineRuntimeInfo{PowerState: state},
}},
}
}
// What somebody types has to become the number vSphere wants, or a refusal that
// says which part of it was not a number.
func TestParseSize(t *testing.T) {
for _, c := range []struct {
kind sizeKind
text string
want int32
bad string // a piece of the error, when it is meant to be refused
}{
{kind: sizeCPUs, text: "4", want: 4},
{kind: sizeCPUs, text: " 16 ", want: 16},
{kind: sizeCPUs, text: "0", bad: "at least one"},
{kind: sizeCPUs, text: "-2", bad: "at least one"},
{kind: sizeCPUs, text: "4.5", bad: "not a number"},
{kind: sizeCPUs, text: "eight", bad: "not a number"},
{kind: sizeCPUs, text: "99999", bad: "typo"},
// Memory is gigabytes unless it says otherwise: nobody means 16 MB.
{kind: sizeMemory, text: "16", want: 16384},
{kind: sizeMemory, text: "8g", want: 8192},
{kind: sizeMemory, text: "8GB", want: 8192},
{kind: sizeMemory, text: "512m", want: 512},
{kind: sizeMemory, text: "512mb", want: 512},
{kind: sizeMemory, text: "1.5g", want: 1536},
{kind: sizeMemory, text: "0", bad: "less memory"},
{kind: sizeMemory, text: "0.0001g", bad: "less memory"},
{kind: sizeMemory, text: "4.5m", bad: "whole number"},
{kind: sizeMemory, text: "lots", bad: "not an amount"},
{kind: sizeMemory, text: "99999g", bad: "typo"},
} {
got, err := parseSize(c.kind, c.text)
switch {
case c.bad != "":
if err == nil {
t.Errorf("parseSize(%q) = %d, want a refusal", c.text, got)
} else if !strings.Contains(err.Error(), c.bad) {
t.Errorf("parseSize(%q) refused with %q, which does not mention %q", c.text, err, c.bad)
}
case err != nil:
t.Errorf("parseSize(%q): %v", c.text, err)
case got != c.want:
t.Errorf("parseSize(%q) = %d, want %d", c.text, got, c.want)
}
}
}
// The rules a running machine is held to. This is the heart of the feature: what
// vSphere will refuse has to be refused here first, with the reason, rather than
// sent and bounced with a stack of SOAP.
func TestSizeObjectionOnARunningMachine(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true,
cpuHotAdd: true, cpuHotRemove: true, memoryHotAdd: true}
for _, c := range []struct {
what string
row vmRow
sz sizing
kind sizeKind
want int32
bad string // a piece of the objection, or "" when it must be allowed
}{
// Powered off, anything goes.
{"more vCPUs, machine off", off, plain, sizeCPUs, 8, ""},
{"fewer vCPUs, machine off", off, plain, sizeCPUs, 2, ""},
{"more memory, machine off", off, plain, sizeMemory, 16384, ""},
{"less memory, machine off", off, plain, sizeMemory, 4096, ""},
// Running, without the settings that would allow it.
{"more vCPUs, no hot-add", on, plain, sizeCPUs, 8, "hot-add is off"},
{"fewer vCPUs, no hot-remove", on, plain, sizeCPUs, 2, "hot-remove is off"},
{"more memory, no hot-add", on, plain, sizeMemory, 16384, "hot-add is off"},
// Running, with them.
{"more vCPUs, hot-add on", on, hot, sizeCPUs, 8, ""},
{"fewer vCPUs, hot-remove on", on, hot, sizeCPUs, 2, ""},
{"more memory, hot-add on", on, hot, sizeMemory, 16384, ""},
// The one no setting can allow: vSphere has no memory hot-remove.
{"less memory, hot-add on", on, hot, sizeMemory, 4096, "never be taken away"},
// Nothing to do.
{"the same vCPUs", on, hot, sizeCPUs, 4, "already"},
{"the same memory", on, hot, sizeMemory, 8192, "already"},
// Memory is whole multiples of four megabytes.
{"memory of 10 MB", off, plain, sizeMemory, 10, "multiples of 4 MB"},
// Not knowing what a machine has is a reason to leave it alone: there
// would be nothing honest to put on the left of the arrow.
{"unknown configuration", on, sizing{}, sizeCPUs, 64, "could not be read"},
{"unknown configuration, menu time", on, sizing{}, sizeMemory, 0, "could not be read"},
} {
short, long := sizeObjection(c.row, c.sz, c.kind, c.want)
switch {
case c.bad == "":
if long != "" {
t.Errorf("%s: refused with %q", c.what, long)
}
case long == "":
t.Errorf("%s: allowed, want a refusal mentioning %q", c.what, c.bad)
case !strings.Contains(long, c.bad):
t.Errorf("%s: refused with %q, which does not mention %q", c.what, long, c.bad)
}
// Both lengths or neither: the menu column and the message come from
// here together, and one without the other greys an entry out with
// nothing to explain it.
if (short == "") != (long == "") {
t.Errorf("%s: short %q and long %q disagree about whether there is an objection",
c.what, short, long)
}
}
}
// The socket topology is the machine's own. A vCPU count that does not divide
// into it is refused with the two that do, rather than made to fit by changing
// the number of sockets underneath somebody's per-socket licence.
func TestSizeKeepsTheSocketTopology(t *testing.T) {
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
sz := sizing{cpus: 8, coresPerSocket: 4, memoryMB: 8192, known: true}
_, long := sizeObjection(off, sz, sizeCPUs, 6)
if long == "" {
t.Fatal("6 vCPUs was allowed on a machine with 4 cores per socket")
}
for _, want := range []string{"4 cores per socket", "4 or 8"} {
if !strings.Contains(long, want) {
t.Errorf("the refusal does not say %q: %q", want, long)
}
}
if _, long := sizeObjection(off, sz, sizeCPUs, 12); long != "" {
t.Errorf("12 vCPUs is three whole sockets and was refused: %q", long)
}
// Below one whole socket there is no lower count to offer. Naming 0 would
// be offering an answer parseSize itself refuses.
for _, want := range []int32{1, 2, 3} {
_, long := sizeObjection(off, sz, sizeCPUs, want)
if long == "" {
t.Errorf("%d vCPUs was allowed with 4 cores per socket", want)
continue
}
if !strings.Contains(long, "— 4, not") {
t.Errorf("asking for %d does not offer 4 on its own: %q", want, long)
}
}
// The same across the topologies machines are actually built with. The
// counts sit after the dash, so that is where a zero is looked for:
// "0 or 4" would be the bug and "10 or 12" is a perfectly good answer, and
// a test that cannot tell them apart fails the day this loop is widened.
for _, per := range []int32{2, 4, 8} {
sz := sizing{cpus: 8, coresPerSocket: per, memoryMB: 8192, known: true}
for want := int32(1); want <= 20; want++ {
_, long := sizeObjection(off, sz, sizeCPUs, want)
switch {
case want%per == 0:
if long != "" && !strings.Contains(long, "already") {
t.Errorf("%d vCPUs is whole sockets of %d and was refused: %q", want, per, long)
}
case long == "":
t.Errorf("%d vCPUs was allowed with %d cores per socket", want, per)
case strings.Contains(long, "— 0"):
t.Errorf("%d vCPUs with %d per socket offers none at all: %q", want, per, long)
}
}
}
}
// A machine whose configuration could not be read is refused rather than sent:
// gvm would not know what it has, so it could put nothing honest on the left of
// the arrow — "0B → 8.0GB" is not a confirmation, it is a wrong number.
func TestAnUnreadableConfigurationIsRefused(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
for _, k := range []sizeKind{sizeCPUs, sizeMemory} {
if err := checkSize(on, sizing{}, k, 8); err == nil {
t.Errorf("the %s was changed on a machine whose configuration is unknown", k.what())
}
// And the menu says the same thing before any number is typed.
short, long := sizeObjection(on, sizing{}, k, 0)
if short == "" || long == "" {
t.Errorf("the %s entry is offered on a machine whose configuration is unknown", k.what())
}
}
}
// A machine whose configuration says nothing about the hot-plug settings is a
// machine without them, and one core per socket divides into everything — the
// fields are pointers, and unset must not read as zero cores per socket.
func TestSizingFromAnEmptyConfiguration(t *testing.T) {
if sz := sizingFrom(nil); sz.known {
t.Error("a machine with no configuration claimed to know its sizing")
}
sz := sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 4, MemoryMB: 8192},
})
if !sz.known {
t.Fatal("a configuration that was read is said to be unknown")
}
if sz.coresPerSocket != 1 {
t.Errorf("cores per socket = %d, want 1 where the server did not say", sz.coresPerSocket)
}
if sz.cpuHotAdd || sz.cpuHotRemove || sz.memoryHotAdd {
t.Error("a setting the server did not send was taken for on")
}
yes := true
four := int32(4)
sz = sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 8, MemoryMB: 16384, NumCoresPerSocket: &four},
CpuHotAddEnabled: &yes,
MemoryHotAddEnabled: &yes,
})
if sz.coresPerSocket != 4 || !sz.cpuHotAdd || !sz.memoryHotAdd || sz.cpuHotRemove {
t.Errorf("the settings did not come through: %+v", sz)
}
}
// The two entries are greyed out with the reason on a machine that cannot take
// the change at all, and offered on one that can — which is the weaker question
// the menu asks, before any number has been typed.
func TestTheMenuOffersTheHardwareEntries(t *testing.T) {
b := &browser{}
find := func(items []menuItem, k rune) menuItem {
t.Helper()
for _, m := range items {
if m.key == k {
return m
}
}
t.Fatalf("no menu entry %q", string(k))
return menuItem{}
}
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := plain
hot.cpuHotAdd, hot.memoryHotAdd = true, true
running := b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, plain)
for _, k := range []rune{'c', 'm'} {
m := find(running, k)
if m.available() {
t.Errorf("%q is offered on a running machine with no hot-plug", string(k))
}
if m.hint == "" || m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
for _, c := range []struct {
what string
menu []menuItem
}{
{"a machine that is off", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOff), nil, plain)},
{"a running machine with hot-add", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, hot)},
} {
for _, k := range []rune{'c', 'm'} {
if m := find(c.menu, k); !m.available() {
t.Errorf("%q is not offered on %s: %s", string(k), c.what, m.why)
}
}
}
}
// What the messages call the two figures is what the sheet calls them, so a
// change and the line it changes read as the same number.
func TestSizeIsShownTheWayTheSheetShowsIt(t *testing.T) {
if got := sizeCPUs.shown(4); got != "4 vCPU" {
t.Errorf("vCPUs shown as %q", got)
}
if got := sizeMemory.shown(8192); got != "8.0GB" {
t.Errorf("memory shown as %q, want the sheet's own spelling", got)
}
r := vmRow{vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{MemorySizeMB: 8192}}}}
if sheet, msg := r.memory(), sizeMemory.shown(8192); sheet != msg {
t.Errorf("the sheet says %q and a resize says %q", sheet, msg)
}
}
+126
View File
@@ -0,0 +1,126 @@
// seal.go — the passwords in ~/.gvmrc, not in plain sight.
//
// A sealed value looks like this, and the rest of the file stays as it was:
//
// vcenter.v308.password = gvmenc1:Lb2h…
//
// Only the value is sealed, never the file: urls, users, datacenters and the mail
// settings stay readable and the file stays editable by hand, comments and all.
// AES-256-GCM, the key derived per value with HKDF from a random salt, all of it
// packed into one base64 word.
//
// What this is and is not, plainly. FILEKEY is compiled into gvm and is the same
// in every copy of it, so whoever holds ~/.gvmrc *and* a gvm binary can open the
// value; prising the key out is an afternoon's work, not a cluster's. This is not
// a vault and it is not meant to be one. What it buys is that the password no
// longer stands in the clear in a backup, in a home directory that syncs
// somewhere, in an editor's swap file, or on a screen someone else is looking
// at — which is what was asked for. The file stays 0600 for the rest.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
)
// FILEKEY is what the values in ~/.gvmrc are sealed under: thirty-two random
// bytes, the same in every build so that a file written by one gvm opens in the
// next. There is nothing to guess here and so no reason to slow a guesser down —
// HKDF, not argon2, and a value opens in microseconds.
//
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
// Values written by earlier builds then no longer open, and gvm says so and names
// the vCenter whose password has to be entered again.
var FILEKEY = "8Vb0MUm04VP/aOZTTSGcqdN9NbNC6CETAhSXu1hwbIk="
const (
// sealTag marks a sealed value and leaves room to tell it apart from whatever
// a later version writes, should the scheme ever have to change.
sealTag = "gvmenc1:"
saltLen = 16
keyLen = 32
sealInfo = "gvmrc password"
)
// sealed reports whether a value is one, which is how gvm knows a password in the
// file still stands in the clear and wants sealing.
func sealed(value string) bool { return strings.HasPrefix(value, sealTag) }
// seal turns a password into the word that goes in the file.
func seal(secret string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", errf("cannot seal the password: %w", err)
}
gcm, err := sealGCM(salt)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", errf("cannot seal the password: %w", err)
}
// salt, nonce and the sealed bytes travel together: opening it needs all
// three and nothing else, so one word in the file is the whole story.
blob := append(salt, nonce...)
blob = gcm.Seal(blob, nonce, []byte(secret), nil)
return sealTag + base64.StdEncoding.EncodeToString(blob), nil
}
// unseal turns it back. A value that is not sealed comes back unchanged: that is
// how a password typed straight into the file, or handed over in the environment,
// keeps working.
func unseal(value string) (string, error) {
if !sealed(value) {
return value, nil
}
blob, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, sealTag))
if err != nil {
return "", errf("the sealed password is not readable: %w", err)
}
gcm, err := sealGCM(nil)
if err != nil {
return "", err
}
if len(blob) < saltLen+gcm.NonceSize() {
return "", errf("the sealed password is too short to be one")
}
salt, rest := blob[:saltLen], blob[saltLen:]
nonce, box := rest[:gcm.NonceSize()], rest[gcm.NonceSize():]
gcm, err = sealGCM(salt)
if err != nil {
return "", err
}
secret, err := gcm.Open(nil, nonce, box, nil)
if err != nil {
return "", errf("the sealed password does not open — it was sealed by a gvm " +
"built with another key, or it has been altered; enter it again")
}
return string(secret), nil
}
// sealGCM derives the key for one value and wraps it. A nil salt is allowed so a
// caller may ask for the nonce size before it knows the salt.
func sealGCM(salt []byte) (cipher.AEAD, error) {
root, err := base64.StdEncoding.DecodeString(FILEKEY)
if err != nil || len(root) == 0 {
return nil, errf("this gvm was built without a usable key for sealing passwords")
}
key, err := hkdf.Key(sha256.New, root, salt, sealInfo, keyLen)
if err != nil {
return nil, errf("cannot derive the key: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errf("cannot derive the key: %w", err)
}
return cipher.NewGCM(block)
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSealRoundTrip(t *testing.T) {
for _, secret := range []string{
"hunter2",
"",
"mit Leerzeichen und Ümläuten",
"a/b+c=d", // the characters base64 uses, to be sure nothing is confused
"gvmenc1:nearly", // a password that looks like a sealed value
strings.Repeat("x", 500),
} {
word, err := seal(secret)
if err != nil {
t.Fatalf("%q: %v", secret, err)
}
if !sealed(word) {
t.Errorf("%q sealed to something unmarked: %q", secret, word)
}
if secret != "" && strings.Contains(word, secret) {
t.Errorf("%q is readable in its own sealed form: %q", secret, word)
}
back, err := unseal(word)
if err != nil {
t.Fatalf("%q: %v", secret, err)
}
if back != secret {
t.Errorf("came back as %q, want %q", back, secret)
}
}
}
// A fresh salt and nonce each time, so two machines with the same password do not
// show the same word in the file — which would say they share one.
func TestSealIsDifferentEveryTime(t *testing.T) {
a, _ := seal("gleich")
b, _ := seal("gleich")
if a == b {
t.Error("the same password sealed twice gave the same word")
}
}
// A value that is not sealed is handed back as it is: that is how a password
// typed straight into the file, or given in the environment, keeps working.
func TestUnsealLeavesPlainValuesAlone(t *testing.T) {
for _, plain := range []string{"hunter2", "", "gvmenc", "gvmenc1"} {
got, err := unseal(plain)
if err != nil {
t.Errorf("%q: %v", plain, err)
}
if got != plain {
t.Errorf("%q came back as %q", plain, got)
}
}
}
// Something that says it is sealed and is not must be an error, never an empty
// password — that would reach vCenter and look like the wrong one.
func TestBrokenSealIsAnError(t *testing.T) {
good, _ := seal("hunter2")
for _, c := range []struct{ value, note string }{
{sealTag + "not base64 at all!!", "not base64"},
{sealTag, "nothing after the tag"},
{sealTag + "c2hvcnQ=", "too short to hold a salt"},
{good[:len(good)-4] + "AAAA", "altered"},
} {
got, err := unseal(c.value)
if err == nil {
t.Errorf("%s: opened to %q instead of failing", c.note, got)
}
if got != "" {
t.Errorf("%s: gave back %q as well as an error", c.note, got)
}
}
// And the message says what to do about it.
if _, err := unseal(good[:len(good)-4] + "AAAA"); err == nil ||
!strings.Contains(err.Error(), "enter it again") {
t.Errorf("the message does not say what to do: %v", err)
}
}
// The vCenter's own accessor names itself in the error, so a file with three
// servers says which one is the trouble.
func TestVCenterPasswordNamesItself(t *testing.T) {
word, _ := seal("hunter2")
v := VCenter{Name: "v308", Password: word}
if got, err := v.password(); err != nil || got != "hunter2" {
t.Errorf("password() gave %q, %v", got, err)
}
broken := VCenter{Name: "v308", Password: sealTag + "rubbish"}
_, err := broken.password()
if err == nil {
t.Fatal("a broken seal came back without an error")
}
if !strings.Contains(err.Error(), "v308") {
t.Errorf("the error does not name the vCenter: %v", err)
}
}
// The file rewriting. Everything but the password itself has to survive.
func TestSealPasswordsRewritesOnlyTheSecret(t *testing.T) {
const before = `# my configuration
default = v308
vcenter.v308.url = https://v308.example/
vcenter.v308.user = administrator@v308
vcenter.v308.password = hunter2 # the password
vcenter.v308.datacenter = PPB
vcenter.v108.password = "with spaces"
vcenter.v108.user = admin
# vcenter.old.password = leave-me-alone
mailto = me@example.org
`
dir := t.TempDir()
path := filepath.Join(dir, ".gvmrc")
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
t.Fatal(err)
}
quiet(t)
sealPasswords(path, before)
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(after)
// The secrets are gone and the rest is untouched, line for line.
for _, gone := range []string{"= hunter2", "with spaces"} {
if strings.Contains(got, gone) {
t.Errorf("%q still stands in the clear:\n%s", gone, got)
}
}
for _, kept := range []string{
"# my configuration", "default = v308",
"vcenter.v308.url = https://v308.example/",
"vcenter.v308.user = administrator@v308",
"vcenter.v308.datacenter = PPB",
"# the password", // the note beside it is the user's
"# vcenter.old.password = leave-me-alone", // a commented-out line is not a setting
"vcenter.v108.user = admin",
"mailto = me@example.org",
} {
if !strings.Contains(got, kept) {
t.Errorf("the rewrite lost %q:\n%s", kept, got)
}
}
if strings.Count(got, "\n") != strings.Count(before, "\n") {
t.Errorf("the number of lines changed:\n%s", got)
}
// Both passwords open again, and to what they were.
cfg := Config{}
applyConfig(&cfg, parseConfig(got))
want := map[string]string{"v308": "hunter2", "v108": "with spaces"}
for _, v := range cfg.VCenters {
if !sealed(v.Password) {
t.Errorf("%s was not sealed", v.Name)
continue
}
if secret, err := v.password(); err != nil || secret != want[v.Name] {
t.Errorf("%s opens to %q, %v — want %q", v.Name, secret, err, want[v.Name])
}
}
// And a second pass changes nothing at all.
sealPasswords(path, got)
again, _ := os.ReadFile(path)
if string(again) != got {
t.Errorf("sealing twice changed the file the second time:\n%s", string(again))
}
// The file it writes is still readable by nobody else.
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Errorf("the rewritten file is mode %04o", fi.Mode().Perm())
}
}
func TestWriteSettingReplacesOrAppends(t *testing.T) {
const before = `# top
vcenter.v308.user = admin
vcenter.v308.password = old
mailto = me@example.org
`
dir := t.TempDir()
path := filepath.Join(dir, ".gvmrc")
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
t.Fatal(err)
}
if err := writeSetting(path, "vcenter.v308.password", "new"); err != nil {
t.Fatal(err)
}
got := readFile(t, path)
if !strings.Contains(got, "vcenter.v308.password = new") {
t.Errorf("the setting was not replaced:\n%s", got)
}
if strings.Contains(got, "= old") {
t.Errorf("the old value is still there:\n%s", got)
}
for _, kept := range []string{"# top", "vcenter.v308.user = admin", "mailto = me@example.org"} {
if !strings.Contains(got, kept) {
t.Errorf("writing lost %q:\n%s", kept, got)
}
}
// A setting that is not there yet is added rather than lost.
if err := writeSetting(path, "vcenter.v108.password", "brandnew"); err != nil {
t.Fatal(err)
}
if got := readFile(t, path); !strings.Contains(got, "vcenter.v108.password = brandnew") {
t.Errorf("a new setting was not added:\n%s", got)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// What `gvm config` says about a password, without saying the password.
func TestPasswordState(t *testing.T) {
word, _ := seal("hunter2")
for _, c := range []struct {
v VCenter
want string
note string
}{
{VCenter{Name: "a", Password: word}, "password sealed", "a sealed one"},
{VCenter{Name: "b", Password: "hunter2"}, "in the clear", "one still in the clear"},
{VCenter{Name: "c", Password: sealTag + "rubbish"}, "does not open", "one that will not open"},
} {
got := stripEscapes(passwordState(c.v))
if !strings.Contains(got, c.want) {
t.Errorf("%s: %q does not say %q", c.note, got, c.want)
}
if strings.Contains(got, "hunter2") {
t.Errorf("%s: the password itself is in the output: %q", c.note, got)
}
}
}
// The claim of this whole file: what goes on the wire is the *opened* password,
// never the sealed word out of ~/.gvmrc.
//
// Checked at loginURL rather than against a server, because govmomi's simulator
// accepts any non-empty password by default — a login that succeeds there proves
// nothing at all about which password was sent.
func TestLoginURLCarriesTheOpenedPassword(t *testing.T) {
const secret = "the-real-one"
word, err := seal(secret)
if err != nil {
t.Fatal(err)
}
if strings.Contains(word, secret) {
t.Fatal("the sealed word contains the password, so this would prove nothing")
}
v := VCenter{
Name: "v308", URL: "https://v308.example/",
User: "administrator@v308", Password: word,
}
u, err := loginURL(v)
if err != nil {
t.Fatalf("loginURL: %v", err)
}
got, ok := u.User.Password()
if !ok {
t.Fatal("the url carries no password at all")
}
if got != secret {
t.Errorf("the url carries %q, want the opened password", got)
}
if got == word {
t.Error("the sealed word itself was put in the url")
}
if u.User.Username() != "administrator@v308" {
t.Errorf("the user is %q", u.User.Username())
}
if u.String() == "" || !strings.HasSuffix(u.Path, "/sdk") {
t.Errorf("the endpoint is %q", u.Path)
}
// A password still in the clear goes through untouched, so a file nobody has
// let gvm rewrite yet keeps working.
plain := v
plain.Password = "still-plain"
u, err = loginURL(plain)
if err != nil {
t.Fatal(err)
}
if got, _ := u.User.Password(); got != "still-plain" {
t.Errorf("a plain password came through as %q", got)
}
// And one that will not open never gets as far as a url.
broken := v
broken.Password = sealTag + "rubbish"
if u, err := loginURL(broken); err == nil {
got, _ := u.User.Password()
t.Errorf("a broken seal produced a url carrying %q", got)
} else if !strings.Contains(err.Error(), "v308") {
t.Errorf("the error does not name the vCenter: %v", err)
}
}
+939 -3
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
@@ -30,6 +32,12 @@ func simVCenter(t *testing.T) VCenter {
func simVCenterModel(t *testing.T) (VCenter, *simulator.Model) {
t.Helper()
// Every sweep leaves the machine names in the cache directory for the shell
// to complete against (complete.go), so the cache is pointed at a temporary
// home first: a test run must no more write "DC0_C0_RP0_VM0" into the
// completion cache of the person running it than it may touch a real vCenter.
cacheHome(t)
model := simulator.VPX()
model.Datacenter = 1
model.Host = 2
@@ -269,10 +277,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
vc := simVCenter(t)
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
if err := lsvm([]VCenter{vc}, "", "", false); err != nil {
if err := lsvm([]VCenter{vc}, lsOptions{}); err != nil {
t.Errorf("lsvm: %v", err)
}
if err := lsvm([]VCenter{vc}, "DC0", "", false); err != nil {
if err := lsvm([]VCenter{vc}, lsOptions{match: "DC0"}); err != nil {
t.Errorf("lsvm with a pattern: %v", err)
}
if err := hoststat(vc, ""); err != nil {
@@ -286,7 +294,7 @@ func TestSimReadOnlyCommands(t *testing.T) {
}
// A pattern that is not a regexp is a message, not a panic.
if err := lsvm([]VCenter{vc}, "web(", "", false); err == nil {
if err := lsvm([]VCenter{vc}, lsOptions{match: "web("}); err == nil {
t.Error("lsvm accepted a broken pattern")
}
}
@@ -962,3 +970,931 @@ func TestSimAllVCentersUnreachable(t *testing.T) {
t.Errorf("answered: %v", found.answered)
}
}
// ------------------------------------------------- what the new commands read
// The sweep brings the snapshots back with the machines, which is what the
// snapshot column, the issues filter and the age report are all made of.
func TestSimSweepCarriesTheSnapshots(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
if err := snapshotNow(s, r.ref, "sweep-test", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
found, err := gatherVMs([]VCenter{vc})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
for _, row := range found.rows {
if row.name != "DC0_C0_RP0_VM0" {
continue
}
if row.snapCount() != 1 {
t.Fatalf("the sweep found %d snapshots, want 1", row.snapCount())
}
if row.snapCell() != "1" {
t.Errorf("the column shows %q", row.snapCell())
}
e, ok := row.oldest()
if !ok {
t.Fatal("the snapshot came back without a date")
}
if e.name != "sweep-test" {
t.Errorf("the oldest snapshot is %q", e.name)
}
// Taken a moment ago, so it is neither stale nor an issue.
if days := e.days(); days != 0 {
t.Errorf("a snapshot taken just now is %d days old", days)
}
if row.hasIssues() && strings.Contains(strings.Join(row.issues(), " "), "snapshot") {
t.Errorf("a fresh snapshot is held against the machine: %v", row.issues())
}
return
}
t.Fatal("the machine was not in the sweep")
}
// The age report, end to end: a snapshot taken now is not old, and the same
// report with an age of nothing finds it.
func TestSimSnapshotAgeReport(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
if err := snapshotNow(s, r.ref, "ancient", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
// Nothing is a month old on a vCenter that was created a second ago — and
// where somebody is reading, the report says so rather than printing an
// empty table.
onATerminal(t)
out := captureStdout(t, func() {
if err := snapOldReport(Config{}, []VCenter{vc}, snapOldDays, false); err != nil {
t.Fatalf("snapOldReport: %v", err)
}
})
if !strings.Contains(stripEscapes(out), "no snapshot") {
t.Errorf("the report of old snapshots says:\n%s", out)
}
// Everything is nought days old, so an age of nought finds it.
out = captureStdout(t, func() {
if err := snapOldReport(Config{}, []VCenter{vc}, 0, false); err != nil {
t.Fatalf("snapOldReport: %v", err)
}
})
for _, want := range []string{"ancient", "DC0_C0_RP0_VM1", "0d"} {
if !strings.Contains(stripEscapes(out), want) {
t.Errorf("the report leaves out %q:\n%s", want, out)
}
}
}
// A mailed report with no relay configured is refused before the sweep, not
// after it: finding out that the mail cannot be sent is of no use once the
// report has been printed.
func TestSimSnapshotReportChecksTheMailFirst(t *testing.T) {
quiet(t)
err := snapOldReport(Config{}, []VCenter{{Name: "nowhere", URL: "https://127.0.0.1:1/",
User: "u", Password: "p", Datacenter: "DC0"}}, 30, true)
if err == nil || !strings.Contains(err.Error(), "cannot send mail") {
t.Errorf("a report with -m and no relay failed with: %v", err)
}
}
func TestSimDatastores(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := dsstat(vc, ""); err != nil {
t.Fatalf("dsstat: %v", err)
}
})
plain := stripEscapes(out)
for _, want := range []string{"DATASTORE", "CAPACITY", "USED%", "LocalDS_0", "datastore"} {
if !strings.Contains(plain, want) {
t.Errorf("the datastore table leaves out %q:\n%s", want, plain)
}
}
}
// The printed listing as a document: what a monitoring check would read.
func TestSimJSONListing(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{json: true}); err != nil {
t.Fatalf("lsvm --json: %v", err)
}
})
var doc struct {
Answered []string `json:"answered"`
Failed []string `json:"failed"`
Count int `json:"count"`
Machines []struct {
Name string `json:"name"`
VCenter string `json:"vcenter"`
Moref string `json:"moref"`
Power string `json:"power"`
} `json:"machines"`
}
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("the listing is not JSON: %v\n%s", err, out)
}
if len(doc.Answered) != 1 || doc.Answered[0] != "sim" {
t.Errorf("answered = %v", doc.Answered)
}
if doc.Count != len(doc.Machines) || doc.Count == 0 {
t.Errorf("count = %d, machines = %d", doc.Count, len(doc.Machines))
}
for _, m := range doc.Machines {
if m.Name == "" || m.Moref == "" || m.VCenter != "sim" || m.Power == "" {
t.Errorf("a machine came out as %+v", m)
}
}
}
// A vCenter that does not answer is in the document rather than printed into
// the middle of it, where it would break whatever is reading it.
func TestSimJSONKeepsTheProseOut(t *testing.T) {
quiet(t)
vc := simVCenter(t)
dead := VCenter{Name: "dead", URL: "https://127.0.0.1:1/", User: "u", Password: "p", Datacenter: "DC0"}
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc, dead}, lsOptions{json: true}); err != nil {
t.Fatalf("lsvm --json: %v", err)
}
})
var doc struct {
Answered []string `json:"answered"`
Failed []string `json:"failed"`
}
if err := json.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("a failed server made the document unreadable: %v\n%s", err, out)
}
if len(doc.Failed) != 1 || !strings.Contains(doc.Failed[0], "dead") {
t.Errorf("failed = %v", doc.Failed)
}
if strings.Contains(out, "ERROR") {
t.Errorf("the failure was printed as prose as well:\n%s", out)
}
}
// The issues listing runs against a real inventory. The simulated machines have
// no VMware Tools, which is exactly the kind of thing it is for.
func TestSimIssuesListing(t *testing.T) {
quiet(t)
vc := simVCenter(t)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
plain := stripEscapes(out)
if !strings.Contains(plain, "WHY") {
t.Errorf("the issues listing has no reason column:\n%s", plain)
}
if !strings.Contains(plain, "VMware Tools") {
t.Errorf("the running machines without Tools were not reported:\n%s", plain)
}
}
// 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)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
// Something to find: a snapshot leaves events behind it.
if err := snapshotNow(s, r.ref, "for-the-log", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
lines, err := eventsOf(r)
if err != nil {
t.Fatalf("eventsOf: %v", err)
}
for _, l := range lines {
if strings.TrimSpace(l.text) == "" {
t.Error("an event came back with nothing in it")
}
if strings.ContainsAny(l.text, "\n\r") {
t.Errorf("an event carries a newline, which would break the sheet: %q", l.text)
}
}
// And they go on the sheet under one label, in their own colours.
sheet := eventSheet(lines)
if len(sheet) != len(lines) {
t.Errorf("the sheet holds %d of %d events", len(sheet), len(lines))
}
if len(sheet) > 0 && sheet[0].label != "events" {
t.Errorf("the first event line is labelled %q", sheet[0].label)
}
for _, l := range sheet[1:] {
if l.label != "" {
t.Errorf("an event line carries a second label: %q", l.label)
}
}
}
// A machine read over a connection that has gone cannot be asked for anything,
// and says so rather than looking empty.
func TestSimEventsWithoutAConnection(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
if _, err := eventsOf(r); err == nil {
t.Error("a row with no session read its events anyway")
}
}
// 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) {
quiet(t)
vc := simVCenter(t)
_, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
url := vsphereURL(r)
if url == "" {
t.Fatal("no link was built from a live connection")
}
if !strings.Contains(url, "urn:vmomi:VirtualMachine:"+r.ref.Value+":") {
t.Errorf("the link does not name the machine: %s", url)
}
if !strings.HasSuffix(url, "/summary") {
t.Errorf("the link does not end at the machine's page: %s", url)
}
}
// -v takes several servers for the sweeps, and the same server twice is still
// one login.
func TestMultipleVCentersInOneSweep(t *testing.T) {
cfg := Config{VCenters: []VCenter{
{Name: "v308", URL: "https://a/", User: "u", Password: "p", Datacenter: "DC"},
{Name: "v108", URL: "https://b/", User: "u", Password: "p", Datacenter: "DC"},
{Name: "v38", URL: "https://c/", User: "u", Password: "p", Datacenter: "DC"},
}}
got, err := cfg.targets("v308,v38")
if err != nil {
t.Fatalf("targets: %v", err)
}
if len(got) != 2 || got[0].Name != "v308" || got[1].Name != "v38" {
t.Errorf("-v v308,v38 gave %v", vcNames(got))
}
// The order given is the order used, and a name given twice is one server.
got, _ = cfg.targets("v38, v308 ,v38")
if len(got) != 2 || got[0].Name != "v38" || got[1].Name != "v308" {
t.Errorf("-v with a repeat gave %v", vcNames(got))
}
// An unknown name among them is an error, not a shorter list: a sweep that
// quietly left a server out would report a cluster that is not there.
if _, err := cfg.targets("v308,nowhere"); err == nil {
t.Error("an unknown server in the list was skipped")
}
// And the commands that act on one machine refuse a list outright.
if _, err := cfg.pick("v308,v38"); err == nil {
t.Error("a single-server command took a list of servers")
} else if !strings.Contains(err.Error(), "one vCenter at a time") {
t.Errorf("it refused with: %v", err)
}
}
// The task path itself: that the references off a machine can be read back as
// tasks at all. runningTasks swallows a failure here on purpose — a table is
// worth having without the column — so a broken read would otherwise look
// exactly like a quiet cluster, on every vCenter, for ever.
func TestSimTasksAreReadable(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
if err := snapshotNow(s, r.ref, "leaves-a-task", "from the tests"); err != nil {
t.Fatalf("cannot take a snapshot: %v", err)
}
var fresh mo.VirtualMachine
if err := object.NewVirtualMachine(s.client.Client, r.ref).
Properties(s.ctx, r.ref, []string{"recentTask"}, &fresh); err != nil {
t.Fatalf("cannot read recentTask: %v", err)
}
// A re-read machine has to know which machine it is: runningTasks keys what
// it finds by the machine's own reference, and refreshRow looks its answer
// up by the reference of the row. A property read that did not fill that in
// would leave the task column empty on exactly the row that was just acted
// on, and nowhere else.
if fresh.Reference() != r.ref {
t.Fatalf("a re-read machine came back as %v, not %v", fresh.Reference(), r.ref)
}
if len(fresh.RecentTask) == 0 {
t.Skip("this vCenter keeps no recent tasks on the machine")
}
tasks, err := s.tasks(fresh.RecentTask)
if err != nil {
t.Fatalf("the task references could not be read: %v", err)
}
if len(tasks) == 0 {
t.Fatal("no task came back for a machine that has just had one")
}
for _, task := range tasks {
if task.Info.DescriptionId == "" {
t.Error("a task came back with nothing to call it")
}
}
// And a task that has finished is not something being done to the machine.
if busy := runningTasks(s, []mo.VirtualMachine{fresh}); len(busy) != 0 {
t.Errorf("a finished snapshot is still reported as going on: %v", busy)
}
}
// The issues listing is the one that belongs in cron, so with nothing to report
// it prints nothing at all — cron mails whatever a command prints, and a daily
// "nothing wrong" is a daily mail nobody reads. On a terminal it says so.
func TestSimIssuesListingIsQuietUnderCron(t *testing.T) {
quiet(t)
vc, model := simVCenterModel(t)
// A healthy inventory: the simulated machines are only in the issues list
// because they run without VMware Tools, so switching Tools on for all of
// them leaves nothing to report.
found, err := gatherVMs([]VCenter{vc})
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
for _, r := range found.rows {
startTools(t, model, r.ref)
}
closeSessions(found.sessions)
out := captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
if out != "" {
t.Errorf("a clean estate printed this into a pipe:\n%s", out)
}
onATerminal(t)
out = captureStdout(t, func() {
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
t.Fatalf("lsvm --issues: %v", err)
}
})
if !strings.Contains(stripEscapes(out), "nothing to report") {
t.Errorf("on a terminal a clean estate printed %q", out)
}
}
+42 -9
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
@@ -85,10 +86,23 @@ func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string
if err != nil {
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
}
if err := task.Wait(s.ctx); err != nil {
return fmt.Errorf("%s: the snapshot %s failed: %w", s.vc.Name, name, err)
// Bounded, like every other task gvm waits for. This one was not: it waited
// on the session's own context, which has no deadline, so a snapshot that
// vCenter never finished froze the interactive list with the screen mid-draw
// and no key being read — the one place where waiting for ever is worst.
return waitTask(s.ctx, task, snapshotWait, SF("snapshot %s of %s", name, vmName(s, ref)))
}
// vmName is the machine's name for a message, from its reference alone. Cheap:
// one property, and only asked for when something has gone wrong enough to be
// worth naming.
func vmName(s *session, ref types.ManagedObjectReference) string {
var mvm mo.VirtualMachine
if err := object.NewVirtualMachine(s.client.Client, ref).
Properties(s.ctx, ref, []string{"name"}, &mvm); err != nil {
return ref.Value
}
return nil
return mvm.Name
}
// snapRemove removes one snapshot, with its children left where they are, and
@@ -296,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()
}
+255
View File
@@ -0,0 +1,255 @@
// snapold.go — the snapshots nobody has come back for.
//
// This is the one recurring job in a vSphere estate that nothing in vCenter
// does for you: somebody takes a snapshot before an upgrade, the upgrade goes
// well, and the snapshot stays. Six weeks later its delta disk is bigger than
// the machine and the datastore is the thing that pages you.
//
// So the report reads every vCenter at once and prints one line per snapshot
// older than the age asked for, oldest first, with what it costs — and with -m
// it goes out by mail, which is the form it is actually useful in: this is a
// cron job, not something anyone remembers to run.
package main
import (
"sort"
"strings"
"github.com/fatih/color"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
var snapOldColumns = []printColumn{
{header: "MACHINE", width: 24},
{header: "VC", width: 4},
{header: "SNAPSHOT", width: 22},
{header: "AGE", width: 6, right: true},
{header: "TAKEN", width: 16},
{header: "SIZE", width: 9, right: true},
}
// oldSnap is one snapshot in the report, with the machine it belongs to.
type oldSnap struct {
row vmRow
snap snapEntry
bytes int64 // what it owns on the datastore, 0 when that could not be read
}
// snapOldReport prints the report and, when asked, mails it.
func snapOldReport(cfg Config, targets []VCenter, days int, mail bool) error {
if mail {
// Asked before the sweep, not after it: finding out that the mail cannot
// be sent is of no use once the report has scrolled past.
if err := cfg.mailReady(); err != nil {
return err
}
}
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
for _, why := range found.failed {
PE(why) // said before the report, where it will not be scrolled past
}
if err != nil {
return err
}
// Which machines have snapshots at all decides what the sizes are asked
// for, so it is worked out before anything else is read.
var carrying []vmRow
for _, r := range found.rows {
if len(r.snaps) > 0 {
carrying = append(carrying, r)
}
}
sizes := snapshotSizes(carrying)
var old []oldSnap
for _, r := range carrying {
for _, e := range r.snaps {
if e.days() < days {
continue
}
old = append(old, oldSnap{row: r, snap: e, bytes: sizes[snapKey(r, e)]})
}
}
// Oldest first: that is the order the work is done in, and the first line of
// a mail is the one that gets read.
sort.SliceStable(old, func(i, j int) bool {
return old[i].snap.when.Before(old[j].snap.when)
})
body := snapOldTable(old, days, len(found.rows))
if !mail || len(old) == 0 {
return nil
}
return sendmail(cfg, SF("old snapshots (%s)", vcNames(targets)), "<pre>"+body+"</pre>")
}
// snapOldTable prints the report and returns the same report as text.
//
// Both come out of one set of cells: the screen gets them painted and fitted,
// the mail gets them plain. A report that was formatted twice would eventually
// say two different things, and the mail is the copy nobody checks.
func snapOldTable(old []oldSnap, days, machines int) string {
if len(old) == 0 {
line := SF("no snapshot on any of the %d machines is %s old", machines, plural(days, "day"))
// Only where somebody is reading. This report runs weekly out of cron,
// and cron mails whatever is printed: a weekly "nothing to clean up"
// teaches everyone to filter the report away.
if !color.NoColor {
P(Cgb(line))
}
return line + "\n"
}
cells := make([][]cell, 0, len(old))
total := int64(0)
for _, o := range old {
total += o.bytes
cells = append(cells, []cell{
{o.row.name, colName},
{o.row.vc.Name, colWhere},
{o.snap.name, colChosen},
{SF("%dd", o.snap.days()), ageColor(o.snap.days())},
{o.snap.created, colAddress},
sizeCell(o.bytes),
})
}
cols := append([]printColumn(nil), snapOldColumns...)
widen(cols, cells)
head := SF("%d snapshots older than %s, on %s:", len(old), plural(days, "day"),
plural(countMachines(old), "machine"))
tail := SF("%s in %s, on %s", units.ByteSize(total),
plural(len(old), "snapshot"), plural(countMachines(old), "machine"))
P(Cwb(head))
P()
printRow(cols, "", nil)
for _, c := range cells {
printRow(cols, "", c)
}
P()
P(Cob(tail))
var sb strings.Builder
sb.WriteString(head + "\n\n")
sb.WriteString(plainRow(cols, nil) + "\n")
for _, c := range cells {
sb.WriteString(plainRow(cols, c) + "\n")
}
sb.WriteString("\n" + tail + "\n")
return sb.String()
}
// countMachines counts the machines rather than the snapshots: three snapshots
// of one machine is one machine's worth of work.
func countMachines(old []oldSnap) int {
seen := map[string]bool{}
for _, o := range old {
seen[o.row.id()] = true
}
return len(seen)
}
// sizeCell is what the snapshot owns, or a dash where the file layout could not
// be read. Nought bytes and "not known" are different answers and a report that
// prints 0 B for the second invites somebody to remove the wrong snapshot.
func sizeCell(b int64) cell {
if b <= 0 {
return cell{"-", colOff}
}
return cell{units.ByteSize(b).String(), colSize}
}
// ageColor takes the table's thresholds: yellow once a snapshot has stopped
// being this week's, red once it has stopped being this month's.
func ageColor(days int) string {
switch {
case days >= snapOldDays:
return colFull
case days >= snapStaleDays:
return colBusy
}
return colSize
}
// snapKey names one snapshot of one machine across every server: two vCenters
// hand out the same references, and a machine may hold two snapshots of one
// name.
func snapKey(r vmRow, e snapEntry) string { return r.id() + "/" + e.ref.Value }
// snapshotSizes is what each snapshot owns on the datastore.
//
// The file layout is asked for only for the machines that have snapshots, and
// for all of them at once per server: it lists every file of every machine and
// is far too much to carry through the ordinary sweep.
func snapshotSizes(rows []vmRow) map[string]int64 {
bySession := map[*session][]types.ManagedObjectReference{}
rowOf := map[string]vmRow{}
for _, r := range rows {
if r.sess == nil {
continue
}
bySession[r.sess] = append(bySession[r.sess], r.ref)
rowOf[r.sess.vc.Name+"/"+r.ref.Value] = r
}
out := map[string]int64{}
for s, refs := range bySession {
var vms []mo.VirtualMachine
if err := s.objects(refs, []string{"layoutEx"}, &vms); err != nil {
continue // the report is worth having without the sizes
}
for _, vm := range vms {
r, ok := rowOf[s.vc.Name+"/"+vm.Reference().Value]
if !ok {
continue
}
for ref, size := range snapshotBytes(vm.LayoutEx) {
out[r.id()+"/"+ref.Value] = size
}
}
}
return out
}
// snapshotBytes is the size of each snapshot in one machine's file layout.
//
// A snapshot owns its state file — the .vmsn, with the memory in it — and the
// *last* link of each of its disk chains. The links in front of that one are
// the disks its ancestors froze, and the delta the machine is writing to right
// now belongs to no snapshot at all: it is in the machine's own chain, not in
// any snapshot's. Counting only the last link is therefore both the whole of
// what removing this snapshot would give back and free of double counting,
// which summing whole chains is not.
func snapshotBytes(layout *types.VirtualMachineFileLayoutEx) map[types.ManagedObjectReference]int64 {
if layout == nil {
return nil
}
size := make(map[int32]int64, len(layout.File))
for _, f := range layout.File {
size[f.Key] = f.Size
}
out := make(map[types.ManagedObjectReference]int64, len(layout.Snapshot))
for _, sl := range layout.Snapshot {
total := size[sl.DataKey]
if sl.MemoryKey >= 0 && sl.MemoryKey != sl.DataKey {
total += size[sl.MemoryKey]
}
for _, d := range sl.Disk {
if len(d.Chain) == 0 {
continue
}
for _, key := range d.Chain[len(d.Chain)-1].FileKey {
total += size[key]
}
}
out[sl.Key] = total
}
return out
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"strings"
"testing"
"github.com/fatih/color"
"github.com/vmware/govmomi/vim25/types"
)
// snapRef is a snapshot's reference, as the file layout keys its entries by.
func snapRef(v string) types.ManagedObjectReference {
return types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: v}
}
// The size of a snapshot is what removing it would give back: its own state
// file and the last link of each of its disk chains. The links in front of that
// belong to its ancestors, and counting whole chains — which is the obvious
// thing to do — reports the same delta once per descendant.
func TestSnapshotBytesCountsEachDeltaOnce(t *testing.T) {
// Two snapshots in a line. base froze file 10; after-patch froze file 11.
// Each has a state file of its own (1 and 2), and the machine is writing to
// file 12, which belongs to neither.
layout := &types.VirtualMachineFileLayoutEx{
File: []types.VirtualMachineFileLayoutExFileInfo{
{Key: 1, Type: "snapshotData", Size: 100},
{Key: 2, Type: "snapshotData", Size: 200},
{Key: 10, Type: "diskExtent", Size: 1000},
{Key: 11, Type: "diskExtent", Size: 2000},
{Key: 12, Type: "diskExtent", Size: 4000}, // the running delta
},
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
{
Key: snapRef("snapshot-1"), DataKey: 1, MemoryKey: -1,
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
{FileKey: []int32{10}},
}}},
},
{
Key: snapRef("snapshot-2"), DataKey: 2, MemoryKey: -1,
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
{FileKey: []int32{10}}, {FileKey: []int32{11}},
}}},
},
},
}
got := snapshotBytes(layout)
if got[snapRef("snapshot-1")] != 1100 {
t.Errorf("the first snapshot is %d bytes, want 1100", got[snapRef("snapshot-1")])
}
if got[snapRef("snapshot-2")] != 2200 {
t.Errorf("the second snapshot is %d bytes, want 2200 — its parent's delta was counted again",
got[snapRef("snapshot-2")])
}
}
// A separate memory file is part of the snapshot; a memoryKey of -1 means there
// is not one, and the key that says so must not be looked up as a file.
func TestSnapshotBytesTakesTheMemoryFile(t *testing.T) {
layout := &types.VirtualMachineFileLayoutEx{
File: []types.VirtualMachineFileLayoutExFileInfo{
{Key: 1, Size: 100}, {Key: 3, Size: 8000},
},
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
{Key: snapRef("s"), DataKey: 1, MemoryKey: 3},
},
}
if got := snapshotBytes(layout)[snapRef("s")]; got != 8100 {
t.Errorf("with a memory file the snapshot is %d bytes, want 8100", got)
}
layout.Snapshot[0].MemoryKey = -1
if got := snapshotBytes(layout)[snapRef("s")]; got != 100 {
t.Errorf("without one it is %d bytes, want 100", got)
}
}
func TestSnapshotBytesOfNothing(t *testing.T) {
if got := snapshotBytes(nil); got != nil {
t.Errorf("a machine with no file layout reported %v", got)
}
}
// Nought bytes and "the layout could not be read" are different answers, and
// the second must not look like a snapshot that costs nothing.
func TestSizeCellSaysWhenItDoesNotKnow(t *testing.T) {
if got := sizeCell(0); got.text != "-" || got.col != colOff {
t.Errorf("an unknown size is shown as %q", got.text)
}
if got := sizeCell(1 << 30); !strings.Contains(got.text, "GB") {
t.Errorf("a gigabyte is shown as %q", got.text)
}
}
// The report's ages take the table's colours, so a red count in the list and a
// red line in the mail mean the same thing.
func TestAgeColorMatchesTheTable(t *testing.T) {
for _, c := range []struct {
days int
want string
}{
{0, colSize}, {snapStaleDays - 1, colSize},
{snapStaleDays, colBusy}, {snapOldDays - 1, colBusy},
{snapOldDays, colFull}, {365, colFull},
} {
if got := ageColor(c.days); got != c.want {
t.Errorf("a snapshot of %d days is coloured wrongly", c.days)
}
}
// And the same thresholds the column uses.
r := testRow("web01", true, "10.0.0.5")
r.snaps = []snapEntry{aged("s", snapOldDays+1)}
if r.snapColor() != colFull {
t.Error("the column and the report disagree about an old snapshot")
}
}
// A report with nothing in it says so in the same breath as saying what it
// looked for: "no old snapshots" without the age is not an answer.
//
// And it says it only where somebody is reading. The report runs weekly out of
// cron, and cron mails whatever is printed: a weekly "nothing to clean up"
// teaches everybody to filter the report away, and then the week it has
// something to say is filtered away with it.
func TestAnEmptyReportSaysWhatItLookedForOnlyOnATerminal(t *testing.T) {
var body string
out := captureStdout(t, func() { body = snapOldTable(nil, 30, 212) })
if out != "" {
t.Errorf("an empty report printed %q into a pipe", out)
}
if !strings.Contains(body, "212") || !strings.Contains(body, "30 days") {
t.Errorf("the empty report reads %q", body)
}
onATerminal(t)
out = captureStdout(t, func() { snapOldTable(nil, 30, 212) })
if !strings.Contains(stripEscapes(out), "no snapshot") {
t.Errorf("on a terminal the empty report printed %q", out)
}
}
// onATerminal makes the colour library — which is also gvm's answer to "is
// anybody reading this" — say yes for the length of one test.
func onATerminal(t *testing.T) {
t.Helper()
saved := color.NoColor
color.NoColor = false
t.Cleanup(func() { color.NoColor = saved })
}
// The report counts machines, not snapshots: three snapshots of one machine is
// one machine's worth of work.
func TestCountMachines(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
other := testRow("db01", true, "10.0.0.6")
other.ref = types.ManagedObjectReference{Value: "vm-43"}
old := []oldSnap{
{row: r, snap: aged("a", 40)},
{row: r, snap: aged("b", 50)},
{row: other, snap: aged("c", 60)},
}
if got := countMachines(old); got != 2 {
t.Errorf("three snapshots on two machines counted as %d machines", got)
}
}
// The mail carries the same table as the screen, and carries no escape
// sequences: a mail client shows those as four stray characters per colour.
func TestTheMailedReportHasNoColours(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
old := []oldSnap{{row: r, snap: aged("before-patch", 63), bytes: 3 << 30}}
var body string
out := captureStdout(t, func() { body = snapOldTable(old, 30, 1) })
for _, want := range []string{"web01", "before-patch", "63d"} {
if !strings.Contains(body, want) {
t.Errorf("the mail leaves out %q:\n%s", want, body)
}
if !strings.Contains(stripEscapes(out), want) {
t.Errorf("the screen leaves out %q:\n%s", want, out)
}
}
if strings.ContainsRune(body, 0x1b) {
t.Errorf("the mail carries escape sequences:\n%q", body)
}
}
+44 -8
View File
@@ -11,6 +11,8 @@
package main
import (
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vim25/methods"
"github.com/vmware/govmomi/vim25/mo"
@@ -26,12 +28,32 @@ type snapEntry struct {
ref types.ManagedObjectReference
name string
desc string
created string
created string // when it was taken, as it is shown
when time.Time // and as it is compared: an age is not a string
depth int
prefix string // the branch drawn in front of the name
current bool // the state the machine is running from
}
// age is how long ago the snapshot was taken. A snapshot whose date did not
// come back has no age rather than an age of nothing: zero would read as
// "taken just now", which is the opposite of what an absent date means.
func (e snapEntry) age() (time.Duration, bool) {
if e.when.IsZero() {
return 0, false
}
return time.Since(e.when), true
}
// days is the age in whole days, for the reports that count in them.
func (e snapEntry) days() int {
d, ok := e.age()
if !ok {
return 0
}
return int(d.Hours() / 24)
}
// line is the entry as it is shown: its branch, its name, when it was taken, and
// a mark when it is the one the machine is running from.
func (e snapEntry) line() string {
@@ -75,6 +97,7 @@ func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.Ma
name: n.Name,
desc: n.Description,
created: n.CreateTime.Local().Format("02.01.2006 15:04"),
when: n.CreateTime,
depth: depth,
prefix: prefix + branch,
current: n.Snapshot == current,
@@ -88,6 +111,12 @@ func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.Ma
// snapshotsOf reads the machine's snapshots as a flat list, parents before their
// children. Empty when it has none.
//
// The sweep brings the same tree back for every machine at once (browse.go), and
// this asks for one machine's again. That is deliberate: everything that acts on
// a snapshot addresses it by reference, and a reference out of a sweep that ran
// minutes ago may name a snapshot somebody has since removed. The table may be a
// few minutes old; the list one is about to revert to may not be.
func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, error) {
vm := object.NewVirtualMachine(s.client.Client, ref)
@@ -95,15 +124,22 @@ func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, err
if err := vm.Properties(s.ctx, ref, []string{"snapshot"}, &mvm); err != nil {
return nil, errf("%s: cannot read the snapshots: %w", s.vc.Name, err)
}
if mvm.Snapshot == nil {
return nil, nil
}
return snapshotsIn(mvm.Snapshot), nil
}
current := types.ManagedObjectReference{}
if mvm.Snapshot.CurrentSnapshot != nil {
current = *mvm.Snapshot.CurrentSnapshot
// snapshotsIn is the tree as it comes out of the property collector, flattened.
// One function for both ways of getting there — the sheet asking for one machine
// and the sweep bringing back every machine — so the two cannot disagree about
// what a machine's snapshots are.
func snapshotsIn(info *types.VirtualMachineSnapshotInfo) []snapEntry {
if info == nil {
return nil
}
return flattenSnapshots(mvm.Snapshot.RootSnapshotList, current), nil
current := types.ManagedObjectReference{}
if info.CurrentSnapshot != nil {
current = *info.CurrentSnapshot
}
return flattenSnapshots(info.RootSnapshotList, current)
}
// revertToSnapshot puts the machine back to the exact snapshot given. Everything
+118 -12
View File
@@ -21,10 +21,17 @@ type sortOrder struct {
name string // what it is called, in the title and the legend
natural bool // its own direction: true means largest or busiest first
cmp func(a, b vmRow) int
// legendBreak starts a new line of the legend at this entry. Thirteen
// orders do not fit across eighty columns, and a legend that ran off the
// edge would hide the very choices it exists to offer — so it is two lines,
// broken where the meaning breaks rather than wherever the width runs out.
legendBreak bool
}
// sortOrders in the order the legend lists them: the two that identify a machine
// first, then what it is doing, then what it is made of, then where it lives.
// sortOrders in the order the legend lists them, which is two groups: first
// what a machine is doing and what it wants doing to it, then what it is made
// of and where it lives. The legend breaks between the two.
var sortOrders = []sortOrder{
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
{key: 'p', name: "power", natural: true,
@@ -33,7 +40,29 @@ var sortOrders = []sortOrder{
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
{key: 'm', name: "memory in use", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
{key: 's', name: "memory size", natural: true,
// How many rollback points the machine is carrying, most first. Nought is a
// figure here and not a missing one — nothing to clean up is a fact about
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key carries no mnemonic — every letter that does was taken — so it is
// simply one that is free and easy to reach. The name is what the command
// line takes: `--sort snapshots`, or `--sort snaps`.
{key: 'z', name: "snapshots", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
// with no snapshots has no age, and sorts to the bottom either way round.
{key: 'o', name: "snapshot age", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
// By what is wrong with the machine, worst first: broken above wants-a-look
// above nothing to report, and within each the machine with the most to
// answer for first. Sorting the reasons as text would put "alarm" above
// "disks need consolidating" and mean nothing at all.
{key: 'w', name: "issues", natural: true,
cmp: func(a, b vmRow) int { return cmpIssues(a, b) }},
{key: 's', name: "memory size", natural: true, legendBreak: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
}},
@@ -105,6 +134,30 @@ func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
return 0
}
// issueRank is how bad the machine's worst reason is: two for something broken,
// one for something that wants a look, nought for nothing to report.
func issueRank(r vmRow) int {
rank := 0
for _, i := range r.issueList() {
if i.bad {
return 2
}
rank = 1
}
return rank
}
// cmpIssues orders by that, and within it by how many reasons there are: a
// machine with a full disk *and* no Tools is worse off than one with only the
// disk. Nothing to report is nought and sorts where nought belongs, so the
// order run the other way up is the machines that are fine, by name.
func cmpIssues(a, b vmRow) int {
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
return n
}
return cmpInt(len(a.issueList()), len(b.issueList()))
}
// cmpAddress orders by address, unknown highest — which puts it last under the
// a-to-z direction this order is asked for with.
func cmpAddress(a, b vmRow) int {
@@ -180,22 +233,55 @@ func (b *browser) sortLabel() string {
return arrow + " " + b.order().name
}
// sortLegend is the one line offering the choices. Short by necessity — it shares
// the status line — and the title says what the order is anyway, so nobody who
// misses it is lost.
func sortLegend() string {
parts := make([]string, 0, len(sortOrders)+1)
// sortLegend is the choices, laid out for a terminal of this width: one line
// where they fit on one, and otherwise the two groups they fall into — what the
// machine is doing and wants doing to it, then what it is made of and where it
// lives.
//
// One line is the better answer and the usual one; two is what a narrow
// terminal gets instead of a legend that runs off the right-hand edge, hiding
// the very choices it exists to offer. Decided here, at render time, so a
// window that is dragged wider gets the one line back — the same way the table
// itself is fitted (fitColumns) and the sheet is wrapped.
//
// Terse either way: it shares the bottom of the screen with nothing but itself,
// and the title says what the order is anyway, so nobody who misses it is lost.
func sortLegend(cols int) []string {
const label = "sort: "
entries := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
parts = append(parts, string(o.key)+"·"+shortName(o.name))
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:], " "),
}
parts = append(parts, string(sortReverse)+"·reverse")
return "sort: " + strings.Join(parts, " ")
}
// shortName is the legend's spelling: the title has room for the whole name, one
// line shared with the status does not.
func shortName(name string) string {
switch name {
case "power":
return "pwr" // as the column is headed, and it keeps the legend inside 80
case "cpu load":
return "cpu%"
case "memory in use":
@@ -208,6 +294,12 @@ func shortName(name string) string {
return "vc"
case "address":
return "ip"
case "snapshot age":
return "old"
case "snapshots":
return "snaps"
case "issues":
return "why" // as the column is headed
}
return name
}
@@ -216,7 +308,17 @@ 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() {
b.prompt = &prompt{text: sortLegend(), col: colValue} // a menu, not a warning
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
cols, _ := termSize()
lines := sortLegend(cols)
b.prompt = &prompt{text: lines[0], col: colPrompt}
if len(lines) > 1 {
b.prompt.more = strings.Join(lines[1:], " ")
}
b.render()
k := b.keys.next()
b.prompt = nil
@@ -263,6 +365,10 @@ func (b *browser) sortedColumn(header string) bool {
return header == "HOST"
case "address":
return header == "IP"
case "snapshot age", "snapshots":
return header == "SNAP"
case "issues":
return header == "WHY"
}
return false
}
+149 -6
View File
@@ -204,8 +204,11 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
}
// In the table the order belongs to: sorting by what is wrong with a
// machine lights the reason column, which only the issues listing has.
table := listColumns(b.rows, o.name == "issues")
lit := 0
for _, c := range browseColumns {
for _, c := range table {
if b.sortedColumn(c.header) {
lit++
}
@@ -218,17 +221,59 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
// Every order is offered, and the legend fits a terminal of eighty.
func TestSortLegend(t *testing.T) {
legend := sortLegend()
lines := sortLegend(80)
legend := strings.Join(lines, "\n")
for _, o := range sortOrders {
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the legend does not offer %q for %s: %s", string(o.key), o.name, legend)
t.Errorf("the legend does not offer %q for %s:\n%s", string(o.key), o.name, legend)
}
}
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
t.Errorf("the legend does not offer the reverse: %s", legend)
t.Errorf("the legend does not offer the reverse:\n%s", legend)
}
if n := len([]rune(legend)); n > 78 { // a terminal of eighty, less the gutter
t.Errorf("the legend is %d columns wide: %s", n, legend)
// Every line of it fits a terminal of eighty. The legend has the bottom two
// rows to itself, and they begin at the left edge rather than behind the
// pointer's gutter, so the budget is eighty whole — but a line over it would
// be truncated, and the choices it hid would be unreachable in the only
// place they are offered.
for i, line := range lines {
if n := len([]rune(line)); n > 80 {
t.Errorf("legend line %d is %d columns wide: %s", i+1, n, line)
}
}
// Two rows, and not three: there are only two to spare.
if len(lines) > 2 {
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
}
// One line wherever one line will do — which is every terminal wide enough
// for it, and the usual case. A legend on two lines is what a narrow
// terminal gets instead of one that runs off the edge.
wide := sortLegend(200)
if len(wide) != 1 {
t.Errorf("a wide terminal gets the legend on %d lines:\n%s", len(wide), strings.Join(wide, "\n"))
}
if n := len([]rune(wide[0])); n > 200 {
t.Errorf("the one-line legend is %d columns wide", n)
}
// And every choice is on it, so nothing is reachable only when the terminal
// happens to be narrow.
for _, o := range sortOrders {
if !strings.Contains(wide[0], string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the one-line legend does not offer %q for %s: %s", string(o.key), o.name, wide[0])
}
}
if !strings.Contains(wide[0], string(sortReverse)+"·reverse") {
t.Errorf("the one-line legend does not offer the reverse: %s", wide[0])
}
// The width at which it gives up on one line is the width of the legend
// itself, and not a number written down somewhere.
if got := sortLegend(len([]rune(wide[0]))); len(got) != 1 {
t.Error("the legend broke in two at exactly its own width")
}
if got := sortLegend(len([]rune(wide[0])) - 1); len(got) != 2 {
t.Error("the legend stayed on one line one column too narrow for it")
}
// Distinct letters, or one of them would be unreachable.
@@ -285,3 +330,101 @@ func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
}
}
}
// By how many snapshots a machine is carrying. Nought is a figure here and not
// a missing one — nothing to clean up is a fact about the machine — so it sorts
// where nought belongs: at the bottom going down, at the top coming back up.
func TestSortBySnapshotCount(t *testing.T) {
rows := []vmRow{
sortRow("none", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("three", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("one", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
}
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
rows[2].snaps = []snapEntry{aged("a", 1)}
if got := orderOf(t, rows, 'z', true); got != "three one none" {
t.Errorf("most snapshots first gave %q", got)
}
if got := orderOf(t, rows, 'z', false); got != "none one three" {
t.Errorf("fewest first gave %q", got)
}
}
// By what is wrong with the machine: broken above wants-a-look above nothing to
// report, and within each the machine with the most to answer for first.
func TestSortByIssues(t *testing.T) {
rows := []vmRow{
sortRow("fine", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("warned", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("broken", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
sortRow("worse", "v308", "esx1", "10.0.0.4", 1, 1024, 0, 0, true),
}
// sortRow builds machines with no guest information at all, which reports
// nothing: the issues that are only true of a running machine need a guest
// to be true of. So each is given exactly what it is named for.
for i := range rows {
rows[i].vm.Guest = &types.GuestInfo{
ToolsRunningStatus: "guestToolsRunning",
IpAddress: rows[i].ip(),
}
}
rows[1].vm.Summary.OverallStatus = types.ManagedEntityStatusYellow // one warning
rows[2].vm.Summary.Runtime.ConsolidationNeeded = true // one breakage
rows[3].vm.Summary.Runtime.ConsolidationNeeded = true // and the same
rows[3].vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // plus a warning
if got := orderOf(t, rows, 'w', true); got != "worse broken warned fine" {
t.Errorf("worst first gave %q", got)
}
// And the other way up, the machines with nothing wrong come first, which
// is a listing worth having too.
if got := orderOf(t, rows, 'w', false); got != "fine warned broken worse" {
t.Errorf("nothing to report first gave %q", got)
}
}
// orderOf sorts the rows given by one order and returns the names in order.
//
// A key that is not an order at all is fatal here rather than left to sort by
// name: the browser's default order is index nought, so a test naming a letter
// that has been renamed would go on passing while checking the name order.
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
t.Helper()
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
found := false
for i, o := range sortOrders {
if o.key == key {
b.sortBy, found = i, true
}
}
if !found {
t.Fatalf("%q is not one of the sort orders", string(key))
}
b.applySort()
var names []string
for _, r := range b.rows {
names = append(names, r.name)
}
return strings.Join(names, " ")
}
// The two snapshot orders are different questions: how many, and how old. A
// machine with one snapshot from March needs attention before one with six
// from this morning.
func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
rows := []vmRow{
sortRow("many-new", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("one-ancient", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
}
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
rows[1].snaps = []snapEntry{aged("march", 200)}
if got := orderOf(t, rows, 'z', true); got != "many-new one-ancient" {
t.Errorf("by count: %q", got)
}
if got := orderOf(t, rows, 'o', true); got != "one-ancient many-new" {
t.Errorf("by age: %q", got)
}
}
+170
View File
@@ -0,0 +1,170 @@
// tasks.go — what a machine is in the middle of.
//
// A machine being cloned, migrated or consolidated looks in the table exactly
// like one that is idle, and that is the one moment when the table is wrong
// about the most important thing on the line: why the machine is slow, why its
// disk is growing, why it must be left alone. vCenter keeps the answer on the
// machine itself, in recentTask, so the sweep picks it up along the way.
//
// "Recent" is vCenter's word, not gvm's: a task stays on that list for minutes
// after it has finished. Only the ones that are still going on are shown — a
// finished task is history, and the event log is where history belongs.
package main
import (
"strings"
"time"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// runningTask is one thing vCenter is doing to a machine right now.
type runningTask struct {
what string // the operation, in one word
queued bool // accepted but not started yet
progress int32
since time.Time
}
// taskVerbs are the operations worth naming in the eight characters the column
// has left once a percentage is beside them — "pwr off" for the same reason the
// sort legend says pwr. The key is
// vSphere's descriptionId, which is the same word on every vCenter — the
// task's own Description is localised, so a German vCenter would put German
// into an English table.
//
// Anything not listed keeps its method name, which is still the truth and still
// tells an operator to leave the machine alone.
var taskVerbs = map[string]string{
"createSnapshot": "snapshot",
"removeSnapshot": "rm snap",
"removeAllSnapshots": "rm snaps",
"revertToSnapshot": "revert",
"consolidateDisks": "consolid",
"promoteDisks": "consolid",
"clone": "clone",
"relocate": "migrate",
"migrate": "migrate",
"reconfigure": "reconfig",
"powerOn": "pwr on",
"powerOff": "pwr off",
"suspend": "suspend",
"reset": "reset",
"shutdownGuest": "shutdown",
"rebootGuest": "reboot",
"destroy": "delete",
"customize": "custom",
"createDisk": "disk",
"extendDisk": "disk",
"upgradeTools": "tools",
"upgradeVirtualHardware": "hardware",
}
// taskVerb is the operation in one word. A descriptionId reads
// "VirtualMachine.createSnapshot"; the kind in front of the dot is already the
// row the task is on, so only what follows it says anything.
func taskVerb(descriptionID string) string {
method := descriptionID
if i := strings.LastIndexByte(method, '.'); i >= 0 {
method = method[i+1:]
}
if v, ok := taskVerbs[method]; ok {
return v
}
if method == "" {
return "busy"
}
return method
}
// cell is the task as the table shows it: what it is and how far it has got.
// A percentage is only shown once there is one — vCenter reports 0 both for
// "just started" and for "no idea", and a task sitting at 0 % looks stuck when
// it is merely young.
func (t runningTask) cell() string {
if t.queued {
return t.what + " q"
}
if t.progress > 0 {
return SF("%s %d%%", t.what, t.progress)
}
return t.what
}
// line is the task on the machine's sheet, where there is room to say when it
// started and that queued means nothing has happened yet.
func (t runningTask) line() string {
parts := []string{t.what}
switch {
case t.queued:
parts = append(parts, "queued, not started")
case t.progress > 0:
parts = append(parts, SF("%d %%", t.progress))
}
if !t.since.IsZero() {
parts = append(parts, "since "+t.since.Local().Format("15:04:05"))
}
return join(parts)
}
// runningTasks maps the machines of one sweep to what is being done to them.
// The task references come off the machines themselves and are read in one
// call for the whole inventory, so this costs one round trip per vCenter no
// matter how much is going on.
//
// A machine with two tasks at once keeps the one that has started; of two
// running ones, the first. There is one column, and "something is going on" is
// what it has to say.
func runningTasks(s *session, vms []mo.VirtualMachine) map[types.ManagedObjectReference]runningTask {
var refs []types.ManagedObjectReference
for _, vm := range vms {
refs = append(refs, vm.RecentTask...)
}
if len(refs) == 0 {
return nil
}
tasks, err := s.tasks(refs)
if err != nil {
return nil // the table is worth having without it
}
byRef := make(map[types.ManagedObjectReference]runningTask, len(tasks))
for _, t := range tasks {
rt, ok := taskOf(t.Info)
if !ok {
continue
}
byRef[t.Reference()] = rt
}
out := make(map[types.ManagedObjectReference]runningTask, len(vms))
for _, vm := range vms {
for _, ref := range vm.RecentTask {
rt, ok := byRef[ref]
if !ok {
continue
}
if cur, seen := out[vm.Reference()]; seen && (!cur.queued || rt.queued) {
continue
}
out[vm.Reference()] = rt
}
}
return out
}
// taskOf is one task, when it is still going on.
func taskOf(info types.TaskInfo) (runningTask, bool) {
switch info.State {
case types.TaskInfoStateRunning:
since := info.QueueTime
if info.StartTime != nil {
since = *info.StartTime
}
return runningTask{what: taskVerb(info.DescriptionId), progress: info.Progress, since: since}, true
case types.TaskInfoStateQueued:
return runningTask{what: taskVerb(info.DescriptionId), queued: true, since: info.QueueTime}, true
}
return runningTask{}, false
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The operation is named from vSphere's descriptionId and not from the task's
// own Description, which vCenter localises: a German vCenter would otherwise
// put German words in an English table.
func TestTaskVerb(t *testing.T) {
for _, c := range []struct{ id, want string }{
{"VirtualMachine.createSnapshot", "snapshot"},
{"VirtualMachine.removeAllSnapshots", "rm snaps"},
{"VirtualMachine.relocate", "migrate"},
{"VirtualMachine.reconfigure", "reconfig"},
{"Datacenter.somethingNobodyHasHeardOf", "somethingNobodyHasHeardOf"},
{"noDotAtAll", "noDotAtAll"},
{"", "busy"},
} {
if got := taskVerb(c.id); got != c.want {
t.Errorf("taskVerb(%q) = %q, want %q", c.id, got, c.want)
}
}
}
// Every verb fits the column, or the cell it is put in would be truncated with
// the percentage — the part that says whether anything is happening — cut off.
func TestEveryTaskVerbFitsItsColumn(t *testing.T) {
room := taskColumn.width - len(" 100%")
for id, verb := range taskVerbs {
if len(verb) > room {
t.Errorf("%s is called %q, which is %d characters of the %d there are",
id, verb, len(verb), room)
}
}
}
// A task at nought per cent has not reported any progress, which is not the
// same as having made none: printing 0 % makes a task that has just started
// look stuck.
func TestTaskCell(t *testing.T) {
for _, c := range []struct {
task runningTask
want string
}{
{runningTask{what: "clone", progress: 40}, "clone 40%"},
{runningTask{what: "clone"}, "clone"},
{runningTask{what: "clone", queued: true}, "clone q"},
{runningTask{what: "clone", queued: true, progress: 10}, "clone q"},
} {
if got := c.task.cell(); got != c.want {
t.Errorf("cell() = %q, want %q", got, c.want)
}
}
}
func TestTaskLineSpellsQueuedOut(t *testing.T) {
since := time.Date(2026, 9, 8, 11, 42, 0, 0, time.Local)
got := runningTask{what: "consolid", queued: true, since: since}.line()
if got != "consolid · queued, not started · since 11:42:00" {
t.Errorf("the sheet line reads %q", got)
}
got = runningTask{what: "clone", progress: 40, since: since}.line()
if got != "clone · 40 % · since 11:42:00" {
t.Errorf("the sheet line reads %q", got)
}
}
// Only what is still going on is a task. A task that has finished stays on
// vCenter's recentTask list for minutes afterwards, and a table that showed it
// would report a snapshot being taken long after it was taken.
func TestOnlyRunningTasksCount(t *testing.T) {
for _, c := range []struct {
state types.TaskInfoState
want bool
}{
{types.TaskInfoStateRunning, true},
{types.TaskInfoStateQueued, true},
{types.TaskInfoStateSuccess, false},
{types.TaskInfoStateError, false},
} {
_, ok := taskOf(types.TaskInfo{State: c.state, DescriptionId: "VirtualMachine.clone"})
if ok != c.want {
t.Errorf("a %s task counts = %v, want %v", c.state, ok, c.want)
}
}
}
// A running task is timed from when it started, a queued one from when it was
// accepted — there is nothing else to time it from.
func TestTaskTakesItsTimeFromTheRightEnd(t *testing.T) {
queued := time.Date(2026, 9, 8, 11, 0, 0, 0, time.UTC)
started := time.Date(2026, 9, 8, 11, 5, 0, 0, time.UTC)
got, _ := taskOf(types.TaskInfo{
State: types.TaskInfoStateRunning, QueueTime: queued, StartTime: &started,
DescriptionId: "VirtualMachine.clone",
})
if !got.since.Equal(started) {
t.Errorf("a running task is timed from %v", got.since)
}
got, _ = taskOf(types.TaskInfo{
State: types.TaskInfoStateQueued, QueueTime: queued,
DescriptionId: "VirtualMachine.clone",
})
if !got.since.Equal(queued) {
t.Errorf("a queued task is timed from %v", got.since)
}
}
+12
View File
@@ -49,6 +49,18 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
}
}
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
tmp := ""
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
if err != nil {
if err == terminal.InterruptErr {
P(Crb("Interrupted."))
os.Exit(0)
}
}
return tmp
}
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
ret := make([]byte, n)
+79 -13
View File
@@ -83,9 +83,12 @@ const (
keyShiftTab
keyEnter
keyCtrlA
keyCtrlE
keyCtrlL
keyCtrlO
keyCtrlR
keyCtrlS
keyCtrlW
keyCtrlC
keyEsc
)
@@ -132,17 +135,53 @@ func (kr *keyReader) next() key {
if !ok {
return key{special: keyCtrlC} // input closed - treat like cancel
}
return kr.decode(b)
}
// nextWithin is next() with a limit on how long it waits — for live mode, which
// has to be able to stop waiting and re-read the list.
//
// The limit is on the *first* byte only, which is why it is here and not around
// next() as a whole: a control sequence arrives in one burst, and a deadline
// that could expire in the middle of "ESC [ A" would turn one arrow key into an
// Esc and a stray letter in the filter.
func (kr *keyReader) nextWithin(d time.Duration) (key, bool) {
select {
case b, ok := <-kr.ch:
if !ok {
return key{special: keyCtrlC}, true
}
return kr.decode(b), true
case <-time.After(d):
return key{}, false
}
}
// decode turns one byte, and whatever else belongs with it, into a key.
func (kr *keyReader) decode(b byte) key {
switch b {
case 0x03:
return key{special: keyCtrlC}
case 0x01:
return key{special: keyCtrlA}
case 0x05:
return key{special: keyCtrlE}
// ^l, which in a shell redraws the screen. Nothing is lost by taking it:
// gvm redraws the whole screen on every keystroke anyway, so there is
// nothing here for a redraw key to fix.
case 0x0c:
return key{special: keyCtrlL}
case 0x0f:
return key{special: keyCtrlO}
case 0x12:
return key{special: keyCtrlR}
case 0x13:
return key{special: keyCtrlS}
// ^w, and not the ^i the mnemonic wants: Ctrl-I *is* Tab (0x09), which the
// list already moves down with, so an issues filter bound to it would have
// scrolled the table instead.
case 0x17:
return key{special: keyCtrlW}
case 0x1b:
return kr.readEscape()
case '\r', '\n':
@@ -186,11 +225,39 @@ func (kr *keyReader) readEscape() key {
if b2 != '[' {
return key{special: keyEsc}
}
b3, ok := kr.readByte()
if !ok {
return key{special: keyEsc}
// Read the whole sequence before deciding what it was. A control sequence is
// ESC [ then parameter bytes then one final byte in 0x40..0x7e, and how many
// parameters there are depends on the key *and* on which modifiers were held:
// the plain up arrow is "ESC [ A", the same key with control is "ESC [ 1;5A".
//
// Reading a fixed number of bytes instead — one, and for the digits one more
// for the "~" — left the rest of a longer sequence in the stream, where the
// next read took it for typing. Ctrl-Up put "5A" into the filter and F5 put a
// tilde in it, having first jumped to the top of the list.
params := make([]byte, 0, 8)
var final byte
for {
b, ok := kr.readByte()
if !ok {
return key{special: keyEsc}
}
if b >= 0x40 && b <= 0x7e {
final = b
break
}
if len(params) < cap(params) {
params = append(params, b)
}
}
switch b3 {
// Only the unmodified keys are answered. A sequence with modifiers, or one
// this does not know, is swallowed whole and ignored — which is the point:
// what must not happen is for half of it to arrive as text.
if len(params) > 0 && final != '~' {
return key{special: keyNone}
}
switch final {
case 'A':
return key{special: keyUp}
case 'B':
@@ -205,22 +272,21 @@ func (kr *keyReader) readEscape() key {
return key{special: keyEnd}
case 'Z':
return key{special: keyShiftTab}
case '1', '3', '4', '5', '6':
kr.readByte() // consume trailing '~'
switch b3 {
case '1':
case '~':
switch string(params) {
case "1", "7":
return key{special: keyHome}
case '3':
case "3":
return key{special: keyDelete}
case '4':
case "4", "8":
return key{special: keyEnd}
case '5':
case "5":
return key{special: keyPgUp}
case '6':
case "6":
return key{special: keyPgDn}
}
}
return key{special: keyEsc}
return key{special: keyNone}
}
func utf8SeqLen(b byte) int {
+84 -4
View File
@@ -15,8 +15,10 @@ import (
"github.com/vmware/govmomi"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/property"
"github.com/vmware/govmomi/view"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// dialTimeout bounds a login. Without one a vCenter that accepts the connection
@@ -30,6 +32,12 @@ type session struct {
ctx context.Context
client *govmomi.Client
cancel context.CancelFunc
// The names of the alarm definitions this server has triggered, filled in by
// the sweep when anything is actually alarming (browse.go). It belongs to
// the connection rather than to a machine: one alarm stands against many
// machines, and its name is worth reading once per server, not once per row.
alarms map[types.ManagedObjectReference]string
}
// connect logs in to one server. The caller closes what comes back — a session
@@ -38,12 +46,11 @@ type session struct {
func connect(vc VCenter) (*session, error) {
ctx, cancel := context.WithCancel(context.Background())
u, err := url.Parse(vc.sdkURL())
u, err := loginURL(vc)
if err != nil {
cancel()
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
return nil, err
}
u.User = url.UserPassword(vc.User, vc.Password)
dial, dialCancel := context.WithTimeout(ctx, dialTimeout)
defer dialCancel()
@@ -56,6 +63,24 @@ func connect(vc VCenter) (*session, error) {
return &session{vc: vc, ctx: ctx, client: client, cancel: cancel}, nil
}
// loginURL is the endpoint with the credentials in it — the one place where a
// password is opened and handed over. Its own function so that what goes on the
// wire can be checked without a server: whether the password that leaves here is
// the opened one, and not the sealed word out of the file, is the whole claim of
// seal.go.
func loginURL(vc VCenter) (*url.URL, error) {
u, err := url.Parse(vc.sdkURL())
if err != nil {
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
}
secret, err := vc.password()
if err != nil {
return nil, err
}
u.User = url.UserPassword(vc.User, secret)
return u, nil
}
// close logs out and drops the context. Logging out is best effort: there is
// nothing useful to do about a failure while shutting down.
func (s *session) close() {
@@ -105,7 +130,8 @@ func (s *session) retrieve(kind string, props []string, dst any) error {
return nil
}
// vms and hosts are the two inventory sweeps gvm makes.
// vms, hosts and datastores are the inventory sweeps gvm makes: one call each,
// for everything of that kind in the whole inventory.
func (s *session) vms(props ...string) ([]mo.VirtualMachine, error) {
var out []mo.VirtualMachine
return out, s.retrieve("VirtualMachine", props, &out)
@@ -115,3 +141,57 @@ func (s *session) hosts(props ...string) ([]mo.HostSystem, error) {
var out []mo.HostSystem
return out, s.retrieve("HostSystem", props, &out)
}
func (s *session) datastores(props ...string) ([]mo.Datastore, error) {
var out []mo.Datastore
return out, s.retrieve("Datastore", props, &out)
}
// instanceUUID identifies this vCenter to itself: it is the serverGuid the
// vSphere client puts in the URLs of the objects it shows, which is the one
// thing gvm cannot work out from the configuration alone (see vsphereURL).
func (s *session) instanceUUID() string {
if s.client == nil {
return ""
}
return s.client.ServiceContent.About.InstanceUuid
}
// objects fills dst with the named properties of exactly the objects given,
// rather than of everything of a kind. Alarm definitions and tasks are not in
// the inventory container view — they hang off their managers — so the only way
// to read them is by reference, and by all of them in one call: one round trip
// for a screenful, not one per line.
func (s *session) objects(refs []types.ManagedObjectReference, props []string, dst any) error {
if len(refs) == 0 {
return nil
}
if err := property.DefaultCollector(s.client.Client).
Retrieve(s.ctx, refs, props, dst); err != nil {
return fmt.Errorf("%s: cannot read %d objects: %w", s.vc.Name, len(refs), err)
}
return nil
}
// tasks reads what those task references are doing. A task that has finished is
// still on a machine's recentTask list for a while afterwards, so the caller
// decides what counts as going on; this only reports.
func (s *session) tasks(refs []types.ManagedObjectReference) ([]mo.Task, error) {
var out []mo.Task
return out, s.objects(refs, []string{"info"}, &out)
}
// alarmNames resolves alarm definitions to the names a person gave them. A
// triggered alarm carries only the reference of its definition, and "alarm-3 is
// red" is not something anyone can act on.
func (s *session) alarmNames(refs []types.ManagedObjectReference) map[types.ManagedObjectReference]string {
var alarms []mo.Alarm
if err := s.objects(refs, []string{"info.name"}, &alarms); err != nil {
return nil // the names are a courtesy; the references still say which
}
out := make(map[types.ManagedObjectReference]string, len(alarms))
for _, a := range alarms {
out[a.Reference()] = a.Info.Name
}
return out
}
+1 -1
View File
@@ -1 +1 @@
1.0.15
1.3.3
+62 -15
View File
@@ -14,22 +14,40 @@ import (
"github.com/fatih/color"
)
// lsOptions is what one printed listing was asked for. A struct rather than six
// arguments in a row: three of them are booleans, and a call site reading
// (rows, "", false, true, false) says nothing about which is which.
type lsOptions struct {
match string // regexp on the machine's name
orderBy string // one of sortOrders, by letter or by name
reverse bool
issues bool // only the machines with something wrong with them
json bool // as a document instead of a table
}
// lsvm prints the machines of every server it is given. One unreachable vCenter
// is a line of complaint, not the end of the listing.
func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
re, err := regexp.Compile("(?i)" + match)
func lsvm(targets []VCenter, opt lsOptions) error {
re, err := regexp.Compile("(?i)" + opt.match)
if err != nil {
return errf("bad pattern %q: %w", match, err)
return errf("bad pattern %q: %w", opt.match, err)
}
by, err := findOrder(orderBy)
by, err := findOrder(opt.orderBy)
if err != nil {
return err
}
found, err := gatherVMs(targets)
defer closeSessions(found.sessions)
for _, why := range found.failed {
PE(why) // said before the table, where it will not be scrolled past
// In a table the failures are said before it, where they will not be
// scrolled past. In a document they belong *in* it: a line of prose in the
// middle of the JSON would break whatever is reading it, and a script that
// cannot tell "no machines" from "the server did not answer" is a script
// that reports an empty cluster.
if !opt.json {
for _, why := range found.failed {
PE(why)
}
}
if err != nil {
return err
@@ -41,24 +59,45 @@ func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
rows = append(rows, r)
}
}
sortRows(rows, by, reverse != sortOrders[by].natural)
if opt.issues {
rows = withIssues(rows)
}
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
printList(rows)
if opt.json {
return printJSON(found, rows)
}
// Nothing to report, and nobody watching: say nothing at all. This is the
// listing that belongs in cron, and cron mails whatever a command prints —
// so a daily "nothing wrong" would be a daily mail nobody reads, and the
// one morning it did not arrive would mean nothing either. On a terminal
// somebody is waiting for an answer, so there it is said.
if opt.issues && len(rows) == 0 {
if !color.NoColor {
PF("%s\n", Cgb(SF("nothing to report on any of the %d machines", len(found.rows))))
}
return nil
}
printList(rows, listColumns(rows, opt.issues))
return nil
}
// printList writes the table. On a terminal it is fitted to the width, the same
// as the interactive one; into a pipe every column is written, because there
// nothing is watching and the next program wants the lot.
func printList(rows []vmRow) {
cols := fitColumns(printWidth())
// as the interactive one; into a pipe every column is written and every column is
// made as wide as the longest thing in it.
//
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
// machine names off at twenty-two characters — silently, with an ellipsis, into
// output whose whole purpose is to be read by something else.
func printList(rows []vmRow, table []browseColumn) {
cols := fitColumnsOf(table, printWidth(table))
pcs := make([]printColumn, len(cols))
for i, c := range cols {
pcs[i] = printColumn{header: c.header, width: c.width}
}
printRow(pcs, "", nil)
body := make([][]cell, 0, len(rows))
for _, r := range rows {
cells := make([]cell, len(cols))
for i, c := range cols {
@@ -68,6 +107,14 @@ func printList(rows []vmRow) {
}
cells[i] = cell{text: c.cell(r), col: col}
}
body = append(body, cells)
}
if color.NoColor { // no terminal, so nothing to fit into and nothing to cut for
widen(pcs, body)
}
printRow(pcs, "", nil)
for _, cells := range body {
printRow(pcs, "", cells)
}
}
@@ -75,9 +122,9 @@ func printList(rows []vmRow) {
// printWidth is what the table is laid out for. color.NoColor is the answer to
// "is this a terminal" that the colours already go by, so the two cannot disagree
// about where the output is headed.
func printWidth() int {
func printWidth(table []browseColumn) int {
if color.NoColor {
return tableWidth(browseColumns)
return tableWidth(table)
}
cols, _ := termSize()
return max(cols, 20)