19 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 94ebe679b7 [mike@mwxm4]
One dialect: -j, and nothing older than v2.5.0 is talked to.

The text road is gone. It said the same things in a sentence and exited 0 while
refusing, and keeping it meant keeping a second way to be wrong for the sake of
versions nobody is running.

An older helper is not silently ignored. "There is none" and "the one you have
is too old" send somebody to two different places, so the helper carries why it
cannot be used and both the refusal and `gvm config` say it:

    /usr/local/bin/dns is 2.4.4, and gvm wants 2.5.0 or newer — 'dns --update' fetches it

A version that cannot be read at all is a third reason, said as itself: gvm
would rather say so than talk to something whose answers it cannot predict.

And the version check found a bug in itself while being tested. The line -v
prints carries two versions —

    dns - infoblox helper (v2.5.0 (1282), toolbox v0.5.0, mwx'2026)

— and taking whichever came first read the toolbox's 0.5.0 the moment the
tool's own could not be read. Which is exactly the quiet wrong answer the check
exists to prevent. It is anchored on the bracket now, and the trap is a test
case of its own.

Checked read-only against the real v2.5.0: read as 2.5.0, usable, and a host
lookup coming back with its address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:30:33 +02:00
Michael WesemannandClaude Opus 5 cc4daece4b [mike@mwxm4]
Ask the dns helper with -j, where it understands it.

From v2.5.0 it answers in an envelope rather than a sentence, and the envelope
is better in every way that matters here: the reason lives in a field of its
own instead of in free text, and the exit status agrees with it at last.

    {"ok":true, "action":"showhost","name":"v308.fhi.mpg.de","record":{...}}
    {"ok":false,"action":"showhost","error":"host '...' not found"}

gvm asks the helper its version once, when it finds it, and reads it that way
from then on — a probe per call would be three for one deployment, and a
dialect worked out from the shape of an answer would be a guess made under
pressure. A version that cannot be read is taken for an old one: the older way
works on both, and guessing "new" would leave gvm looking for an envelope that
is not there. `gvm config` says which of the two it found.

Only ok and error are read off the acting calls. What -a carries beyond that is
not needed and therefore not assumed: the address still comes from reading the
record back afterwards, which is the appliance's own answer to the same
question and the thing the machine will actually have.

Everything above the one function that knows about dialects is written once,
against a reply of one shape — that is the whole point of having such a
function rather than two of everything.

Both roads are tested, against a stand-in that speaks either on demand. The
older one is exactly the kind of thing that rots unnoticed once nobody uses it,
and it is the one whose habit of exiting 0 while refusing broke the first
version of this code. Checked read-only against the real v2.5.0 as well: taken
for json, an existing name giving its address, a missing one reading as free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:23:56 +02:00
Michael WesemannandClaude Opus 5 5b23d6b3cd [mike@mwxm4]
Write down why snapshots are taken without memory and without quiescing.

No change in behaviour — Mike confirmed both are what he wants. What was
missing was the reason, on a call whose two false arguments decide what a
rollback gets back, and which nothing in the code or the README explained.

Memory would keep the running machine's RAM as well, so a rollback came back
mid-flight, at the price of writing all of it to the datastore every time.
These snapshots are for the moment before a patch, where coming back to a
machine that boots is the point.

Quiescing would have VMware Tools still the guest's filesystems first. Without
it the disk state is crash-consistent — what a machine finds after the plug is
pulled — which a journalling filesystem handles and a database may not; and the
snapshot neither depends on Tools running nor stops when they are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:14:53 +02:00
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
26 changed files with 6710 additions and 94 deletions
+1
View File
@@ -15,3 +15,4 @@ bin/
tmp/
gvm
.gvmrc
.claude/
+457 -5
View File
@@ -44,6 +44,13 @@ setting has an environment spelling that wins over the file — `GVM_VCENTER_V30
`GVM_MAILTO`, `GVM_DEFAULT` and so on — which is how to run gvm from cron
without the password living in a file.
Besides the servers it holds the site's own answers, each written once: where
mail goes, where telemetry is posted, how `h` logs in to a guest, and what a
machine made from a template is told about the network (`domain`, `dns`,
`netmask`, `gateway`, `timezone` — see *Making a machine from a template*).
`gvm config` prints the lot, passwords as set or not set, and names what is
missing for anything that is half configured.
The file holds passwords, so gvm creates it mode 0600 and complains when it
finds it readable by others.
@@ -97,6 +104,14 @@ not.
| `power -b <vm>` | ask the guest to reboot (needs VMware Tools) |
| `power --off <vm>` | power off at the hypervisor — hard |
| `power --reset <vm>` | reset at the hypervisor — hard |
| `size --vm <vm>` | what it has: vCPUs, memory, and what may be changed while it runs |
| `size --vm <vm> -c 8 -m 16` | give it 8 vCPUs and 16 GB — `-m 512m` for megabytes |
| `new --from <template> --name <vm>` | make a new machine from a template |
| `new ... --host <h> --datastore <ds> --on` | where it lands, and whether it starts |
| `new ... --spec <spec> --ip <addr>` | customise the guest: hostname and address |
| `new ... --ip auto` | have the site's `dns` tool hand an address out |
| `new --specs` | the customisation specifications this vCenter holds |
| `config` | also shows what a new guest would be told about the network |
| `host [-t]` | per-host cpu, memory, machine counts; `-t` also posts them |
| `host -c` | just the machine counts |
| `ds [-t]` | per-datastore capacity, free space, over-commitment; `-t` also posts them |
@@ -133,6 +148,8 @@ including one long word such as a datastore path, so a narrow terminal loses
nothing. The machine's name, vCenter, datacenter and host are the title.
^o sort the table (see below)
^w only the machines with something wrong with them (see below)
^e the whole estate on one screen (see below)
^l live: the list re-reads itself (see below)
^r ask the servers again
esc clear the filter, or leave when there is none
^c leave
@@ -175,6 +192,126 @@ column's minimum is one notch narrower than it reads in order to buy the address
its place there, and the count is the one column here that has somewhere else to
be said — `^w`, `--issues` and `snap --old` all name it and date it.
### The estate
`^e` answers the question the machine list cannot: **where is there still room.**
Every host of every server that answered, grouped under its cluster, with what
it carries set against what it has:
Estate v308, v309 14 hosts · 212 machines
CLUSTER / HOST ON/VM vCPU x MEM ALLOC x CPU LOAD % MEM USED %
v308 · prod 58/61 196/96 2.0x 1.1TB/768.0GB 1.5x
▸ esx01 21/22 72/32 2.2x 384.0GB/256.0GB 1.5x ████······ 41 ███████··· 72
esx02 19/20 68/32 2.1x 360.0GB/256.0GB 1.4x ███······· 32 ██████···· 64
esx03 18/19 56/32 1.7x 376.0GB/256.0GB 1.4x █████····· 53 ████████·· 81
v308 · standalone 4/4 8/16 32.0GB/128.0GB ██········ 12 ██········ 21
Two kinds of number, and the difference between them is the whole point — which
is also what decides what a narrow terminal gives up. Three layouts, each the
widest that fits whole: everything with the load drawn as bars; the same figures
with the bars down to their percentages; and, narrower still, only the two
ratios and the two percentages. Both kinds of number survive all three, because
one of them alone says nothing.
**What is allocated** — every vCPU and every megabyte the machines on a host have
been promised, added up. It routinely exceeds the host and is meant to: the ratio
is the figure worth having, because 1.5x of memory is a decision somebody made
and 8.0x is one somebody forgot. It is left blank where a host has room to
spare, since a column of "0.4x" down a screen of healthy hosts is noise where
the point is to spot the one that is over.
**What is in use** — what the host itself reports it is doing, as a bar and a
percentage. A host at 2.2x allocation and 41 % load is fine; the same host at
90 % is not, and no allocation figure can tell those two apart. A load that is
not known draws nothing rather than an empty trough: a host at one per cent
fills none of the bar either, and "almost idle" must not look like "I cannot see
this host".
A host vCenter has lost touch with keeps its hardware figures — they do not
depend on reaching it — but reports no load at all rather than the nought its
cached statistics would give: an empty bar is what an idle host looks like.
Only running machines are charged to a host. A machine that is switched off has
been promised nothing it is using, and counting it would make a host of parked
machines look full when the whole point of parking them there was that it is
not — they are still in the ON/VM count, which is where that belongs.
`⏎` on a host is the other half: it goes back to the machine list with the filter
set to that host, so the answer to "what is on this one" is the table everybody
already knows how to read, and `Esc` undoes it. The count it reports is the
host's own, not the filtered list's: the filter is a text match over the whole
row and carries no server, so it can also catch another vCenter's host of the
same name or a machine named after a host, and a count taken from it would then
contradict the screen it came from. `^r` reads the screen again —
live mode deliberately does not tick here, because this screen reads the hosts
itself and a timer doing that every ten seconds would be paying for a screen
somebody is reading rather than watching.
The allocations are added up from the rows the list already holds, so no machine
is read twice for this, and they are matched to hosts by reference rather than by
name — the same lesson `gvm host` carries a comment about, where a host added by
address and renamed later reported zero machines while running dozens.
### Live
`^l` and the list stops being a snapshot. It re-reads itself every ten seconds —
every two while vCenter is doing anything at all, so a clone's progress is
something one watches finish rather than a figure one waits for — and the title
says so, because a screen that moves on its own with nothing to explain it reads
as a fault.
It refreshes over the connections that are already open. `^r` logs in again,
which is how a session that has died is recovered; doing that every ten seconds
would be three logins a minute for nothing.
Two things come with it.
**The line under the table says what just changed.** A table shows what is; this
is the only thing on the screen that says what *became*:
db01 off · web01 +1 snapshot · app07 clone · esx03: disks need consolidating
Power states, snapshots appearing and going, tasks starting and finishing,
machines arriving and leaving, and vCenter starting to complain. Four of them and
a count of the rest, because a line that has to be read carefully is a line
nobody reads. It is the reason to leave the thing open.
Where two vCenters hold a machine of the same name the server goes in front of
it — "v309 web01 off" — and only there: a name is not what makes a machine that
machine, and the width is worth spending on the rare case rather than on every
line. A quiet refresh clears that line and nothing else: a message somebody
produced by pressing a key is theirs to keep.
**The `CPU~` column is where each machine has just been.** Six sweeps of its
processor load, one character each:
NAME PWR CPU CPU% CPU~ MEM
app07 on 4 18 ▃▅█▆▄▂ 8.0GB
web01 on 4 82 ▄▂▁▃▅█ 8.0GB
The scale is fixed at 0 to 100 and never fitted to the samples: a line that
scales itself would make a machine idling between 1 and 2 per cent look exactly
like one swinging between 40 and 80. The column is there only once there is
something in it — a machine has a history after its second sweep — and it is the
first thing after the guest's operating system that a narrow terminal gives up:
everything else in the table is a fact about a machine, and this is a shape.
A machine that is not running has no load rather than a load of zero, so nothing
is sampled from it and it draws nothing — and what it had drawn before goes with
it, rather than leaving a busy shape beside a CPU% of "-".
The refresh holds still for every screen that asks something: a menu decides what
it offers from the state it was drawn with, the picker holds a list being chosen
from, and a half-typed name or a confirmation is an answer in progress. Being ten
seconds out of date is better than any of those moving under a hand. A machine's
sheet *is* refreshed, at the line it was being read at — watching one machine's
memory is a reason to have it open.
Nothing here acts on a machine. A refresh that could start or stop something
would be a timer with the power to do it, and that is the one thing a screen left
open unattended must not have.
### Sorting
`^o` puts a legend on the status line and the next key picks the order, so the
@@ -307,6 +444,215 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
name is on the status line, and a sheet that is open jumps to its snapshot
section so the new one is there to see.
Snapshots are taken **without memory and without quiescing**, and both are
deliberate. Memory would keep the running machine's RAM too, so that a rollback
came back mid-flight — at the price of writing all of it to the datastore every
time, and of a rollback that restores a process tree along with the disks. What
these are for is the moment before a patch, where coming back to a machine that
*boots* is the point. Leaving quiescing off makes the disk state
crash-consistent — what a machine finds after the plug is pulled, which a
journalling filesystem handles and a database may not — and means a snapshot
neither depends on VMware Tools running nor stops when they are not.
### Making a machine from a template
`p` in a template's action menu, `gvm new` on the command line. It is the one
thing gvm does that brings a machine into being rather than acting on one that
already exists, and that makes its hard question *where* rather than *whether*.
A template is not a machine that happens to be switched off. vSphere takes its
resource pool away when it is marked as one, so a copy of it has nowhere to run
until somebody says where — and that is the one thing that cannot be inherited
from the source. Everything else is: the folder it sits in, the datastore it
lives on, the hardware it was built with.
So the placement is worked out first and the question says it in full:
make web05 from ubuntu-24.04-template on v308? y = yes, anything else = no
it will run in prod-cluster, powered off
The resource pool is the one the template's own host belongs to — the cluster's,
where it is in a cluster, which leaves the choice of host to DRS the way every
other deployment there does. `--host` overrides that and pins it; `--datastore`
puts it somewhere other than the template's own.
**It does not wait.** A clone copies every disk the template has, which is
minutes to the half hour, and a list frozen for that long is a list nobody would
start one from. The task hangs off the template in vCenter, so the row it was
started from shows how far along it is in its TASK column — `^l` makes that move
on its own, and announces the new machine on the line under the table when it
exists. `gvm new` on the command line does wait: a script that gets its prompt
back wants the machine to be there.
The rest of a template's menu is greyed out with "a template" next to it, because
vSphere will not start one, snapshot one or reconfigure one. They are greyed
rather than left out: a menu that changes shape between rows is one nobody
learns.
#### Telling the guest what it is
A copy of a template comes up as the template: same hostname, same address. Those
two are the facts that are about *this* machine, and everything else — the
domain, the resolvers, the netmask, the gateway, the timezone — is the site's
answer, the same for every machine. So the two are typed and the rest is kept
somewhere it is written once.
**Two places it can be kept, and gvm takes it from either.**
*In `~/.gvmrc`*, which is the short road for a site that keeps no specifications
in its vCenter:
domain = fhi-berlin.mpg.de
dns = 141.14.128.1, 141.14.128.2
netmask = 255.255.255.0
gateway = 10.0.0.1
timezone = Europe/Berlin
gvm new --from ubuntu-tpl --name web05 --ip 10.0.0.55
gvm writes the customisation itself from those: hostname from the machine's name
(`--hostname` where they differ), address from `--ip`, and the five above. It is
**Linux only** — a Windows guest is a Sysprep, which is a licence key, an
administrator password and a domain to join, and none of that belongs in a
configuration file next to the SMTP relay. `gvm config` shows what it would
write, and names the settings that are still missing.
*In the vCenter*, as a customisation specification made in the vSphere client
under *Policies and Profiles*:
gvm new --from ubuntu-tpl --name web05 --spec linux-static --ip 10.0.0.55
`--spec` names one, and **gvm overrides exactly two things in it** — the
hostname and the address — and leaves the rest alone. This is the road for
Windows, and the one to prefer where the specifications already exist: the
policy is then where the web client and every other tool can see it too. A tool
that knew better than the vCenter about any of the rest is how one ends up with
a network policy nobody remembers agreeing to.
In the list it is a step of its own: after the name, a picker of whichever roads
are open —
customise web05 how v308
▸ leave the guest as the template made it
this site — fhi-berlin.mpg.de, gateway 10.0.0.1
linux-static
windows-domain
— and then one line for the address, which says in words what leaving it empty
would do:
address for web05, or empty for DHCP:
address for web05, or empty to leave it to linux-static:
Where that picker does **not** appear, the confirmation says why rather than
leaving a step to look broken — neither road is open, because the vCenter holds
no specifications (or will not say) *and* `~/.gvmrc` has not been told the site:
make web05 from ubuntu-tpl on v308? y = yes, anything else = no
no specifications on v308, and ~/.gvmrc has no domain, netmask, gateway · in prod-cluster, powered off
`gvm new --specs` lists what a server holds, and prints the server's own words
when it will not say.
#### An address it fetches rather than one you look up
gvm new --from ubuntu-tpl --name web05 --ip auto
Where the site has a tool that hands addresses out, `--ip auto` asks it instead
of making you look one up first. In the list the same word does it:
address for web05, "auto" for one from dns, empty for DHCP:
It shells out to `dns`, the Infoblox helper — found on the path by that name, or
named as `dnstool` in `~/.gvmrc` where it lives somewhere else. Everything it
knows (the appliance, the credentials, the default domain) stays its own; a
second copy of any of that inside gvm would be a second thing to keep right.
`gvm config` says whether one was found, because "why was I not offered that"
is the question that follows an option quietly not being there.
**The name has to be free.** It is asked before anything is added, and a name
the appliance already knows is refused rather than added to — 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'
Which is the same question gvm asks the vCenter about the machine's name, one
answer short of the same answer.
**And the address is said where it stays.** In the confirmation, and again in
the line that is left on the screen afterwards — this is a number somebody
writes down:
web05 is being made at 141.14.140.182 — ubuntu-tpl shows how far along it is
Three more things about it are deliberate:
* **It is asked with `-j`, and nothing older than v2.5.0 is asked at all.**
From that version the helper answers in an envelope: `ok`, the reason in an
`error` field of its own, the record — and an exit status that agrees with it.
{"ok":true, "action":"showhost","name":"v308.fhi.mpg.de","record":{...}}
{"ok":false,"action":"showhost","error":"host '...' not found"}
What came before said the same things in a sentence and **exited 0 while
refusing**, which is what an earlier version of this got wrong by believing
exit statuses. Rather than keep reading both, gvm asks the helper its version
once and uses it only from 2.5.0 up. An older one is not silently ignored —
"there is none" and "the one you have is too old" send somebody to two
different places, so it says which:
/usr/local/bin/dns is 2.4.4, and gvm wants 2.5.0 or newer — 'dns --update' fetches it
`gvm config` says the same thing when nothing is being deployed.
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.
* **The record is read back**, not taken from the sentence the helper prints.
`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 to the same question, and where the two disagree the record is what the
machine will have.
* **It is fetched before the confirmation**, so that the question says the
address rather than a promise of one:
make web05 from ubuntu-tpl on v308? y = yes, anything else = no
in prod, powered off · named and addressed for fhi.mpg.de at 141.14.140.182 (web05.fhi.mpg.de, from dns)
* **And given back where it is not used.** A deployment abandoned at that
question, or one the vCenter then refuses, releases the address rather than
leaving a record behind for a machine that was never made.
The name asked for is the machine's own — the short one, since the helper puts
the record in the site's default domain itself — which is also the hostname the
guest is given, so the two cannot disagree.
Three things it refuses rather than guesses at, on either road:
* **An address with no netmask to go with it.** Where the specification's
adapter takes its address from DHCP it carries no mask, and a machine given an
address without one half works. Where to get a mask from is a question for the
vCenter.
* **A hand-written Windows answer file**, and a cloud-init specification. Both
carry the hostname inside a format gvm does not own, and reaching into either
to change one line would be guessing.
* **Nothing at all**, silently. If the template reports no VMware Tools — which
is what carries a customisation out inside the guest — it says so before the
clone rather than leaving it to be discovered twenty minutes later on a
machine that came up under the template's name. It is a guess and is said as
one: the machine is made either way.
One more thing it says rather than refuses: a gateway that is not on the
machine's own network. A routed setup can put one anywhere, so it is not an
error — but almost every time it is a typo, and a machine that cannot reach its
gateway is one somebody drives to the console for.
Deploying without either road still works, and the guest comes up as the
template made it.
### The sheet's four letters
The four letters are the things that change nothing, on the machine or on the
@@ -372,12 +718,18 @@ this is about.
r revert to a snapshot ... s shut down the guest
d remove a snapshot ... b reboot the guest
D remove ALL snapshots S power off (hard)
B reset (hard)
──────────────────────────────── B reset (hard)
c change the vCPU count ...
m change the memory ...
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
always needs the shift key. What cannot be done right now is greyed out with the
reason next to it — "no VMware Tools", "already running", "no snapshots" — rather
than left out, and picking it anyway spells the reason out instead of running it.
Of the power pairs, lowercase asks the guest and uppercase acts at the
hypervisor: the violent variant always needs the shift key. What cannot be done
right now is greyed out with the reason next to it — "no VMware Tools", "already
running", "no snapshots", "needs it off" — rather than left out, and picking it
anyway spells the reason out instead of running it.
The three groups are what the machine has been, what it is, and what it is
doing.
Snapshots are drawn as the tree they are — which state descends from which is
the whole point of a snapshot list — and the one the machine is running from
@@ -455,6 +807,55 @@ things hold for all of them:
One power operation per command line; two is a mistake, not a sequence, and gvm
says so instead of guessing.
### What a machine has
`c` and `m` in the menu, `gvm size` on the command line: the vCPU count and the
memory. Nothing is lost by either, so they get the plain y/n question rather than
the page that wants YES typed out — a number set wrongly is set back.
vSphere refuses most of what one might ask for on a running machine, and gvm
refuses it first, with the reason, rather than sending it to be bounced:
* **A running machine can only grow, and only where it was built to.** CPU and
memory hot-add are per-machine settings, turned on when the machine was made.
Without them the entry says "needs it off". Memory can never shrink while a
machine runs — there is no hot-remove for it in vSphere at all — and vCPUs only
where hot-remove is on as well.
* **The socket topology is the machine's own.** A vCPU count has to be a multiple
of the cores per socket, and gvm does not quietly change the sockets to make a
number fit: software is licensed per socket, and a tool that turns 2 sockets
into 4 to accept an odd number would be writing somebody an invoice. It names
the two counts that do fit instead.
* **Memory is whole multiples of 4 MB.** It is typed in gigabytes, because that
is what the sheet shows and what anybody says out loud; `512m` means megabytes,
and `1.5g` is 1536 MB.
* **Read again immediately before it is sent.** The menu's grey-out says only
that a change of this kind is possible at all; between drawing it and answering
the question somebody else may have started the machine.
* **A machine whose configuration cannot be read is left alone.** Not knowing
what it has means there is nothing honest to put on the left of the arrow, so
both entries grey out and say so rather than offering a change from a figure
gvm does not have.
* **`gvm size -c 8 -m 1026m` does neither.** Both numbers are read and checked
before either is sent: a command line that sets the vCPUs and then refuses the
memory has half happened, which is the one outcome nobody asked for.
`gvm size --vm <machine>` on its own changes nothing and says what there is:
machine web01 (running)
vCPU 4, in 1 per socket
memory 8.0GB
while it runs add vCPUs, add memory
That last line is the one worth having before planning the work — it is the
difference between a change now and a maintenance window. Where a machine has
none of the three settings it says so plainly: "nothing — it has to be powered
off to be changed".
Hot-added resources are not necessarily in use the moment vCenter reports the
task done: the message says as much, because an operating system does not always
notice on its own that it has been given another four processors.
The printed listing (`vm -l`) is the same table: the same columns, the same
cells, the same colours, fitted to the terminal when there is one and written out
in full into a pipe, where the colours are left off.
@@ -716,9 +1117,60 @@ wrong quietly:
* that `y` names what it copied and that an ssh login uses no local clipboard
tool — the tests say they are a login, which also keeps them off the clipboard
of whoever is running them
* that a step which does not happen says why — a vCenter with no customisation
specifications, and one that will not let them be read, both used to skip the
question in silence — and that the reason and the placement both fit on the
line they share
* that a name already in the appliance is refused before anything is added to
it, that an appliance which cannot be reached is not mistaken for a free
name, and that a helper older than 2.5.0 is refused by name and version
rather than talked to — against a stand-in, since a suite that called the
real one would be editing the institute's network every time it ran
* that the version is read off the tool's own, and not off the toolbox version
in the same line — which is the wrong answer that check exists to prevent
* that an address fetched from the site's tool is read back from the record
rather than from its printed sentence, that the tool's own words come back
when it refuses, that a fetched address is given back when the deployment
does not happen, and that nothing is ever asked of it for an address that was
typed — against a stand-in, because a test suite that called the real one
would be editing the institute's network every time it ran
* that the specification gvm writes itself carries the site's five settings and
this machine's two, invents nothing where an address was not given, and is
refused for a Windows template and for a configuration that is not complete —
named field by field
* that a customisation writes the hostname and the address into a vCenter's own
specification and changes nothing else in it, for Linux and for Windows; that
an address with no netmask behind it is refused, as are the two specification
kinds gvm will not reach into; and — against the simulator's four real
specifications — that the whole way through works
* that a deployment is refused from anything that is not a template, that a name
already in use is refused before the clone starts, and — against the simulator
— that what comes out is a machine rather than another template, in the pool it
was given, switched off
* every rule a resize is held to: what a running machine may and may not be
given, that the cores per socket are never changed to make a vCPU count fit,
that a refusal never names 0 vCPUs as a count that would, and — against the
simulator — that a reconfigure actually lands, read back from the server
rather than assumed, and that a command line asking for one possible change
and one impossible one makes neither
* that a `^C` during a login does not take gvm with it, and that the login still
dies of it: the signal is caught for as long as the child has the screen, and
catching is not ignoring — an ignored one would be inherited by the ssh
* that a refresh which half failed still leaves the rows and the view describing
the same list, and that the screen draws — it panicked before
* that every layout of the estate screen fits the terminal it was drawn for, at
nine widths from 60 columns up
* that a page step on that screen does not leap twice when it lands on a cluster
name, and that an unreachable host draws no load
* that the estate screen groups the hosts under their clusters, that a heading
is the sum of what is under it, that every placed machine is charged to
exactly one host, and that Enter comes back with the list filtered to it
* that a ratio says nothing where there is room to spare, and that an unknown
load does not draw the same bar as an idle one
* that the live refresh reads over the session it already has and not a new
login — with the session closed it fails rather than reconnecting — that it
notices a machine stopped behind gvm's back and says so, and that the trend
keeps its scale, is bounded, and is forgotten when a machine goes
* that the column ladder still only ever *drops* columns with the task column in
the table, and that a terminal of eighty still keeps the address
* that the completion cache survives a sweep of one server, forgets a machine
+414 -37
View File
@@ -128,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 {
@@ -147,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"
@@ -184,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),
@@ -193,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) {
@@ -248,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 (
@@ -259,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 }
@@ -285,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)
}
}
@@ -422,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.there() {
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.
@@ -603,7 +985,7 @@ func (b *browser) renderMenu() {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim,
"lowercase asks the guest, uppercase the hypervisor"})
"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())
@@ -613,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
@@ -625,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")
+50 -9
View File
@@ -137,7 +137,7 @@ func TestMenuAvailability(t *testing.T) {
}
someSnaps := []snapEntry{{name: "s1", created: "01.01.2026 00:00"}}
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps)
running := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), someSnaps, testSizing())
for _, k := range []rune{'n', 'r', 'd', 'D', 's', 'b', 'S', 'B'} {
if !find(running, k).available() {
t.Errorf("%q not offered for a running machine with Tools: %s", string(k), find(running, k).why)
@@ -147,7 +147,7 @@ func TestMenuAvailability(t *testing.T) {
t.Error("power on is offered for a machine that is already running")
}
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps)
noTools := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsDown), someSnaps, testSizing())
for _, k := range []rune{'s', 'b'} {
if find(noTools, k).available() {
t.Errorf("%q offered without VMware Tools", string(k))
@@ -159,7 +159,7 @@ func TestMenuAvailability(t *testing.T) {
}
}
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps)
stopped := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown), someSnaps, testSizing())
if !find(stopped, 'o').available() {
t.Error("power on is not offered for a stopped machine")
}
@@ -170,7 +170,7 @@ func TestMenuAvailability(t *testing.T) {
}
// Without snapshots there is nothing to revert to or remove.
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil)
bare := b.buildMenu(stateRow(types.VirtualMachinePowerStatePoweredOn, toolsUp), nil, testSizing())
for _, k := range []rune{'r', 'd', 'D'} {
if find(bare, k).available() {
t.Errorf("%q offered for a machine with no snapshots", string(k))
@@ -395,7 +395,7 @@ func TestMenuHintAndReasonAgree(t *testing.T) {
stateRow(types.VirtualMachinePowerStatePoweredOff, toolsDown),
stateRow(types.VirtualMachinePowerStateSuspended, toolsUp),
} {
for _, m := range b.buildMenu(r, nil) {
for _, m := range b.buildMenu(r, nil, testSizing()) {
if m.isSeparator() {
continue
}
@@ -606,15 +606,15 @@ func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
wantFacts bool
}{
{"30", true}, // room for both, all four facts
{"19", true}, // room for three of them, the hostname among them
{"18", false}, // room for two: the state and the address
{"13", false}, // no room at all, so the choices have the screen
{"22", true}, // room for three of them, the hostname among them
{"21", false}, // room for two: the state and the address
{"16", false}, // no room at all, so the choices have the screen
} {
t.Setenv("LINES", c.rows)
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
b.menu = b.buildMenu(r, nil)
b.menu = b.buildMenu(r, nil, testSizing())
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
frame := renderToPipe(t, b, b.renderMenu)
@@ -761,3 +761,44 @@ func TestEveryTaskWaitIsBounded(t *testing.T) {
t.Error("no task wait found at all — this check has stopped checking anything")
}
}
// A menu is allowed to be built from less than it wanted, but never to look as
// certain as one that was. openMenu clears the status line as its last act —
// the menu is a fresh screen — and a warning set on the way there was being
// wiped before a single frame was drawn.
func TestTheMenuKeepsWhatItHadToWarnAbout(t *testing.T) {
// A row with no session: the configuration cannot be read, which is the
// warning this is about.
r := testRow("web01", true, "10.0.0.5")
b := &browser{rows: []vmRow{r}, view: []int{0}}
// openMenu leaves early without a session at all, so the warning is checked
// where it is raised: a sizing that could not be read must both grey the
// entries out and leave something on the status line.
if _, err := sizingOf(nil, r.ref); err == nil {
t.Error("a machine with no connection read its configuration anyway")
}
menu := b.buildMenu(r, nil, sizing{})
for _, k := range []rune{'c', 'm'} {
for _, m := range menu {
if m.key != k {
continue
}
if m.available() {
t.Errorf("%q is offered on a machine whose configuration is unknown", string(k))
}
if m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
}
}
// testSizing is a plausible machine's hardware for the menu tests: four vCPUs
// in one socket, eight gigabytes, and none of the three hot-plug settings — the
// way most machines are actually built, and the case where the two hardware
// entries are only offered on a machine that is switched off.
func testSizing() sizing {
return sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
}
+137 -29
View File
@@ -82,7 +82,7 @@ var (
)
const (
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/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
@@ -105,6 +105,12 @@ type vmRow struct {
// slow in proportion to how many machines are on the screen.
snaps []snapEntry // its snapshots, as the sweep found them
task *runningTask // what vCenter is doing to it right now, if anything
// trend is the machine's recent CPU load drawn as one column of text. It is
// not read from anywhere: it is written here by live.go out of samples the
// browser keeps across sweeps, because a row is thrown away and rebuilt by
// every one of them and a history cannot live on something that short-lived.
trend string
}
// id is what makes this machine this machine. A name does not: two vCenters may
@@ -115,6 +121,12 @@ func (r vmRow) id() string { return r.vc.Name + "/" + r.ref.Value }
func (r vmRow) power() types.VirtualMachinePowerState { return r.vm.Summary.Runtime.PowerState }
// isTemplate reports whether this is a template rather than a machine. vSphere
// takes a template's resource pool away and refuses to start it, snapshot it or
// reconfigure it: the only thing to be done with one is to make a machine from
// it (deploy.go).
func (r vmRow) isTemplate() bool { return r.vm.Summary.Config.Template }
func (r vmRow) powerShort() string {
switch r.power() {
case types.VirtualMachinePowerStatePoweredOn:
@@ -321,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
@@ -338,7 +355,7 @@ var browseColumns = []browseColumn{
// its place on a terminal of eighty.
{header: "NAME", width: 22, flex: true, color: fixed(colName),
cell: func(r vmRow) string { return r.name }},
{header: "VC", width: 4, color: fixed(colWhere), expendable: 10,
{header: "VC", width: 4, color: fixed(colWhere), expendable: 11,
cell: func(r vmRow) string { return r.vc.Name }},
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
// How many rollback points the machine is dragging along, aged by colour.
@@ -349,20 +366,20 @@ var browseColumns = []browseColumn{
// `vm -l --issues` and in `snap --old`, all of which name it and date it; an
// address has no other home. So a terminal of eighty gives up the count and
// keeps the address, and the count is back from eighty-five.
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 3},
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 4},
// The address is the widest thing here that is not a name, and it is a
// lookup field: on a terminal this narrow nobody is looking an address up,
// they are glancing at what is busy. So it goes before the small figures do.
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 4},
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 5},
{header: "HOST", width: 10, color: fixed(colAside), expendable: 3,
cell: func(r vmRow) string { return r.host }},
{header: "CPU", width: 3, color: fixed(colSize), expendable: 5,
{header: "CPU", width: 3, color: fixed(colSize), expendable: 6,
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
{header: "CPU%", width: 4, expendable: 9,
{header: "CPU%", width: 4, expendable: 10,
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 6},
{header: "MEM%", width: 4, expendable: 8,
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 7},
{header: "MEM%", width: 4, expendable: 9,
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
@@ -382,7 +399,7 @@ var browseColumns = []browseColumn{
// shifting about: it is the news. What must not happen — a column coming back
// as the terminal is dragged *narrower* — is fitColumns' business, and this
// does not touch it.
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 8,
cell: vmRow.taskCell, color: fixed(colBusy)}
// The reason outranks every other column but the machine's name and its power
@@ -392,7 +409,7 @@ var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
// still expendable rather than fixed, so that a terminal too narrow for it
// falls down the same ladder as everything else instead of into the one-column
// fallback.
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 11,
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 12,
cell: vmRow.issueCell, color: vmRow.issueColor}
// The figures the issues listing leaves out. A machine is in that list because
@@ -417,7 +434,9 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
}
}
out := make([]browseColumn, 0, len(browseColumns)+1)
trend := anyTrend(rows)
out := make([]browseColumn, 0, len(browseColumns)+2)
for _, c := range browseColumns {
if why {
if c.header == "GUEST OS" {
@@ -432,6 +451,12 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
if busy && c.header == "PWR" {
out = append(out, taskColumn)
}
// Next to the figure it is the history of, which is where the eye
// already is when it wonders whether 80 per cent is where the machine
// has been sitting all morning or where it went a moment ago.
if trend && c.header == "CPU%" {
out = append(out, trendColumn)
}
}
return out
}
@@ -638,14 +663,21 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
if err != nil {
return nil, nil, err
}
rows, err := sweepOne(vc, s)
return rows, s, err
}
// sweepOne is that pass over one server, over a connection that is already
// open. It is split out for live mode (live.go), which re-reads the same
// properties every few seconds and must not log in again to do it.
func sweepOne(vc VCenter, s *session) ([]vmRow, error) {
vms, err := s.vms(sweepProps...)
if err != nil {
return nil, s, err
return nil, err
}
hosts, err := s.hosts("name")
if err != nil {
return nil, s, err
return nil, err
}
hostName := make(map[types.ManagedObjectReference]string, len(hosts))
for _, h := range hosts {
@@ -673,7 +705,7 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
}
rows = append(rows, r)
}
return rows, s, nil
return rows, nil
}
// triggeredAlarms is every alarm definition this sweep saw complaining, once
@@ -727,7 +759,18 @@ type browser struct {
events []eventLine
eventsOf string // the id of the machine they are of
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
site site // what a machine made from a template is told about the network
dns dnsHelper // the site's address helper, where this machine has one
// 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
@@ -742,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
@@ -759,10 +804,11 @@ type browser struct {
// browseVMs is the command: gather, then hand the terminal over to the loop.
//
// ssh is the command line the sheet's `h` runs, out of the configuration:
// the browser is handed it rather than reading it, so nothing in the interactive
// half has to know where settings come from.
func browseVMs(targets []VCenter, filter, ssh string) error {
// 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
@@ -771,7 +817,8 @@ func browseVMs(targets []VCenter, filter, ssh string) error {
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
}
b := &browser{targets: targets, filter: filter, ssh: ssh}
b := &browser{targets: targets, filter: filter, ssh: cfg.SSH,
site: cfg.site(), dns: dnsTool(cfg.DNSTool)}
defer func() { closeSessions(b.sessions) }()
PF("asking %s ...\n", vcNames(targets))
@@ -840,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
}
@@ -852,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:
@@ -873,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:
@@ -922,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])
@@ -1060,11 +1127,25 @@ func (b *browser) toggleIssues() {
// the list cannot make a column come and go under the cursor.
func (b *browser) columns() []browseColumn { return listColumns(b.rows, b.issuesOnly) }
// current is the machine under the cursor, or nil where there is none.
//
// Both steps are bounds-checked, and the second is not paranoia: the view holds
// indexes into the rows, and there is a moment in every refresh — the rows
// replaced, the view not yet rebuilt — when an index of the old list points
// past the end of the new one. refilter itself asks this question in that
// moment, to remember which machine the cursor was on. Checking only the view's
// own length left a panic there that needed nothing but a machine disappearing
// from the inventory while the cursor was near the end of the list: rare with
// ^r, and every ten seconds with live mode.
func (b *browser) current() *vmRow {
if b.sel < 0 || b.sel >= len(b.view) {
return nil
}
return &b.rows[b.view[b.sel]]
i := b.view[b.sel]
if i < 0 || i >= len(b.rows) {
return nil
}
return &b.rows[i]
}
func (b *browser) move(n int) {
@@ -1090,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 {
@@ -1250,17 +1332,24 @@ type prompt struct {
text string
hint string
col string
// more is a second line, drawn where the help line goes. Only the sort
// legend uses it: a menu of thirteen choices does not fit across eighty
// columns, and the help line underneath it is describing keys that do
// nothing while a menu is waiting for one.
// more is a second line, drawn where the help line goes, for what will not
// fit in the question: the sort legend, which is thirteen choices and does
// not cross eighty columns, and the placement of a machine about to be
// deployed. The help line it covers is describing keys that do nothing
// while a question is waiting for one.
more string
}
// ask puts one question on the status line and waits for a single key. Only "y"
// means yes — every other key, Esc and Ctrl-C included, means no.
func (b *browser) ask(question string) bool {
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colPrompt}
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
@@ -1331,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 == "" {
@@ -1596,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
+65 -1
View File
@@ -316,6 +316,70 @@ func TestSheetLettersReachTheirActions(t *testing.T) {
}
}
// 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))
@@ -1463,7 +1527,7 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
}
}
b.menu = b.buildMenu(*b.current(), b.current().snaps)
b.menu = b.buildMenu(*b.current(), b.current().snaps, testSizing())
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
for _, want := range []string{"take a snapshot", "power off", "reset"} {
+1 -1
View File
@@ -244,7 +244,7 @@ func inventoryAge() string {
var (
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
"--off", "--reset"}
"--off", "--reset", "--vm", "--from"}
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
)
+101
View File
@@ -31,6 +31,82 @@ type Config struct {
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
@@ -412,6 +488,12 @@ func applyConfig(c *Config, m map[string]string) {
set("smtpport", &c.SMTPPort)
set("telemetry", &c.Telemetry)
set("ssh", &c.SSH)
set("domain", &c.Domain)
set("dns", &c.DNS)
set("dnstool", &c.DNSTool)
set("netmask", &c.Netmask)
set("gateway", &c.Gateway)
set("timezone", &c.Timezone)
applyVCenters(c, m)
}
@@ -478,6 +560,12 @@ func applyEnv(c *Config) {
env("GVM_SMTPPORT", &c.SMTPPort)
env("GVM_TELEMETRY", &c.Telemetry)
env("GVM_SSH", &c.SSH)
env("GVM_DOMAIN", &c.Domain)
env("GVM_DNS", &c.DNS)
env("GVM_DNSTOOL", &c.DNSTool)
env("GVM_NETMASK", &c.Netmask)
env("GVM_GATEWAY", &c.Gateway)
env("GVM_TIMEZONE", &c.Timezone)
applyVCenterEnv(c)
}
@@ -587,6 +675,19 @@ func writeConfigTemplate(path string) {
b.WriteString("# smtpport = 25\n\n")
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
b.WriteString("# --- 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 uses it from v2.5.0 up — older ones are said to be too old\n")
b.WriteString("# rather than read.\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")
+782
View File
@@ -0,0 +1,782 @@
// 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 dnsHelper // the site's address helper, where there is one
}
// 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.there() {
// Why, rather than that: a tool that is too old and no tool at all are
// two different things to go and do something about.
return opts, dnsHost{}, nothing, errf("%s — or give --ip an address", opts.dns.why())
}
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.path)
}
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")
}
}
+347
View File
@@ -0,0 +1,347 @@
// 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"
"regexp"
"strconv"
"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"
// dnsHelper is the tool, where there is one gvm can use.
//
// Only v2.5.0 and up: from there it answers in JSON when asked with -j, with
// the reason for a refusal in a field of its own and an exit status that agrees
// with it. What came before said the same things in a sentence and exited 0
// while refusing, which is a shape worth reading only as long as somebody is
// still running one — and nobody here is.
//
// A tool that is too old is not the same as no tool at all, and why() keeps the
// difference: "there is none" and "the one you have is too old to ask this of"
// send somebody to two different places.
type dnsHelper struct {
path string // "" where none was found at all
version [3]int // what it says it is
usable bool // found, and new enough
}
func (h dnsHelper) there() bool { return h.usable }
// dnsJSONFrom is the first version that answers in JSON, and so the first that
// gvm will talk to.
var dnsJSONFrom = [3]int{2, 5, 0}
func versionString(v [3]int) string { return SF("%d.%d.%d", v[0], v[1], v[2]) }
// why says what is in the way, for the refusal and for `gvm config`. Empty
// where nothing is.
func (h dnsHelper) why() string {
switch {
case h.usable:
return ""
case h.path == "":
return SF("there is no address tool on this machine — put one on the path as 'dns', "+
"or name it as dnstool in %s", configFile())
case h.version == [3]int{}:
return SF("%s does not say which version it is, and gvm wants %s or newer",
h.path, versionString(dnsJSONFrom))
}
return SF("%s is %s, and gvm wants %s or newer — 'dns --update' fetches it",
h.path, versionString(h.version), versionString(dnsJSONFrom))
}
// dnsTool is the helper to use, or one that says why it cannot be used.
//
// 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. Either way it is then asked how old it is —
// once, here, because a probe per call would be three of them for one
// deployment.
func dnsTool(configured string) dnsHelper {
path := ""
if c := strings.TrimSpace(configured); c != "" {
// Named but not there: nothing is offered, rather than quietly using a
// different tool of the same name from somewhere on the path.
if p, err := exec.LookPath(c); err == nil {
path = p
}
} else if p, err := exec.LookPath("dns"); err == nil {
path = p
}
if path == "" {
return dnsHelper{}
}
h := dnsHelper{path: path}
if v, ok := dnsAskVersion(path); ok {
h.version = v
h.usable = !olderThan(v, dnsJSONFrom)
}
return h
}
// dnsAskVersion asks the helper how old it is. A version that cannot be read is
// not a version: gvm would rather say so than talk to something whose answers
// it cannot predict.
func dnsAskVersion(path string) ([3]int, bool) {
ctx, cancel := context.WithTimeout(context.Background(), dnsWait)
defer cancel()
out, err := exec.CommandContext(ctx, path, "-v").CombinedOutput()
if err != nil {
return [3]int{}, false
}
return dnsVersion(string(out))
}
// dnsVersion picks the version out of what -v says, which is a sentence about
// itself:
//
// dns - infoblox helper (v2.5.0 (1282), toolbox v0.5.0, mwx'2026)
//
// Anchored on the bracket, because there are two versions in that line and only
// the first is the tool's own. Taking whichever came first instead read the
// toolbox's 0.5.0 the moment the tool's own could not be read — which is the
// quiet wrong answer this whole check exists to avoid, and it turned up in a
// test rather than in front of somebody.
var dnsVersionRe = regexp.MustCompile(`\(v(\d+)\.(\d+)\.(\d+)`)
func dnsVersion(said string) ([3]int, bool) {
m := dnsVersionRe.FindStringSubmatch(said)
if m == nil {
return [3]int{}, false
}
var v [3]int
for i := range v {
n, err := strconv.Atoi(m[i+1])
if err != nil {
return [3]int{}, false
}
v[i] = n
}
return v, true
}
func olderThan(v, than [3]int) bool {
for i := range v {
if v[i] != than[i] {
return v[i] < than[i]
}
}
return false
}
// 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
}
// dnsAnswer is one reply: whether the helper did the thing, what it said if it
// did not, and the record where there is one.
//
// It is the envelope v2.5.0 and up put every reply in, kept as gvm's own type
// so that the three calls above it read the same four fields rather than each
// one unwrapping a reply for itself:
//
// {"ok":true, "action":"showhost","name":"v308.fhi.mpg.de","record":{...}}
// {"ok":false,"action":"showhost","error":"host '...' not found"}
type dnsAnswer struct {
ok bool
err string // the helper's own words, where it refused
record json.RawMessage // the host record, where the call has one
}
// notFound reports whether the refusal is the ordinary one: no such name. That
// is an answer to "is this name free", not a failure to report.
func (a dnsAnswer) notFound() bool {
return !a.ok && strings.Contains(strings.ToLower(a.err), "not found")
}
// dnsAsk is one call to the helper. -j because that is the only way gvm talks
// to it, and -y throughout: it asks before it changes anything when it has a
// terminal, and gvm has already asked.
func dnsAsk(h dnsHelper, args ...string) (dnsAnswer, error) {
ctx, cancel := context.WithTimeout(context.Background(), dnsWait)
defer cancel()
argv := append(append([]string{}, args...), "-j", "-y")
out, err := exec.CommandContext(ctx, h.path, argv...).CombinedOutput()
text := strings.TrimSpace(string(out))
var env struct {
OK bool `json:"ok"`
Error string `json:"error"`
Record json.RawMessage `json:"record"`
}
if jerr := json.Unmarshal([]byte(text), &env); jerr != nil {
if err != nil && text == "" {
return dnsAnswer{}, errf("%s: %w", h.path, err)
}
return dnsAnswer{}, errf("%s answered something that is not a reply: %s", h.path, firstLine(text))
}
a := dnsAnswer{ok: env.OK, err: env.Error, record: env.Record}
if !a.ok && a.err == "" {
a.err = "it refused, without saying why"
}
return a, nil
}
// 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(h dnsHelper, host string) (dnsHost, error) {
a, err := dnsAsk(h, "-s", dnsName(host))
if err != nil {
return dnsHost{}, err
}
switch {
case a.notFound():
return dnsHost{}, nil
case !a.ok:
return dnsHost{}, errf("%s", a.err)
case len(a.record) == 0:
return dnsHost{}, errf("%s said nothing about %s", h.path, dnsName(host))
}
var rec dnsHost
if err := json.Unmarshal(a.record, &rec); err != nil {
return dnsHost{}, errf("%s answered something that is not a host record: %s",
h.path, firstLine(string(a.record)))
}
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(h dnsHelper, 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(h, 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)
}
a, err := dnsAsk(h, "-a", name)
if err != nil {
return dnsHost{}, err
}
if !a.ok {
return dnsHost{}, errf("%s", a.err)
}
// Read back rather than taken from what the reply carried. Both dialects
// say something about what they just made, and neither has to be trusted
// for it: the record is the appliance's own answer to the same question,
// asked after the fact, and it is what the machine will actually have.
rec, err := dnsShow(h, name)
if err != nil {
return dnsHost{}, err
}
if rec.address() == "" {
return dnsHost{}, errf("%s made %s, but the record has no address in it", h.path, name)
}
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(h dnsHelper, host string) error {
a, err := dnsAsk(h, "-d", dnsName(host))
if err != nil {
return err
}
if !a.ok {
return errf("%s", a.err)
}
return nil
}
// 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
}
+357
View File
@@ -0,0 +1,357 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Nothing in this file may touch the real helper. `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 what is 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.
//
// The stand-in answers the way v2.5.0 does, measured rather than assumed: an
// envelope with ok, the reason in an error field of its own, and exit 1 where
// it refused.
//
// {"ok":true, "action":"showhost","record":{...}}
// {"ok":false,"action":"showhost","error":"host ... not found"}
func fakeDNS(t *testing.T, version, body string) (tool, log string) {
t.Helper()
dir := t.TempDir()
tool = filepath.Join(dir, "dns")
log = filepath.Join(dir, "asked")
script := `#!/bin/sh
echo "$@" >> ` + log + `
if [ "$1" = "-v" ]; then
echo "dns - infoblox helper (v` + version + ` (1282), toolbox v0.5.0, mwx'2026)"
exit 0
fi
` + body + `
exit 0
`
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) (dnsHelper, string) {
t.Helper()
state := filepath.Join(t.TempDir(), "records")
tool, log := fakeDNS(t, "2.5.0", `
state=`+state+`
rec() { printf '{"name":"%s.fhi.mpg.de","ipv4addrs":[{"ipv4addr":"141.14.140.182"}]}' "$1"; }
ok() { printf '{"ok":true,"action":"%s","record":%s}\n' "$1" "$(rec $2)"; exit 0; }
no() { printf '{"ok":false,"action":"%s","error":"%s"}\n' "$1" "$2"; exit 1; }
done_() { printf '{"ok":true,"action":"%s"}\n' "$1"; exit 0; }
case "$1" in
-s) if grep -qx "$2" $state 2>/dev/null; then ok showhost "$2"
else no showhost "host $2.fhi.mpg.de not found"; fi ;;
-a) if grep -qx "$2" $state 2>/dev/null; then no addhost "host $2.fhi.mpg.de already exists"
else echo "$2" >> $state; done_ addhost; fi ;;
-d) grep -vx "$2" $state > $state.tmp 2>/dev/null
mv $state.tmp $state 2>/dev/null
done_ delhost ;;
esac`)
return dnsTool(tool), log
}
// asked is what the helper was called with, one call per line, without the
// version probe — that one is gvm working out which dialect it is talking to,
// not part of what any of these tests is about.
func asked(t *testing.T, log string) []string {
t.Helper()
out, err := os.ReadFile(log)
if err != nil {
return nil
}
var calls []string
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if l != "" && !strings.HasPrefix(l, "-v") {
calls = append(calls, l)
}
}
return calls
}
// Which versions gvm will talk to. Below 2.5.0 the helper answers in sentences
// and exits 0 while refusing, and gvm no longer reads that — so it says what is
// in the way rather than talking to it anyway.
func TestOnlyANewEnoughHelperIsUsed(t *testing.T) {
for _, c := range []struct {
version string
usable bool
}{
{"2.5.0", true},
{"2.5.1", true},
{"2.6.0", true},
{"3.0.0", true},
{"2.4.4", false},
{"1.9.9", false},
} {
tool, _ := fakeDNS(t, c.version, `echo '{"ok":true,"action":"noop"}'`)
h := dnsTool(tool)
if h.there() != c.usable {
t.Errorf("v%s: usable = %v, want %v", c.version, h.there(), c.usable)
}
if c.usable {
continue
}
// And says which of the two things is wrong: a tool that is too old is
// not the same as no tool, and they send somebody to different places.
why := h.why()
if !strings.Contains(why, c.version) || !strings.Contains(why, "2.5.0") {
t.Errorf("v%s: it says %q, which does not name both versions", c.version, why)
}
if !strings.Contains(why, "dns --update") {
t.Errorf("v%s: it does not say what to do about it: %q", c.version, why)
}
}
// There are two versions in that line and only the first is the tool's own:
// a helper whose own version cannot be read must not be taken for its
// toolbox's.
if v, ok := dnsVersion("dns - infoblox helper (v2.5.0 (1282), toolbox v0.5.0, mwx'2026)"); !ok ||
v != [3]int{2, 5, 0} {
t.Errorf("the tool's own version read as %v (%v)", v, ok)
}
if v, ok := dnsVersion("dns - infoblox helper (vnonsense (1282), toolbox v0.5.0, mwx'2026)"); ok {
t.Errorf("the toolbox's version was taken for the tool's: %v", v)
}
// A helper that will not say which version it is, and one that is not there
// at all: two more reasons, each said as itself.
tool, _ := fakeDNS(t, "no-version-here", `echo hello`)
h := dnsTool(tool)
if h.there() {
t.Error("a helper that does not say its version was used anyway")
}
if !strings.Contains(h.why(), "does not say which version") {
t.Errorf("it says %q", h.why())
}
if none := (dnsHelper{}); !strings.Contains(none.why(), "no address tool") {
t.Errorf("no tool at all says %q", none.why())
}
}
// The record is read back rather than taken from whatever the reply carried:
// the helper says something about what it just made, and it does not have to be
// trusted for it.
func TestAnAddressIsReadBackFromTheRecord(t *testing.T) {
h, log := fakeInfoblox(t)
rec, err := dnsAdd(h, "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 -j and -y: the one is the only dialect gvm reads, the other
// 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.Fatalf("it was asked %v", calls)
}
for _, c := range calls {
if !strings.Contains(c, "-y") || !strings.Contains(c, "-j") {
t.Errorf("a call went out without -j and -y: %q", c)
}
}
}
// A name already in the appliance is refused before anything is added to it,
// with what is in the way and how to clear it.
func TestANameThatIsTakenIsRefused(t *testing.T) {
h, log := fakeInfoblox(t)
if _, err := dnsAdd(h, "web05"); err != nil {
t.Fatalf("the first one went wrong: %v", err)
}
before := len(asked(t, log))
_, err := dnsAdd(h, "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)
}
}
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:])
}
}
// The helper's own words come back when it refuses for any other reason.
func TestTheHelpersOwnWordsComeBack(t *testing.T) {
tool, _ := fakeDNS(t, "2.5.0",
`echo '{"ok":false,"action":"addhost","error":"no free address in the network"}'; exit 1`)
_, err := dnsAdd(dnsTool(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)
}
}
// An appliance that cannot be reached is 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.
func TestAnApplianceThatIsDownIsNotAFreeName(t *testing.T) {
tool, _ := fakeDNS(t, "2.5.0",
`echo '{"ok":false,"action":"showhost","error":"cannot reach infoblox"}'; exit 1`)
h := dnsTool(tool)
if _, err := dnsShow(h, "web05"); err == nil {
t.Error("an appliance that could not be reached reported a free name")
}
if _, err := dnsAdd(h, "web05"); err == nil {
t.Error("an address was asked for over an appliance that is down")
}
}
// A helper that agrees but leaves no record is not an answer either: the
// deployment would be given an empty address and the machine would come up on
// nothing.
func TestAnAddressThatCannotBeReadBackIsNoAddress(t *testing.T) {
tool, _ := fakeDNS(t, "2.5.0", `case "$1" in
-s) echo '{"ok":true,"action":"showhost","record":{"name":"web05.fhi.mpg.de","ipv4addrs":[]}}' ;;
-a) echo '{"ok":true,"action":"addhost"}' ;;
esac`)
if _, err := dnsAdd(dnsTool(tool), "web05"); err == nil {
t.Fatal("an empty record was taken for an address")
}
// And something that is not a reply at all.
tool, _ = fakeDNS(t, "2.5.0", `echo 'not json at all'`)
if _, err := dnsShow(dnsTool(tool), "web05"); err == nil {
t.Fatal("a page of prose was taken for a reply")
}
}
// 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
// abandoned at the confirmation must not leave a record behind for a machine
// that was never made — and the name is free again afterwards.
func TestAFetchedAddressIsGivenBack(t *testing.T) {
h, log := fakeInfoblox(t)
opts := deployOpts{how: customSite, st: testSite(), ip: autoIP, dns: h}
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()
if calls := asked(t, log); len(calls) == 0 || !strings.HasPrefix(calls[len(calls)-1], "-d web05") {
t.Errorf("the address was not given back: %v", calls)
}
if rec, err := dnsShow(h, "web05"); err != nil || rec.Name != "" {
t.Errorf("the name is still taken after being released: %+v (%v)", rec, err)
}
}
// All of it happens only where the site has such a tool. Without one, "auto" is
// refused with what to do about it, and 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.
h, log := fakeInfoblox(t)
opts, rec, give, err := fetchAddress(deployOpts{ip: "10.0.0.55", dns: h}, "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: h}, "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 is used: the one named in the configuration, or "dns" on the
// path, or none — and one that is named but not there offers nothing rather
// than quietly using a different tool of the same name from somewhere else.
func TestWhichHelperIsUsed(t *testing.T) {
tool, _ := fakeDNS(t, "2.5.0", `echo hello`)
if got := dnsTool(tool); got.path != tool || !got.there() {
t.Errorf("a named helper resolved to %+v", got)
}
if got := dnsTool(filepath.Join(t.TempDir(), "not-there")); got.there() {
t.Errorf("a named helper that is not there resolved to %+v", got)
}
t.Setenv("PATH", filepath.Dir(tool))
if got := dnsTool(""); got.path != tool {
t.Errorf("the helper on the path resolved to %+v", got)
}
t.Setenv("PATH", t.TempDir())
if got := dnsTool(""); got.there() {
t.Errorf("a path with no helper on it resolved to %+v", 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)
}
}
}
+1 -1
View File
@@ -282,7 +282,7 @@ func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
func TestMenuLettersAreDistinct(t *testing.T) {
b := &browser{}
seen := map[rune]string{}
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil) {
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
if m.isSeparator() {
continue
}
+88 -3
View File
@@ -47,7 +47,7 @@ var helpTail = strings.Join([]string{
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.1.0"
var version = "1.3.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -131,6 +131,39 @@ func run() error {
subPower.String(&pwOff, "", "off", "Power off <vm> at the hypervisor — hard, like pulling the plug")
subPower.String(&pwReset, "", "reset", "Reset <vm> at the hypervisor — hard, like the reset button")
// The machine is a flag of its own here, not the value of the action flag as
// it is for power and snap: this command takes a machine *and* a number, and
// only one of the two can be the value of "--cpus".
// szCPUs and szMemory, not sizeCPUs and sizeMemory: those two are the
// sizeKind constants, and locals of the same name would shadow them for the
// rest of this function — where the next person to write parseSize(sizeCPUs,
// ...) would get a type error with no obvious cause.
var szVM, szCPUs, szMemory string
subSize := flaggy.NewSubcommand("size")
subSize.Description = "Show or change a machine's vCPUs and memory"
subSize.String(&szVM, "", "vm", "The machine to show or change")
subSize.String(&szCPUs, "c", "cpus", "Set its vCPU count")
subSize.String(&szMemory, "m", "memory", "Set its memory, in GB (or 512m for MB)")
// Two things again, so both are flags of their own: what to copy and what to
// call the copy. --host and --datastore are the two parts of the placement
// that are not inherited from the template.
var newFrom, newName, newHost, newStore string
var newSpec, newIP, newHostname string
var newSpecs bool
var newOn bool
subNew := flaggy.NewSubcommand("new")
subNew.Description = "Make a new machine from a template"
subNew.String(&newFrom, "", "from", "The template to copy")
subNew.String(&newName, "", "name", "What to call the new machine")
subNew.String(&newHost, "", "host", "Put it on this host, rather than letting its cluster decide")
subNew.String(&newStore, "", "datastore", "Put it on this datastore, rather than the template's own")
subNew.Bool(&newOn, "", "on", "Power it on once it is made")
subNew.String(&newSpec, "", "spec", "Customise the guest with this specification from the vCenter")
subNew.String(&newIP, "", "ip", "Give it this address — 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"
@@ -158,6 +191,8 @@ func run() error {
flaggy.AttachSubcommand(subVM, 1)
flaggy.AttachSubcommand(subSnap, 1)
flaggy.AttachSubcommand(subPower, 1)
flaggy.AttachSubcommand(subSize, 1)
flaggy.AttachSubcommand(subNew, 1)
flaggy.AttachSubcommand(subHost, 1)
flaggy.AttachSubcommand(subDS, 1)
flaggy.AttachSubcommand(subLog, 1)
@@ -196,7 +231,7 @@ func run() error {
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
reverse: vmReverse, issues: vmIssues, json: vmJSON})
}
return browseVMs(targets, vmMatch, cfg.SSH)
return browseVMs(targets, vmMatch, cfg)
case subSnap.Used:
if snapOld {
@@ -265,6 +300,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 {
@@ -321,7 +393,7 @@ func run() error {
if err != nil {
return err
}
return browseVMs(targets, "", cfg.SSH)
return browseVMs(targets, "", cfg)
}
// showConfig prints what gvm made of ~/.gvmrc and the environment. Passwords
@@ -353,6 +425,19 @@ func showConfig(cfg Config) error {
PF("mail %s -> %s via %s:%d\n", orNone(cfg.MailFrom), orNone(cfg.MailTo), orNone(cfg.SMTPHost), cfg.smtpPort())
PF("telemetry %s\n", orNone(cfg.Telemetry))
PF("ssh %s\n", orNone(strings.Join(sshCommand(cfg.SSH, "<machine>"), " ")))
PF("new guest %s\n", cfg.site().describe())
// 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.
// The helper, or what is in the way of using it. "There is none" and "the
// one you have is too old" send somebody to two different places, and this
// is where the difference is worth the line.
tool := dnsTool(cfg.DNSTool)
said := tool.path + SF(" (v%s)", versionString(tool.version))
if !tool.there() {
said = tool.why()
}
PF("dns tool %s\n", said)
PF("completion %s\n", inventoryAge())
PF("version %s\n", version)
return nil
+31 -1
View File
@@ -69,6 +69,35 @@ smtpport = 25
# -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, and
# only from v2.5.0 — older ones answer in sentences rather than JSON and are
# said to be too old rather than read. 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;
@@ -84,7 +113,8 @@ telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# Every setting above has an environment spelling that wins over the file:
#
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
# GVM_TELEMETRY, GVM_SSH
# GVM_TELEMETRY, GVM_SSH, GVM_DOMAIN, GVM_DNS, GVM_NETMASK, GVM_GATEWAY,
# GVM_TIMEZONE, GVM_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,
+452
View File
@@ -0,0 +1,452 @@
// live.go — the list that keeps itself up to date.
//
// Everything else in the interactive half happens because somebody pressed a
// key. This is the part that happens because time passed: `^l` turns it on and
// the list re-reads itself every few seconds, which turns gvm from something one
// looks at into something one leaves open on a second screen.
//
// Three things come with it, and they are here rather than in browse.go because
// they only mean anything together:
//
// 1. The refresh itself, over the connections that are already open. `^r` logs
// in again — that is how a session that has died is recovered — and doing
// that every ten seconds would be three logins a minute for nothing.
// 2. What changed. A table says what is; after a sweep it can say what just
// became, which is the one thing no column can hold: "db01 off · web01 +1
// snapshot". It is the reason to leave the thing open at all.
// 3. The trend column: each machine's recent CPU load as one column of text.
// The samples cannot live on the rows, which every sweep throws away, so
// the browser keeps them and writes the drawing back onto the rows.
//
// Nothing here acts on a machine. A refresh that could start or stop something
// would be a timer with the power to do it, and the one thing a screen left
// open unattended must not have is that.
package main
import (
"strings"
"sync"
"time"
)
// How often the list re-reads itself. Faster while vCenter is doing something:
// a clone's progress that moves once every ten seconds is a figure one waits
// for, and one that moves every two is a thing one watches finish.
//
// Neither is configurable yet. The sweep is a few properties of every machine
// over a connection that is already up, which is cheap enough that ten seconds
// is not a number worth tuning per site — and a setting nobody needs is a
// setting to keep working for ever.
const (
liveEvery = 10 * time.Second
liveBusy = 2 * time.Second
)
// trendLen is how many sweeps the trend column remembers, and therefore how
// wide the column is: a history longer than the drawing would be arithmetic
// nobody sees.
//
// Six rather than eight. The difference is two characters of table, and two
// characters is what decides whether this column is on the screen at all on a
// terminal of 120 with a task column in it — which is the ordinary case it was
// built for. Six sweeps is a minute of history at the quiet interval.
const trendLen = 6
// toggleLive turns it on and off, and says which — a mode that changes what the
// screen does on its own has to announce itself, or a list that moves under
// somebody's hands looks like a fault.
//
// Turning it on refreshes at once rather than in ten seconds' time. The
// keystroke is a request for the current state, not for a subscription that
// begins later.
func (b *browser) toggleLive() {
b.live = !b.live
if !b.live {
b.setStatus(colDim, "live off — ^r to reload by hand")
return
}
b.liveNext, b.liveGap = time.Now(), b.liveInterval()
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
}
// liveReady reports whether a tick may happen now. Only the list and a
// machine's sheet are refreshed underneath somebody: the menu decides what it
// offers from the state it was drawn with, the picker holds a list of snapshots
// that is being chosen from, and both the editor and a question are half-typed
// answers. Redrawing any of those from under a hand is worse than being ten
// seconds out of date.
//
// The estate screen is left out for a different reason: it reads the hosts
// itself, which is work the machine list does not do, and a timer that did it
// every ten seconds would be paying for a screen somebody is reading rather
// than watching. ^r reads it again.
func (b *browser) liveReady() bool {
return b.live && b.menu == nil && b.pick == nil && b.confirm == nil &&
b.edit == nil && b.prompt == nil && b.estate == nil
}
// liveIn is how long until the next tick — the remaining time, not the whole
// interval, so that somebody arrowing through the list steadily cannot postpone
// the refresh for ever.
func (b *browser) liveIn() time.Duration {
d := time.Until(b.liveNext)
if d < 0 {
return 0
}
return d
}
// liveInterval is the gap after this tick: short while anything at all is being
// done on the cluster, so the task column moves while one watches it.
func (b *browser) liveInterval() time.Duration {
for _, r := range b.rows {
if r.task != nil {
return liveBusy
}
}
return liveEvery
}
// liveTick is one refresh. What it must not do is move the screen: the cursor
// stays on the machine it was on, and the viewport stays where it was — a list
// that jumps to put the selection on the last visible line every ten seconds is
// unreadable, and that is what the ordinary refilter would do, since it is
// written for a filter being typed, where going back to the top is right.
func (b *browser) liveTick() {
was := b.rows
scroll := b.scroll
// The error is information, not a reason to stop: resweep may have replaced
// the rows and still have something to report — one server of three did not
// answer — and the rows it left are a list, just a partly older one.
//
// What must not happen is returning here with the rows replaced and the
// view not rebuilt. The view holds indexes into the rows, and the drawing
// follows it without asking: leaving the two disagreeing showed every row
// as a different machine, put the cursor on one the operator was not
// looking at, and panicked outright as soon as the new list was shorter.
err := b.resweep()
b.applySort() // which refilters, so the view describes the rows again
b.scroll = min(scroll, max(len(b.view)-1, 0))
b.sample()
if b.detail != nil {
// The sheet is rebuilt from the machine as it is now, at the line it was
// being read at. It closes itself where that machine has gone.
keep := b.dscroll
b.openDetail()
b.dscroll = keep
}
// (4) The next tick is timed from here, not from before the sweep: a sweep
// that takes longer than the interval would otherwise leave no idle time at
// all, and liveIn would hand nextWithin a zero deadline that races every
// keystroke against an already expired timer.
b.liveGap = b.liveInterval()
b.liveNext = time.Now().Add(b.liveGap)
if err != nil {
b.setStatus(colWarn, "live: "+err.Error())
b.saidLive = ""
return
}
// The changed line is live mode's own, and a quiet tick clears it — but it
// clears nothing else. A message somebody produced by pressing a key is
// theirs: "copied its hostname web01.example" or the reason a snapshot was
// refused must not vanish because ten seconds passed and nothing happened
// on the cluster. Every keystroke already clears the status; a timer has no
// business doing it.
what := changesBetween(was, b.rows)
if what != "" {
b.setStatus(colInfo, what)
b.saidLive = what
return
}
if b.status == b.saidLive {
b.setStatus("", "")
}
b.saidLive = ""
}
// resweep re-reads every machine over the sessions that are already open, in
// parallel across the servers the way the first sweep is.
//
// A server that stops answering costs its own machines, not the screen: its rows
// are kept as they were and it is named in the error. Dropping them would empty
// half a list because one of three vCenters was restarting.
func (b *browser) resweep() error {
type result struct {
rows []vmRow
err error
}
res := make([]result, len(b.sessions))
var wg sync.WaitGroup
for i, s := range b.sessions {
if s == nil {
continue
}
wg.Add(1)
go func(i int, s *session) {
defer wg.Done()
res[i].rows, res[i].err = sweepOne(s.vc, s)
}(i, s)
}
wg.Wait()
previous := b.rows
var rows []vmRow
var failed, answered []string
for i, s := range b.sessions {
if s == nil {
continue
}
if res[i].err != nil {
failed = append(failed, s.vc.Name)
rows = append(rows, rowsOfVC(previous, s.vc)...)
continue
}
answered = append(answered, s.vc.Name)
rows = append(rows, res[i].rows...)
}
if len(rows) == 0 {
if len(failed) == 0 {
return errf("no connection left to re-read the machines over")
}
return errf("no machine could be re-read (%s)", strings.Join(failed, ", "))
}
b.rows = rows
// The title is made of these two, and a refresh that leaves them alone puts
// "412 machines on v308, v309" above a status line saying v309 did not
// answer. One of the two is then a lie, and the status line is the one that
// the next keystroke clears.
b.answered, b.lost = answered, failed
if len(failed) > 0 {
return errf("%s did not answer; showing what was last read of it", strings.Join(failed, ", "))
}
return nil
}
// rowsOfVC keeps one server's rows across a sweep it did not survive, so a
// vCenter that stops answering for a moment does not empty its half of the list.
func rowsOfVC(rows []vmRow, vc VCenter) []vmRow {
var out []vmRow
for _, r := range rows {
if r.vc.Name == vc.Name {
out = append(out, r)
}
}
return out
}
// ------------------------------------------------------------- what changed
// changesBetween is what moved between two sweeps, as one line. A table shows
// what is; this is the only thing on the screen that says what just became, and
// it is why the list is worth leaving open.
//
// Deliberately short: four things and a count of the rest. A line that has to be
// read carefully is one nobody reads at all, and the table underneath it holds
// the detail of every one of them.
func changesBetween(was, now []vmRow) string {
// Indexes, not copies. A vmRow carries the machine's whole property
// document — summary, guest, snapshot tree — and this runs every two
// seconds on a list of hundreds to compare four scalars.
before := make(map[string]int, len(was))
for i := range was {
before[was[i].id()] = i
}
seen := make(map[string]bool, len(now))
name := namer(was, now)
var said []string
for i := range now {
r := &now[i]
seen[r.id()] = true
at, had := before[r.id()]
if !had {
said = append(said, name(*r)+" is new")
continue
}
said = append(said, changesOf(&was[at], r, name)...)
}
for i := range was {
if !seen[was[i].id()] {
said = append(said, name(was[i])+" is gone")
}
}
if len(said) == 0 {
return ""
}
if len(said) > 4 {
return strings.Join(said[:4], " · ") + SF(" · and %d more", len(said)-4)
}
return strings.Join(said, " · ")
}
// namer says how to call a machine on that line. Its name, ordinarily — but a
// name is not what makes a machine that machine, and two vCenters may each hold
// a "web01" (see vmRow.id). Where they do, the server goes in front, and only
// there: "v309 web01 off" is the truth and "web01 off" is a coin toss, while
// putting the server in front of every name would spend the width on the
// ordinary case to pay for the rare one.
//
// The table underneath has a column for this, which is why it is worth so
// little width up here and so much certainty.
func namer(was, now []vmRow) func(vmRow) string {
// Both sweeps, because the lines that say a machine has gone are built out
// of the old one: deciding ambiguity from the new rows alone left "web01 is
// gone" unqualified in exactly the case where one of two web01s went.
//
// Counted by identity rather than by server, so two machines of one name on
// one vCenter — which vSphere allows, in different folders — are ambiguous
// too. The server in front does not separate those two; it does say which
// server to go and look on, which is more than the bare name does.
ids := make(map[string]map[string]bool)
for _, rows := range [][]vmRow{was, now} {
for i := range rows {
n := rows[i].name
if ids[n] == nil {
ids[n] = make(map[string]bool, 1)
}
ids[n][rows[i].id()] = true
}
}
return func(r vmRow) string {
if len(ids[r.name]) > 1 {
return r.vc.Name + " " + r.name
}
return r.name
}
}
// changesOf is what happened to one machine. Only things somebody would want to
// be told: the load moving is what the trend column is for, and a line that
// reported it would never say anything else.
func changesOf(old, now *vmRow, name func(vmRow) string) []string {
var said []string
who := name(*now)
if old.power() != now.power() {
said = append(said, who+" "+now.powerShort())
}
if d := len(now.snaps) - len(old.snaps); d != 0 {
said = append(said, SF("%s %+d snapshot", who, d))
}
switch {
case old.task == nil && now.task != nil:
said = append(said, who+" "+now.task.what)
case old.task != nil && now.task == nil:
said = append(said, who+" "+old.task.what+" done")
}
// What vCenter is complaining about, by the count: the reasons themselves
// are a column away (^w) and several of them at once would fill this line
// on their own.
if o, n := old.issues(), now.issues(); len(o) != len(n) {
switch {
case len(n) == 0:
said = append(said, who+" is clear")
case len(n) > len(o):
said = append(said, who+": "+n[0])
}
}
return said
}
// ------------------------------------------------------------ the trend column
// sample adds this sweep's load to what is remembered of each machine and draws
// it onto the row. Machines that have gone are forgotten here, which is the one
// place that can: a map of every machine ever seen would grow all day.
func (b *browser) sample() {
if b.hist == nil {
b.hist = make(map[string][]float64, len(b.rows))
}
seen := make(map[string]bool, len(b.rows))
for i := range b.rows {
r := &b.rows[i]
id := r.id()
seen[id] = true
// A machine that is not running has no load rather than a load of zero
// (cpuLoad says which), and a zero sampled off a stopped machine would
// draw a floor that never happened.
//
// What it had before goes with it. Keeping it left a machine that was
// switched off ten minutes ago showing a busy history beside a CPU%
// of "-": a shape that was true once, next to a figure saying there is
// nothing to be true about.
if pct, ok := r.cpuLoad(); ok {
h := append(b.hist[id], pct)
if len(h) > trendLen {
h = h[len(h)-trendLen:]
}
b.hist[id] = h
} else {
delete(b.hist, id)
}
r.trend = sparkline(b.hist[id])
}
for id := range b.hist {
if !seen[id] {
delete(b.hist, id)
}
}
}
// sparkBlocks are eight levels in one character each, which is what makes a
// history fit in a column.
var sparkBlocks = []rune("▁▂▃▄▅▆▇█")
// sparkline draws percentages as one piece of text.
//
// The scale is fixed at 0 to 100 and not fitted to the samples. A line that
// scales itself to what it holds makes a machine idling between 1 and 2 per
// cent look exactly like one swinging between 40 and 80 — the shape would be
// the news and the size would be invisible, which is the opposite of what a
// glance down a column is for.
//
// One sample draws nothing: a single block is not a trend, and a column that
// appears full of them the moment gvm starts would be eight characters of width
// spent on saying "hello".
func sparkline(samples []float64) string {
if len(samples) < 2 {
return ""
}
out := make([]rune, 0, len(samples))
for _, pct := range samples {
i := int(pct / 100 * float64(len(sparkBlocks)))
out = append(out, sparkBlocks[min(max(i, 0), len(sparkBlocks)-1)])
}
return string(out)
}
// trendColumn is the third column that is not always there, for the same reason
// the other two are not (see taskColumn): it holds something that is only
// sometimes true — a machine has a history once it has been swept twice — and a
// column of eight dashes down two hundred rows is width spent on nothing.
//
// It goes early on a narrow terminal — everything else in the table is a fact
// about a machine and this is a shape — but not first: the guest's operating
// system is the least read column there is, and eight characters of where a
// machine has just been are worth more than "Ubuntu Linux (64-bit)".
var trendColumn = browseColumn{header: "CPU~", width: trendLen, expendable: 2,
cell: func(r vmRow) string { return r.trend },
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }}
// anyTrend reports whether anything has a history to draw yet.
func anyTrend(rows []vmRow) bool {
for _, r := range rows {
if r.trend != "" {
return true
}
}
return false
}
+487
View File
@@ -0,0 +1,487 @@
package main
import (
"os"
"strings"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
// The trend is drawn on a fixed scale, 0 to 100, and not fitted to what it
// holds. A line that scales itself makes a machine idling between 1 and 2 per
// cent look exactly like one swinging between 40 and 80 — the shape would be
// the news and the size invisible, which is the opposite of what a glance down
// a column is for.
func TestSparklineKeepsItsScale(t *testing.T) {
idle := sparkline([]float64{1, 2, 1, 2})
busy := sparkline([]float64{40, 80, 40, 80})
if idle == busy {
t.Errorf("idling and swinging drew the same line: %q", idle)
}
if strings.Trim(idle, "▁") != "" {
t.Errorf("a machine at 1-2%% is not drawn at the floor: %q", idle)
}
// The ends of the scale, and nothing outside it: a percentage over 100 is a
// figure vCenter has been known to hand out, and it must not index past the
// blocks.
for _, c := range []struct {
pct float64
want rune
}{{0, '▁'}, {50, '▅'}, {100, '█'}, {140, '█'}, {-5, '▁'}} {
got := sparkline([]float64{c.pct, c.pct})
if []rune(got)[0] != c.want {
t.Errorf("%.0f%% drew %q, want %q", c.pct, got, string(c.want))
}
}
// One sample is not a trend, and a column full of single blocks the moment
// gvm starts would be width spent on saying hello.
if got := sparkline([]float64{50}); got != "" {
t.Errorf("one sample drew %q", got)
}
if got := sparkline(nil); got != "" {
t.Errorf("no samples drew %q", got)
}
}
// The history is kept per machine, bounded, and forgotten when the machine goes
// — a map of every machine ever seen would grow all day.
func TestSampleRemembersAndForgets(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
// web01 is the running one testBrowser makes, so it is the one with a load.
for i := 0; i < trendLen+5; i++ {
b.sample()
}
var id string
for _, r := range b.rows {
if r.running() {
id = r.id()
}
}
if id == "" {
t.Fatal("no running machine to sample")
}
if got := len(b.hist[id]); got != trendLen {
t.Errorf("the history holds %d samples, want it bounded at %d", got, trendLen)
}
if !anyTrend(b.rows) {
t.Error("nothing was drawn after a dozen sweeps")
}
// A machine that is not running has no load rather than a load of zero, so
// nothing is remembered of it: a sampled zero would draw a floor that never
// happened.
for _, r := range b.rows {
if !r.running() && len(b.hist[r.id()]) != 0 {
t.Errorf("%s is off and has %d samples", r.name, len(b.hist[r.id()]))
}
}
// And the machine going takes its history with it.
b.rows = b.rows[:0]
b.sample()
if len(b.hist) != 0 {
t.Errorf("%d histories outlived their machines", len(b.hist))
}
}
// The trend column is there only once there is something in it, the way the
// task and reason columns are: eight dashes down two hundred rows would be
// width spent on nothing.
func TestTheTrendColumnComesWithTheHistory(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
has := func() bool {
for _, c := range b.columns() {
if c.header == "CPU~" {
return true
}
}
return false
}
if has() {
t.Error("the trend column is there before anything was sampled")
}
b.sample()
if has() {
t.Error("the trend column is there after one sweep, which is not a trend")
}
b.sample()
if !has() {
t.Error("the trend column is missing after two sweeps")
}
// It is the first thing a narrow terminal gives up: everything else in the
// table is a fact about a machine, and this is a shape.
wide := fitColumnsOf(b.columns(), 200)
if wide[len(wide)-1].header == "CPU~" && len(wide) < 2 {
t.Fatal("nothing to compare")
}
narrow := fitColumnsOf(b.columns(), 100)
for _, c := range narrow {
if c.header == "CPU~" {
t.Error("a hundred columns kept the trend and gave up facts for it")
}
}
}
// What changed between two sweeps is the one thing no column can hold. Each
// kind of change has to be named, and the line has to stay short enough to read
// at a glance.
func TestChangesBetweenSweeps(t *testing.T) {
on := func(name string) vmRow {
r := testRow(name, true, "10.0.0.5")
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
return r
}
// Nothing moved.
web := on("web01")
if got := changesBetween([]vmRow{web}, []vmRow{web}); got != "" {
t.Errorf("a sweep with nothing in it said %q", got)
}
// Powered off behind gvm's back.
off := web
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
if got := changesBetween([]vmRow{web}, []vmRow{off}); !strings.Contains(got, "web01 off") {
t.Errorf("a machine that stopped said %q", got)
}
// A snapshot appearing and one going.
snapped := web
snapped.snaps = []snapEntry{{name: "s1"}}
if got := changesBetween([]vmRow{web}, []vmRow{snapped}); !strings.Contains(got, "+1 snapshot") {
t.Errorf("a new snapshot said %q", got)
}
if got := changesBetween([]vmRow{snapped}, []vmRow{web}); !strings.Contains(got, "-1 snapshot") {
t.Errorf("a removed snapshot said %q", got)
}
// A task starting and finishing.
busy := web
busy.task = &runningTask{what: "clone", progress: 40}
if got := changesBetween([]vmRow{web}, []vmRow{busy}); !strings.Contains(got, "web01 clone") {
t.Errorf("a task starting said %q", got)
}
if got := changesBetween([]vmRow{busy}, []vmRow{web}); !strings.Contains(got, "clone done") {
t.Errorf("a task finishing said %q", got)
}
// Machines coming and going.
if got := changesBetween([]vmRow{web}, []vmRow{web, on("db01")}); !strings.Contains(got, "db01 is new") {
t.Errorf("a new machine said %q", got)
}
if got := changesBetween([]vmRow{web, on("db01")}, []vmRow{web}); !strings.Contains(got, "db01 is gone") {
t.Errorf("a machine that went said %q", got)
}
// And the line stays short: four things, then a count.
var many []vmRow
for _, n := range []string{"a", "b", "c", "d", "e", "f"} {
many = append(many, on(n))
}
got := changesBetween(nil, many)
if !strings.Contains(got, "and 2 more") {
t.Errorf("six changes said %q, which does not end in a count", got)
}
if n := strings.Count(got, " · "); n > 4 {
t.Errorf("the line runs to %d pieces: %q", n, got)
}
}
// A tick may not happen underneath a menu, a picker, a confirmation or
// something half-typed: redrawing any of those from under a hand is worse than
// being ten seconds out of date.
func TestLiveHoldsStillForEveryScreenThatAsksSomething(t *testing.T) {
b := testBrowser("web01")
b.live = true
if !b.liveReady() {
t.Fatal("live mode does not tick on the plain list")
}
for _, c := range []struct {
what string
set func()
undo func()
}{
{"a menu", func() { b.menu = []menuItem{{key: 'n'}} }, func() { b.menu = nil }},
{"a picker", func() { b.pick = &picker{} }, func() { b.pick = nil }},
{"a confirmation", func() { b.confirm = &confirmation{} }, func() { b.confirm = nil }},
{"an editor", func() { b.edit = &editor{} }, func() { b.edit = nil }},
{"a question", func() { b.prompt = &prompt{} }, func() { b.prompt = nil }},
} {
c.set()
if b.liveReady() {
t.Errorf("live mode ticks with %s on screen", c.what)
}
c.undo()
}
// The sheet is refreshed, though: watching a machine's memory is a reason
// to have it open.
b.detail = []sheetLine{{label: "cpu", value: "4 vCPU"}}
if !b.liveReady() {
t.Error("live mode does not refresh an open sheet")
}
// And off is off.
b.live = false
if b.liveReady() {
t.Error("live mode ticks while it is switched off")
}
}
// It looks more often while vCenter is doing something: a clone's progress that
// moves every ten seconds is a figure one waits for, and one that moves every
// two is a thing one watches finish.
func TestLiveLooksFasterWhileSomethingIsRunning(t *testing.T) {
b := testBrowser("web01", "db01")
if got := b.liveInterval(); got != liveEvery {
t.Errorf("a quiet cluster is swept every %s, want %s", got, liveEvery)
}
b.rows[1].task = &runningTask{what: "clone", progress: 10}
if got := b.liveInterval(); got != liveBusy {
t.Errorf("a busy cluster is swept every %s, want %s", got, liveBusy)
}
}
// Two machines of one name are two machines, and the changed line has to say
// which of them stopped. A name is not what makes a machine that machine —
// vmRow.id carries the comment — and "web01 off" across three vCenters is a
// coin toss. The server goes in front only where the name is ambiguous: paying
// the width on every line for the rare case would be the wrong trade, and the
// table underneath has a column for it.
func TestTheChangedLineSaysWhichServerWhenItHasTo(t *testing.T) {
twice := func(vc string) vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
return r
}
stopped := func(r vmRow) vmRow {
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
return r
}
a, c := twice("v308"), twice("v309")
got := changesBetween([]vmRow{a, c}, []vmRow{a, stopped(c)})
if !strings.Contains(got, "v309 web01 off") {
t.Errorf("with a web01 on each of two servers it said %q", got)
}
// And one of that name is not dressed up with a server it does not need.
got = changesBetween([]vmRow{a}, []vmRow{stopped(a)})
if got != "web01 off" {
t.Errorf("an unambiguous machine said %q", got)
}
}
// A tick may clear its own line and nothing else. A message somebody produced
// by pressing a key is theirs: the address they just copied, or the reason a
// change was refused, must not vanish because ten seconds passed with nothing
// happening on the cluster.
func TestAQuietTickClearsOnlyItsOwnLine(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
// What a tick that found something leaves behind, then a quiet one.
b.setStatus(colInfo, "db01 off")
b.saidLive = "db01 off"
if b.status == b.saidLive {
b.setStatus("", "")
}
if b.status != "" {
t.Errorf("a quiet tick kept its own stale line: %q", b.status)
}
// And somebody else's message, which it must leave alone.
b.setStatus(colInfo, "copied its hostname web01.example to the clipboard (pbcopy)")
b.saidLive = "db01 off"
if b.status == b.saidLive {
b.setStatus("", "")
}
if !strings.Contains(b.status, "copied") {
t.Errorf("a quiet tick wiped a message it did not write: %q", b.status)
}
}
// A refresh that half failed still leaves the rows and the view describing the
// same list. Returning early with the rows replaced and the view not rebuilt
// showed every row as a different machine, put the cursor on one nobody was
// looking at, and panicked outright as soon as the new list was shorter — in
// renderList, which follows the view without asking.
func TestAHalfFailedRefreshLeavesTheScreenConsistent(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "20")
b := testBrowser("web01", "db01", "app07", "mail02")
b.applySort()
b.sel = len(b.view) - 1
b.live = true
// No sessions: resweep fails outright, which is the harshest version of the
// same path. The rows it could not re-read stay, and the screen still draws.
b.liveTick()
if len(b.view) != len(b.rows) {
t.Errorf("the view describes %d rows of %d", len(b.view), len(b.rows))
}
for _, i := range b.view {
if i < 0 || i >= len(b.rows) {
t.Fatalf("the view points at row %d of %d", i, len(b.rows))
}
}
_ = stripEscapes(renderToPipe(t, b, b.renderList)) // panicked before the fix
// And the tick is scheduled from after the sweep, not from before it: a
// sweep slower than the interval would otherwise leave no idle time at all
// and race every keystroke against an expired timer.
if d := b.liveIn(); d <= 0 {
t.Errorf("the next tick is already due (%s) the moment this one finished", d)
}
if b.liveGap == 0 {
t.Error("the title has no interval to show")
}
}
// A machine that stops stops drawing. Keeping its history left a busy shape
// beside a CPU% of "-": true once, and next to a figure saying there is
// nothing to be true about.
func TestTheTrendGoesWhenTheMachineStops(t *testing.T) {
b := testBrowser("web01", "db01")
b.applySort()
var at int
for i, r := range b.rows {
if r.running() {
at = i
}
}
b.sample()
b.sample()
if b.rows[at].trend == "" {
t.Fatal("a running machine drew nothing after two sweeps")
}
b.rows[at].vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
b.sample()
if got := b.rows[at].trend; got != "" {
t.Errorf("a machine that was switched off still draws %q", got)
}
if n := len(b.hist[b.rows[at].id()]); n != 0 {
t.Errorf("%d samples outlived the machine being switched off", n)
}
}
// The lines that say a machine has gone are built from the old sweep, so
// ambiguity has to be judged over both. Deciding it from the new rows alone
// left "web01 is gone" unqualified in exactly the case the function exists for.
func TestAmbiguityIsJudgedOverBothSweeps(t *testing.T) {
at := func(vc string) vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vc = VCenter{Name: vc}
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
return r
}
a, c := at("v308"), at("v309")
got := changesBetween([]vmRow{a, c}, []vmRow{a})
if !strings.Contains(got, "v309 web01 is gone") {
t.Errorf("one of two web01s was deleted and it said %q", got)
}
}
// A refresh that reached nothing at all leaves the title alone, and should:
// the rows on screen are still the ones that server gave, so a title naming it
// is describing them correctly. It is the *partial* failure that must move the
// title, and that one needs a server to answer — see TestSimLiveRefresh.
func TestARefreshThatReachedNothingKeepsTheRowsAndTheirTitle(t *testing.T) {
b := testBrowser("web01")
b.applySort()
b.answered = []string{"v308"}
b.liveTick()
if len(b.rows) != 1 || len(b.answered) != 1 {
t.Errorf("a failed refresh left %d rows titled %v", len(b.rows), b.answered)
}
if !strings.Contains(b.status, "live:") {
t.Errorf("it did not say the refresh failed: %q", b.status)
}
}
// The keystroke reader has to be able to stop waiting, without that breaking
// the one thing it must never break: a control sequence arrives in one burst,
// and a deadline expiring in the middle of "ESC [ A" would turn one arrow key
// into an Esc and a stray letter in the filter.
func TestNextWithinStopsWaitingButNotMidSequence(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
kr := newKeyReader(r)
// Nothing to read: it gives up and says so.
start := time.Now()
if _, ok := kr.nextWithin(50 * time.Millisecond); ok {
t.Error("a key was reported with nothing sent")
}
if waited := time.Since(start); waited < 40*time.Millisecond {
t.Errorf("it gave up after %s, before it was asked to", waited)
}
// A key that is there is decoded as usual.
w.WriteString("q")
k, ok := kr.nextWithin(time.Second)
if !ok || k.special != keyRune || k.r != 'q' {
t.Errorf("nextWithin gave %+v, %v", k, ok)
}
// An arrow key survives it whole, with what follows still intact.
w.WriteString("\x1b[Ax")
k, ok = kr.nextWithin(time.Second)
if !ok || k.special != keyUp {
t.Errorf("the arrow came back as %+v, %v", k, ok)
}
if next := kr.next(); next.special != keyRune || next.r != 'x' {
t.Errorf("what followed the arrow came back as %+v", next)
}
// ^l is the toggle, and nothing else had it.
w.WriteString("\x0c")
if k, ok = kr.nextWithin(time.Second); !ok || k.special != keyCtrlL {
t.Errorf("^l came back as %+v, %v", k, ok)
}
}
// Turning it on asks at once rather than in ten seconds' time, and both states
// say which they are: a list that moves on its own with nothing to explain it
// reads as a fault.
func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
b := testBrowser("web01")
b.toggleLive()
if !b.live {
t.Fatal("^l did not turn live mode on")
}
if !strings.Contains(b.status, "live on") {
t.Errorf("turning it on said %q", b.status)
}
if d := b.liveIn(); d > time.Second {
t.Errorf("the first refresh is %s away, want it now", d)
}
b.toggleLive()
if b.live {
t.Fatal("^l did not turn live mode off")
}
if !strings.Contains(b.status, "live off") {
t.Errorf("turning it off said %q", b.status)
}
}
+460
View File
@@ -0,0 +1,460 @@
// resize.go — changing what a machine has: vCPUs and memory.
//
// This is the third kind of thing gvm does to a machine, after its power and
// its snapshots, and it is the one with the most ways to be refused. vSphere
// will not take just any number:
//
// 1. A running machine can only grow, and only where it was built to. CPU and
// memory hot-add are per-machine settings somebody turned on when the
// machine was made; without them the machine has to be powered off first.
// Memory can never shrink while it runs — there is no hot-remove for it at
// all — and vCPUs only where hot-remove is on as well.
// 2. The vCPU count has to be a multiple of the cores per socket. gvm does not
// quietly change the socket topology to make a number fit: software is
// licensed per socket, and a tool that turns 2 sockets into 4 to satisfy an
// odd vCPU count would be writing somebody an invoice.
// 3. Memory is a whole number of megabytes, in multiples of four.
//
// All of that is asked before anything is sent, in the same shape the power
// operations use: an objection in two lengths, one short enough for the menu's
// own column and one to be read on its own, both out of one function so the
// menu and the message cannot disagree.
package main
import (
"strconv"
"strings"
"time"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/units"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// reconfigWait is how long gvm watches a reconfigure before it stops watching.
// The task itself is usually over in a second — the machine is not copied or
// moved, only its configuration is written — so this is a limit on a frozen
// terminal, not on the operation. Like every other wait here, vCenter carries
// on regardless.
const reconfigWait = 5 * time.Minute
// sizeKind is which of the two is being changed. They are separate operations
// with separate rules, not two fields of one: memory can never shrink while a
// machine runs and vCPUs sometimes can, and a menu entry that greys out for
// both reasons at once could not say why.
type sizeKind int
const (
sizeCPUs sizeKind = iota
sizeMemory
)
func (k sizeKind) what() string {
if k == sizeCPUs {
return "vCPU count"
}
return "memory"
}
// sizing is what a machine has and what may be changed while it runs. It comes
// from `config`, which the inventory sweep deliberately does not read — it is
// the whole configuration of a machine, and this wants four fields of it — so
// it is asked for one machine at a time, when somebody is about to change it.
type sizing struct {
cpus int32
coresPerSocket int32
memoryMB int32
cpuHotAdd bool
cpuHotRemove bool
memoryHotAdd bool
known bool // false when the configuration could not be read at all
}
// sizingProps are those four fields and the two figures they are about. Named
// paths rather than "config": the whole configuration of a machine is a large
// document, and this is a menu being drawn.
var sizingProps = []string{
"config.hardware.numCPU",
"config.hardware.numCoresPerSocket",
"config.hardware.memoryMB",
"config.cpuHotAddEnabled",
"config.cpuHotRemoveEnabled",
"config.memoryHotAddEnabled",
}
// sizingOf reads them. A machine whose configuration cannot be read gets a
// sizing that says it knows nothing, and sizeObjection turns that into a
// refusal of its own: not knowing what a machine has is a reason to leave it
// alone, not a reason to send a number and hope.
func sizingOf(s *session, ref types.ManagedObjectReference) (sizing, error) {
if s == nil {
return sizing{}, errf("no connection to read the configuration over")
}
var mvm mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, ref)
if err := vm.Properties(s.ctx, ref, sizingProps, &mvm); err != nil {
return sizing{}, errf("%s: cannot read the configuration of %s: %w",
s.vc.Name, vmName(s, ref), err)
}
return sizingFrom(mvm.Config), nil
}
// sizingFrom is the same from the property itself, so the rules below can be
// exercised without a server.
func sizingFrom(cfg *types.VirtualMachineConfigInfo) sizing {
if cfg == nil {
return sizing{}
}
sz := sizing{
cpus: cfg.Hardware.NumCPU,
memoryMB: cfg.Hardware.MemoryMB,
known: true,
}
// The cores per socket and the three flags are pointers: unset means the
// machine predates the setting or vCenter did not send it. Unset is one
// core per socket, which divides into everything, and off.
sz.coresPerSocket = 1
if cfg.Hardware.NumCoresPerSocket != nil {
sz.coresPerSocket = *cfg.Hardware.NumCoresPerSocket
}
if cfg.CpuHotAddEnabled != nil {
sz.cpuHotAdd = *cfg.CpuHotAddEnabled
}
if cfg.CpuHotRemoveEnabled != nil {
sz.cpuHotRemove = *cfg.CpuHotRemoveEnabled
}
if cfg.MemoryHotAddEnabled != nil {
sz.memoryHotAdd = *cfg.MemoryHotAddEnabled
}
return sz
}
// now is the current value of whichever of the two this is, and shown is it in
// the words the sheet uses — "4 vCPU", "8.0GB" — so a message about a change
// and the line above it read as the same figure.
func (sz sizing) now(k sizeKind) int32 {
if k == sizeCPUs {
return sz.cpus
}
return sz.memoryMB
}
func (k sizeKind) shown(v int32) string {
if k == sizeCPUs {
return SF("%d vCPU", v)
}
return units.ByteSize(int64(v) * 1024 * 1024).String()
}
// ------------------------------------------------------------- what was typed
// parseSize turns what somebody typed into the number vSphere wants: a vCPU
// count, or a memory size in megabytes.
//
// Memory is read as gigabytes, because that is the unit the sheet shows and the
// unit anybody says out loud — "give it 16" is never sixteen megabytes. An
// explicit unit overrides that, so 512m is still sayable, and a fraction is
// taken where it lands on a whole megabyte: 1.5g is 1536 MB.
func parseSize(k sizeKind, text string) (int32, error) {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return 0, errf("nothing typed")
}
if k == sizeCPUs {
n, err := strconv.Atoi(t)
if err != nil {
return 0, errf("%q is not a number of vCPUs", text)
}
if n < 1 {
return 0, errf("a machine has at least one vCPU, not %d", n)
}
if n > maxCPUs {
return 0, errf("%d vCPUs is past anything vSphere builds — a typo?", n)
}
return int32(n), nil
}
unit := "g"
for _, suffix := range []string{"mb", "gb", "m", "g"} {
if strings.HasSuffix(t, suffix) {
unit, t = suffix[:1], strings.TrimSpace(strings.TrimSuffix(t, suffix))
break
}
}
v, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, errf("%q is not an amount of memory", text)
}
mb := v
if unit == "g" {
mb = v * 1024
}
if mb < 4 {
return 0, errf("%s is less memory than a machine can have", text)
}
if mb > maxMemoryMB {
return 0, errf("%s is more memory than vSphere takes — a typo?", text)
}
if mb != float64(int64(mb)) {
return 0, errf("%s is not a whole number of megabytes", text)
}
return int32(mb), nil
}
// The two ceilings are not vSphere's exact maxima, which move with every
// release and with the hardware version of the machine. They are there to catch
// a finger that stayed on a key: past these, a number is a typo rather than an
// intention, and everything below them is left to the server to accept or
// refuse with its own reasons.
const (
maxCPUs = 1024
maxMemoryMB = 32 * 1024 * 1024 // 32 TB
)
// -------------------------------------------------------------- the objection
// sizeObjection says why this machine cannot be given that, in two lengths, the
// same as powerObjection. Both the menu and the message come from here.
//
// A want of 0 asks the weaker question the menu asks while it is being drawn:
// not "can it have six" but "is there any number at all it could be given right
// now" — which is what decides whether the entry is offered or greyed out.
func sizeObjection(r vmRow, sz sizing, k sizeKind, want int32) (short, long string) {
// A machine whose configuration could not be read is not one to change. It
// is tempting to send it anyway and let vCenter be the authority on what it
// takes — but gvm would not know what the machine has now, so it could
// neither check the socket rule nor put an honest "from" in the question it
// asks. "8.0GB, up from something I could not read" is not a confirmation.
if !sz.known {
return "configuration unread", SF("what %s has could not be read, so there is nothing "+
"to change it from — try again, or change it in the vSphere client", r.name)
}
running := r.running()
if want == 0 {
switch {
case !running:
return "", ""
case k == sizeCPUs && !sz.cpuHotAdd && !sz.cpuHotRemove:
return "needs it off", SF("%s is running and has neither CPU hot-add nor hot-remove — "+
"power it off to change the vCPU count", r.name)
case k == sizeMemory && !sz.memoryHotAdd:
return "needs it off", SF("%s is running and has no memory hot-add — "+
"power it off to change its memory", r.name)
}
return "", ""
}
now := sz.now(k)
if want == now {
return "unchanged", SF("%s already has %s", r.name, k.shown(now))
}
if k == sizeCPUs {
// The socket topology is the machine's, not gvm's to adjust: a vCPU
// count that does not divide into it is refused with the two counts
// that do, rather than made to fit by changing the number of sockets.
if per := sz.coresPerSocket; per > 1 && want%per != 0 {
below, above := want-want%per, want-want%per+per
// Below a single socket there is no lower count to offer: zero
// processors is not a machine, and parseSize refuses it anyway.
// Naming it would be offering an answer gvm will not take.
fits := SF("%d or %d", below, above)
if below < per {
fits = SF("%d", above)
}
return "not a whole socket", SF("%s has %d cores per socket, so its vCPUs come in "+
"multiples of %d — %s, not %d", r.name, per, per, fits, want)
}
switch {
case running && want > now && !sz.cpuHotAdd:
return "no CPU hot-add", SF("%s is running and CPU hot-add is off — "+
"power it off to give it more than %d vCPU", r.name, now)
case running && want < now && !sz.cpuHotRemove:
return "no CPU hot-remove", SF("%s is running and CPU hot-remove is off — "+
"power it off to take vCPUs away from it", r.name)
}
return "", ""
}
switch {
case want%4 != 0:
return "not a multiple of 4 MB", SF("memory is set in multiples of 4 MB, and %d MB is not one", want)
case running && want < now:
// Not a flag anybody can turn on: vSphere has no memory hot-remove.
return "cannot shrink while on", SF("%s is running, and memory can never be taken away from "+
"a running machine — power it off first", r.name)
case running && !sz.memoryHotAdd:
return "no memory hot-add", SF("%s is running and memory hot-add is off — "+
"power it off to change its memory", r.name)
}
return "", ""
}
// checkSize is that objection as an error, or nil. Asked before the change is
// offered and again immediately before it is sent: between a menu being drawn
// and a confirmation being answered, somebody else may have powered the machine
// on.
func checkSize(r vmRow, sz sizing, k sizeKind, want int32) error {
if _, long := sizeObjection(r, sz, k, want); long != "" {
return errf("%s", long)
}
return nil
}
// hotly reports whether this change is going to happen underneath a running
// guest, which is worth saying afterwards: an operating system does not
// necessarily notice on its own that it has been given another four processors.
func hotly(r vmRow, k sizeKind, sz sizing, want int32) bool {
return r.running() && want > sz.now(k)
}
// ------------------------------------------------------------------ doing it
// runResize sends the change and waits for it. One task per call even when both
// numbers move, because the two are separate operations everywhere else in gvm
// and a single message that half worked would be the worst of both.
func runResize(s *session, r vmRow, sz sizing, k sizeKind, want int32) (message string, err error) {
if err := checkSize(r, sz, k, want); err != nil {
return "", err
}
spec := types.VirtualMachineConfigSpec{}
if k == sizeCPUs {
spec.NumCPUs = want
} else {
spec.MemoryMB = int64(want)
}
vm := object.NewVirtualMachine(s.client.Client, r.ref)
task, err := vm.Reconfigure(s.ctx, spec)
if err != nil {
return "", errf("%s: cannot change the %s of %s: %w", s.vc.Name, k.what(), r.name, err)
}
what := SF("%s of %s", k.what(), r.name)
if err := waitTask(s.ctx, task, reconfigWait, what); err != nil {
return "", err
}
msg := SF("%s: %s → %s", r.name, k.shown(sz.now(k)), k.shown(want))
if hotly(r, k, sz, want) {
msg += " — added while it runs; the guest may have to bring it online"
}
return msg, nil
}
// ---------------------------------------------------------- the command line
// sizeCLI is `gvm size`: with no number it says what the machine has, and with
// one it changes it. The two live in one command because "what has it got" is
// the question one asks immediately before "give it more", and having to
// remember two spellings of the same noun to ask both is a small tax.
func sizeCLI(vc VCenter, vmname, cpus, memory string, yes bool) error {
s, err := connect(vc)
if err != nil {
return err
}
defer s.close()
vm, err := s.vm(vmname)
if err != nil {
return err
}
// Read as fresh as the operation is going to be: what is offered and what is
// refused both depend on whether the machine is running right now.
var mvm mo.VirtualMachine
if err := vm.Properties(s.ctx, vm.Reference(), append([]string{"summary", "guest"}, sizingProps...), &mvm); err != nil {
return errf("%s: cannot read %s: %w", vc.Name, vm.Name(), err)
}
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: vm.Name(), vm: mvm}
sz := sizingFrom(mvm.Config)
if cpus == "" && memory == "" {
sizeShow(r, sz)
return nil
}
// Both are read and checked before either is sent. A command line that sets
// the vCPUs and then refuses the memory for not being a multiple of 4 MB has
// half happened, and half of what somebody asked for is the one outcome
// nobody wanted — it is why `power` refuses two operations at once rather
// than carrying out the first.
//
// Checking both against the machine as it is now is sound because neither
// change can make the other impossible: what a resize is refused for is the
// power state, the hot-plug settings and the socket topology, and none of
// the three is touched here.
var changes []struct {
kind sizeKind
want int32
}
for _, c := range []struct {
kind sizeKind
text string
}{{sizeCPUs, cpus}, {sizeMemory, memory}} {
if c.text == "" {
continue
}
want, err := parseSize(c.kind, c.text)
if err != nil {
return err
}
if err := checkSize(r, sz, c.kind, want); err != nil {
return err
}
changes = append(changes, struct {
kind sizeKind
want int32
}{c.kind, want})
}
for _, c := range changes {
ok, err := confirm(SF("%s on %s: %s → %s?", r.name, vc.Name,
c.kind.shown(sz.now(c.kind)), c.kind.shown(c.want)), yes)
if err != nil || !ok {
return err
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
return err
}
PO(msg)
}
return nil
}
// sizeShow prints what the machine has and what could be changed without
// stopping it — the second being the thing one actually wants to know before
// planning the work, and the thing no listing anywhere else says.
func sizeShow(r vmRow, sz sizing) {
if !sz.known {
PE("the configuration of " + r.name + " could not be read")
return
}
PF("%-14s %s\n", "machine", r.name+" ("+r.powerLong()+")")
PF("%-14s %s\n", "vCPU", SF("%d, in %d per socket", sz.cpus, sz.coresPerSocket))
PF("%-14s %s\n", "memory", sizeMemory.shown(sz.memoryMB))
PF("%-14s %s\n", "while it runs", liveChanges(sz))
}
// liveChanges is that last line: which of the three hot-plug settings this
// machine was built with, said as what they let one do rather than as the names
// of the flags.
func liveChanges(sz sizing) string {
var can []string
if sz.cpuHotAdd {
can = append(can, "add vCPUs")
}
if sz.cpuHotRemove {
can = append(can, "remove vCPUs")
}
if sz.memoryHotAdd {
can = append(can, "add memory")
}
if len(can) == 0 {
return "nothing — it has to be powered off to be changed"
}
return strings.Join(can, ", ")
}
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"strings"
"testing"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
// sizedRow is a machine in a given power state, for the rules below. Only the
// power state is read off it: everything else a resize depends on comes from
// the sizing, which is read separately from the machine's configuration.
func sizedRow(state types.VirtualMachinePowerState) vmRow {
return vmRow{
name: "web01",
vc: VCenter{Name: "v308"},
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Runtime: types.VirtualMachineRuntimeInfo{PowerState: state},
}},
}
}
// What somebody types has to become the number vSphere wants, or a refusal that
// says which part of it was not a number.
func TestParseSize(t *testing.T) {
for _, c := range []struct {
kind sizeKind
text string
want int32
bad string // a piece of the error, when it is meant to be refused
}{
{kind: sizeCPUs, text: "4", want: 4},
{kind: sizeCPUs, text: " 16 ", want: 16},
{kind: sizeCPUs, text: "0", bad: "at least one"},
{kind: sizeCPUs, text: "-2", bad: "at least one"},
{kind: sizeCPUs, text: "4.5", bad: "not a number"},
{kind: sizeCPUs, text: "eight", bad: "not a number"},
{kind: sizeCPUs, text: "99999", bad: "typo"},
// Memory is gigabytes unless it says otherwise: nobody means 16 MB.
{kind: sizeMemory, text: "16", want: 16384},
{kind: sizeMemory, text: "8g", want: 8192},
{kind: sizeMemory, text: "8GB", want: 8192},
{kind: sizeMemory, text: "512m", want: 512},
{kind: sizeMemory, text: "512mb", want: 512},
{kind: sizeMemory, text: "1.5g", want: 1536},
{kind: sizeMemory, text: "0", bad: "less memory"},
{kind: sizeMemory, text: "0.0001g", bad: "less memory"},
{kind: sizeMemory, text: "4.5m", bad: "whole number"},
{kind: sizeMemory, text: "lots", bad: "not an amount"},
{kind: sizeMemory, text: "99999g", bad: "typo"},
} {
got, err := parseSize(c.kind, c.text)
switch {
case c.bad != "":
if err == nil {
t.Errorf("parseSize(%q) = %d, want a refusal", c.text, got)
} else if !strings.Contains(err.Error(), c.bad) {
t.Errorf("parseSize(%q) refused with %q, which does not mention %q", c.text, err, c.bad)
}
case err != nil:
t.Errorf("parseSize(%q): %v", c.text, err)
case got != c.want:
t.Errorf("parseSize(%q) = %d, want %d", c.text, got, c.want)
}
}
}
// The rules a running machine is held to. This is the heart of the feature: what
// vSphere will refuse has to be refused here first, with the reason, rather than
// sent and bounced with a stack of SOAP.
func TestSizeObjectionOnARunningMachine(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true,
cpuHotAdd: true, cpuHotRemove: true, memoryHotAdd: true}
for _, c := range []struct {
what string
row vmRow
sz sizing
kind sizeKind
want int32
bad string // a piece of the objection, or "" when it must be allowed
}{
// Powered off, anything goes.
{"more vCPUs, machine off", off, plain, sizeCPUs, 8, ""},
{"fewer vCPUs, machine off", off, plain, sizeCPUs, 2, ""},
{"more memory, machine off", off, plain, sizeMemory, 16384, ""},
{"less memory, machine off", off, plain, sizeMemory, 4096, ""},
// Running, without the settings that would allow it.
{"more vCPUs, no hot-add", on, plain, sizeCPUs, 8, "hot-add is off"},
{"fewer vCPUs, no hot-remove", on, plain, sizeCPUs, 2, "hot-remove is off"},
{"more memory, no hot-add", on, plain, sizeMemory, 16384, "hot-add is off"},
// Running, with them.
{"more vCPUs, hot-add on", on, hot, sizeCPUs, 8, ""},
{"fewer vCPUs, hot-remove on", on, hot, sizeCPUs, 2, ""},
{"more memory, hot-add on", on, hot, sizeMemory, 16384, ""},
// The one no setting can allow: vSphere has no memory hot-remove.
{"less memory, hot-add on", on, hot, sizeMemory, 4096, "never be taken away"},
// Nothing to do.
{"the same vCPUs", on, hot, sizeCPUs, 4, "already"},
{"the same memory", on, hot, sizeMemory, 8192, "already"},
// Memory is whole multiples of four megabytes.
{"memory of 10 MB", off, plain, sizeMemory, 10, "multiples of 4 MB"},
// Not knowing what a machine has is a reason to leave it alone: there
// would be nothing honest to put on the left of the arrow.
{"unknown configuration", on, sizing{}, sizeCPUs, 64, "could not be read"},
{"unknown configuration, menu time", on, sizing{}, sizeMemory, 0, "could not be read"},
} {
short, long := sizeObjection(c.row, c.sz, c.kind, c.want)
switch {
case c.bad == "":
if long != "" {
t.Errorf("%s: refused with %q", c.what, long)
}
case long == "":
t.Errorf("%s: allowed, want a refusal mentioning %q", c.what, c.bad)
case !strings.Contains(long, c.bad):
t.Errorf("%s: refused with %q, which does not mention %q", c.what, long, c.bad)
}
// Both lengths or neither: the menu column and the message come from
// here together, and one without the other greys an entry out with
// nothing to explain it.
if (short == "") != (long == "") {
t.Errorf("%s: short %q and long %q disagree about whether there is an objection",
c.what, short, long)
}
}
}
// The socket topology is the machine's own. A vCPU count that does not divide
// into it is refused with the two that do, rather than made to fit by changing
// the number of sockets underneath somebody's per-socket licence.
func TestSizeKeepsTheSocketTopology(t *testing.T) {
off := sizedRow(types.VirtualMachinePowerStatePoweredOff)
sz := sizing{cpus: 8, coresPerSocket: 4, memoryMB: 8192, known: true}
_, long := sizeObjection(off, sz, sizeCPUs, 6)
if long == "" {
t.Fatal("6 vCPUs was allowed on a machine with 4 cores per socket")
}
for _, want := range []string{"4 cores per socket", "4 or 8"} {
if !strings.Contains(long, want) {
t.Errorf("the refusal does not say %q: %q", want, long)
}
}
if _, long := sizeObjection(off, sz, sizeCPUs, 12); long != "" {
t.Errorf("12 vCPUs is three whole sockets and was refused: %q", long)
}
// Below one whole socket there is no lower count to offer. Naming 0 would
// be offering an answer parseSize itself refuses.
for _, want := range []int32{1, 2, 3} {
_, long := sizeObjection(off, sz, sizeCPUs, want)
if long == "" {
t.Errorf("%d vCPUs was allowed with 4 cores per socket", want)
continue
}
if !strings.Contains(long, "— 4, not") {
t.Errorf("asking for %d does not offer 4 on its own: %q", want, long)
}
}
// The same across the topologies machines are actually built with. The
// counts sit after the dash, so that is where a zero is looked for:
// "0 or 4" would be the bug and "10 or 12" is a perfectly good answer, and
// a test that cannot tell them apart fails the day this loop is widened.
for _, per := range []int32{2, 4, 8} {
sz := sizing{cpus: 8, coresPerSocket: per, memoryMB: 8192, known: true}
for want := int32(1); want <= 20; want++ {
_, long := sizeObjection(off, sz, sizeCPUs, want)
switch {
case want%per == 0:
if long != "" && !strings.Contains(long, "already") {
t.Errorf("%d vCPUs is whole sockets of %d and was refused: %q", want, per, long)
}
case long == "":
t.Errorf("%d vCPUs was allowed with %d cores per socket", want, per)
case strings.Contains(long, "— 0"):
t.Errorf("%d vCPUs with %d per socket offers none at all: %q", want, per, long)
}
}
}
}
// A machine whose configuration could not be read is refused rather than sent:
// gvm would not know what it has, so it could put nothing honest on the left of
// the arrow — "0B → 8.0GB" is not a confirmation, it is a wrong number.
func TestAnUnreadableConfigurationIsRefused(t *testing.T) {
on := sizedRow(types.VirtualMachinePowerStatePoweredOn)
for _, k := range []sizeKind{sizeCPUs, sizeMemory} {
if err := checkSize(on, sizing{}, k, 8); err == nil {
t.Errorf("the %s was changed on a machine whose configuration is unknown", k.what())
}
// And the menu says the same thing before any number is typed.
short, long := sizeObjection(on, sizing{}, k, 0)
if short == "" || long == "" {
t.Errorf("the %s entry is offered on a machine whose configuration is unknown", k.what())
}
}
}
// A machine whose configuration says nothing about the hot-plug settings is a
// machine without them, and one core per socket divides into everything — the
// fields are pointers, and unset must not read as zero cores per socket.
func TestSizingFromAnEmptyConfiguration(t *testing.T) {
if sz := sizingFrom(nil); sz.known {
t.Error("a machine with no configuration claimed to know its sizing")
}
sz := sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 4, MemoryMB: 8192},
})
if !sz.known {
t.Fatal("a configuration that was read is said to be unknown")
}
if sz.coresPerSocket != 1 {
t.Errorf("cores per socket = %d, want 1 where the server did not say", sz.coresPerSocket)
}
if sz.cpuHotAdd || sz.cpuHotRemove || sz.memoryHotAdd {
t.Error("a setting the server did not send was taken for on")
}
yes := true
four := int32(4)
sz = sizingFrom(&types.VirtualMachineConfigInfo{
Hardware: types.VirtualHardware{NumCPU: 8, MemoryMB: 16384, NumCoresPerSocket: &four},
CpuHotAddEnabled: &yes,
MemoryHotAddEnabled: &yes,
})
if sz.coresPerSocket != 4 || !sz.cpuHotAdd || !sz.memoryHotAdd || sz.cpuHotRemove {
t.Errorf("the settings did not come through: %+v", sz)
}
}
// The two entries are greyed out with the reason on a machine that cannot take
// the change at all, and offered on one that can — which is the weaker question
// the menu asks, before any number has been typed.
func TestTheMenuOffersTheHardwareEntries(t *testing.T) {
b := &browser{}
find := func(items []menuItem, k rune) menuItem {
t.Helper()
for _, m := range items {
if m.key == k {
return m
}
}
t.Fatalf("no menu entry %q", string(k))
return menuItem{}
}
plain := sizing{cpus: 4, coresPerSocket: 1, memoryMB: 8192, known: true}
hot := plain
hot.cpuHotAdd, hot.memoryHotAdd = true, true
running := b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, plain)
for _, k := range []rune{'c', 'm'} {
m := find(running, k)
if m.available() {
t.Errorf("%q is offered on a running machine with no hot-plug", string(k))
}
if m.hint == "" || m.why == "" {
t.Errorf("%q is greyed out without saying why", string(k))
}
}
for _, c := range []struct {
what string
menu []menuItem
}{
{"a machine that is off", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOff), nil, plain)},
{"a running machine with hot-add", b.buildMenu(sizedRow(types.VirtualMachinePowerStatePoweredOn), nil, hot)},
} {
for _, k := range []rune{'c', 'm'} {
if m := find(c.menu, k); !m.available() {
t.Errorf("%q is not offered on %s: %s", string(k), c.what, m.why)
}
}
}
}
// What the messages call the two figures is what the sheet calls them, so a
// change and the line it changes read as the same number.
func TestSizeIsShownTheWayTheSheetShowsIt(t *testing.T) {
if got := sizeCPUs.shown(4); got != "4 vCPU" {
t.Errorf("vCPUs shown as %q", got)
}
if got := sizeMemory.shown(8192); got != "8.0GB" {
t.Errorf("memory shown as %q, want the sheet's own spelling", got)
}
r := vmRow{vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
Config: types.VirtualMachineConfigSummary{MemorySizeMB: 8192}}}}
if sheet, msg := r.memory(), sizeMemory.shown(8192); sheet != msg {
t.Errorf("the sheet says %q and a resize says %q", sheet, msg)
}
}
+537
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"os"
"strings"
@@ -1218,6 +1219,542 @@ func TestSimEventsWithoutAConnection(t *testing.T) {
}
}
// Making a machine from a template, against a server that answers: the source
// has to be a template, the placement is worked out rather than asked for, and
// the machine that comes out is a machine and not another template.
func TestSimDeployFromATemplate(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
// An ordinary machine is refused before anything is sent: gvm copies
// templates, and a clone of a running machine is a different operation with
// different consequences.
src, err := sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf: %v", err)
}
if src.template {
t.Fatal("the simulator handed back a template where a machine was asked for")
}
if _, err := startDeploy(s, src, deployTarget{}, "copy01", deployOpts{}); err == nil {
t.Error("a machine was copied as though it were a template")
}
// Make it one. A template has no resource pool of its own from here on,
// which is the whole reason the placement has to be worked out.
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot stop the machine to template it: %v", err)
}
if err := vm.MarkAsTemplate(s.ctx); err != nil {
t.Fatalf("cannot mark it as a template: %v", err)
}
src, err = sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf after templating: %v", err)
}
if !src.template {
t.Fatal("a machine marked as a template does not read as one")
}
target, err := targetFor(s, src, deployOpts{})
if err != nil {
t.Fatalf("targetFor: %v", err)
}
if target.where == "" {
t.Error("the placement has nothing to say where it would run")
}
if target.pool.Value == "" {
t.Error("no resource pool was worked out, so nothing could run")
}
// A name that is already taken is refused before the clone starts, rather
// than several seconds in by vCenter.
if _, err := startDeploy(s, src, target, "DC0_C0_RP0_VM1", deployOpts{}); err == nil {
t.Error("a name that is already in use was accepted")
}
task, err := startDeploy(s, src, target, "made-from-template", deployOpts{})
if err != nil {
t.Fatalf("startDeploy: %v", err)
}
if err := waitTask(s.ctx, task, cloneWait, "making it"); err != nil {
t.Fatalf("the clone did not finish: %v", err)
}
// It exists, it is a machine rather than a template, and it is where it was
// said it would be.
made, err := s.vm("made-from-template")
if err != nil {
t.Fatalf("the new machine cannot be found: %v", err)
}
var mvm mo.VirtualMachine
if err := made.Properties(s.ctx, made.Reference(), []string{"summary", "resourcePool"}, &mvm); err != nil {
t.Fatalf("cannot read what was made: %v", err)
}
if mvm.Summary.Config.Template {
t.Error("what came out is another template, not a machine")
}
if mvm.ResourcePool == nil || *mvm.ResourcePool != target.pool {
t.Errorf("it landed in %v, not in the pool it was given (%v)", mvm.ResourcePool, target.pool)
}
if mvm.Summary.Runtime.PowerState != types.VirtualMachinePowerStatePoweredOff {
t.Errorf("it was started, though nothing asked for that: %s", mvm.Summary.Runtime.PowerState)
}
}
// Telling the guest what it is, against a server that holds real customisation
// specifications: gvm writes the two facts that are about this one machine into
// the one the vCenter keeps, and leaves the site's answers alone.
func TestSimCustomisationFromAVCenterSpec(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
names, err := specNames(s)
if err != nil {
t.Fatalf("specNames: %v", err)
}
if !contains(names, "vcsim-linux-static") || !contains(names, "vcsim-windows-static") {
t.Fatalf("the simulator's specifications are not what this test is written against: %v", names)
}
// A Linux specification with a netmask in it: both facts go in, and the
// netmask and gateway that came with it stay.
spec, err := customizationFor(s, deployOpts{spec: "vcsim-linux-static", ip: "10.0.0.55"}, deploySource{name: "tpl"}, "web05")
if err != nil {
t.Fatalf("customizationFor: %v", err)
}
if got := hostNameOf(spec); got != "web05" {
t.Errorf("the hostname is %q", got)
}
if got := addressOf(spec); !strings.Contains(got, "10.0.0.55") || !strings.Contains(got, "255.255.255.0") {
t.Errorf("the address line is %q", got)
}
// The machine's own name is the hostname unless something else is said.
spec, err = customizationFor(s, deployOpts{spec: "vcsim-linux-static", hostname: "web05.fhi"}, deploySource{name: "tpl"}, "web05")
if err != nil {
t.Fatalf("customizationFor with a hostname: %v", err)
}
if got := hostNameOf(spec); got != "web05.fhi" {
t.Errorf("--hostname was ignored: %q", got)
}
// Windows has its computer name somewhere else, and it is found there.
spec, err = customizationFor(s, deployOpts{spec: "vcsim-windows-static"}, deploySource{name: "tpl"}, "WEB05")
if err != nil {
t.Fatalf("a Windows specification was refused: %v", err)
}
if got := hostNameOf(spec); got != "WEB05" {
t.Errorf("the Windows computer name is %q", got)
}
// The one whose adapter takes its address from DHCP has no netmask to give
// a fixed address, and says so rather than making one up.
if _, err := customizationFor(s, deployOpts{spec: "vcsim-linux", ip: "10.0.0.55"}, deploySource{name: "tpl"}, "web05"); err == nil {
t.Error("an address was written into a DHCP specification")
}
// Without an address that same specification is perfectly usable.
if _, err := customizationFor(s, deployOpts{spec: "vcsim-linux"}, deploySource{name: "tpl"}, "web05"); err != nil {
t.Errorf("a DHCP specification was refused with no address asked for: %v", err)
}
// A name that is not there says what is.
_, err = customizationFor(s, deployOpts{spec: "no-such-spec"}, deploySource{name: "tpl"}, "web05")
if err == nil {
t.Fatal("a specification that does not exist was accepted")
}
if !strings.Contains(err.Error(), "vcsim-linux-static") {
t.Errorf("it did not say what there is: %v", err)
}
// And the whole way through: a template, deployed with a customisation.
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot stop the machine: %v", err)
}
if err := vm.MarkAsTemplate(s.ctx); err != nil {
t.Fatalf("cannot mark it as a template: %v", err)
}
src, err := sourceOf(s, r.ref)
if err != nil {
t.Fatalf("sourceOf: %v", err)
}
target, err := targetFor(s, src, deployOpts{})
if err != nil {
t.Fatalf("targetFor: %v", err)
}
opts := deployOpts{spec: "vcsim-linux-static", ip: "10.0.0.56"}
task, err := startDeploy(s, src, target, "customised01", opts)
if err != nil {
t.Fatalf("startDeploy: %v", err)
}
if err := waitTask(s.ctx, task, cloneWait, "making it"); err != nil {
t.Fatalf("the clone did not finish: %v", err)
}
if _, err := s.vm("customised01"); err != nil {
t.Errorf("the customised machine cannot be found: %v", err)
}
}
// The estate screen against a server that answers: the hosts come back grouped
// under their clusters, what they carry is added up from the rows the list
// already holds, and Enter narrows the list to the host under the cursor.
func TestSimEstateScreen(t *testing.T) {
quiet(t)
t.Setenv("COLUMNS", "150")
t.Setenv("LINES", "20")
vc := simVCenter(t)
found, err := gatherVMs([]VCenter{vc})
defer closeSessions(found.sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
b := &browser{targets: []VCenter{vc}, rows: found.rows, sessions: found.sessions,
answered: found.answered}
b.applySort()
b.openEstate()
if b.estate == nil {
t.Fatalf("the estate screen did not open: %q", b.status)
}
e := b.estate
// Every host of the simulated inventory, under a heading each, and the
// cursor on a host rather than on a heading.
var hosts, headings int
for _, r := range e.rows {
if r.isHeading() {
headings++
continue
}
hosts++
}
if hosts == 0 || headings == 0 {
t.Fatalf("%d hosts under %d headings", hosts, headings)
}
if e.rows[e.sel].isHeading() {
t.Error("the cursor started on a heading")
}
// The machines are charged to the hosts they are on, and the total matches
// what the list holds — nothing counted twice, nothing dropped.
charged := 0
for _, r := range e.rows {
if !r.isHeading() {
charged += r.vms
}
}
placed := 0
for _, r := range b.rows {
if r.vm.Summary.Runtime.Host != nil {
placed++
}
}
if charged != placed {
t.Errorf("%d machines charged to hosts, %d placed on one", charged, placed)
}
// A heading is the sum of the hosts under it.
for i, r := range e.rows {
if !r.isHeading() {
continue
}
sum := 0
for _, h := range e.rows[i+1:] {
if h.isHeading() {
break
}
sum += h.vms
}
if r.vms != sum {
t.Errorf("%q says %d machines, its hosts hold %d", r.heading, r.vms, sum)
}
}
// It draws, with the figures on it.
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
for _, want := range []string{"Estate", "CLUSTER / HOST", "MEM ALLOC", "DC0_C0"} {
if !strings.Contains(frame, want) {
t.Errorf("the screen does not show %q:\n%s", want, frame)
}
}
// And Enter is the whole point of arrowing to a host: the machine list,
// narrowed to it.
//
// Arrowed to one that carries something, deliberately: the simulator does
// not spread its machines evenly and leaves some hosts empty, so starting
// from wherever the cursor happens to open would be a test that passes or
// fails by placement rather than by behaviour. (It did: one run in three.)
for i, r := range e.rows {
if !r.isHeading() && r.vms > 0 {
e.sel = i
break
}
}
host := e.rows[e.sel].host
if e.rows[e.sel].vms == 0 {
t.Fatal("no host in the simulated inventory carries a machine")
}
b.showHost()
if b.estate != nil {
t.Error("the estate screen stayed open after Enter")
}
if b.filter != host {
t.Errorf("the filter is %q, want the host %q", b.filter, host)
}
if len(b.view) == 0 {
t.Errorf("filtering to %s left no machines, though it carries some", host)
}
for _, i := range b.view {
if b.rows[i].host != host {
t.Errorf("%s is on %s, not on %s", b.rows[i].name, b.rows[i].host, host)
}
}
}
// Live mode against a server that answers: the refresh goes over the connection
// that is already open, it notices what happened behind gvm's back, and it says
// what changed.
func TestSimLiveRefreshUsesTheOpenSession(t *testing.T) {
quiet(t)
vc := simVCenter(t)
found, err := gatherVMs([]VCenter{vc})
rows, sessions := found.rows, found.sessions
defer closeSessions(sessions)
if err != nil {
t.Fatalf("gatherVMs: %v", err)
}
b := &browser{targets: []VCenter{vc}, rows: rows, sessions: sessions,
answered: found.answered}
b.applySort()
b.sample()
// The refresh reads the same machines back, and does not touch the
// sessions: a reload replaces them, this must not.
before := len(b.rows)
was := b.rows
if err := b.resweep(); err != nil {
t.Fatalf("resweep: %v", err)
}
if len(b.rows) != before {
t.Errorf("the refresh came back with %d of %d machines", len(b.rows), before)
}
if len(b.sessions) != len(sessions) || b.sessions[0] != sessions[0] {
t.Error("the refresh replaced the session it was supposed to reuse")
}
if got := changesBetween(was, b.rows); got != "" {
t.Errorf("a refresh with nothing happening in between said %q", got)
}
// Something happens that gvm did not do. A refresh has to notice, and say
// so — this is the whole reason to leave the list open.
var victim vmRow
for _, r := range b.rows {
if r.running() {
victim = r
break
}
}
if victim.name == "" {
t.Fatal("no running machine to stop")
}
if _, err := runPower(victim.sess, victim, opPowerOff); err != nil {
t.Fatalf("cannot stop %s: %v", victim.name, err)
}
was = b.rows
if err := b.resweep(); err != nil {
t.Fatalf("resweep after the change: %v", err)
}
said := changesBetween(was, b.rows)
if !strings.Contains(said, victim.name+" off") {
t.Errorf("the refresh did not report the machine stopping: %q", said)
}
for _, r := range b.rows {
if r.name == victim.name && r.running() {
t.Error("the refreshed row still says the machine is running")
}
}
// One server of two failing moves the title as well as the status line: the
// rows of the one that did not answer are kept, and it is named as lost
// rather than left in the list of servers holding machines. A title reading
// "on v308, v309" above a status line saying v309 did not answer is two
// lines contradicting each other, and the next keystroke clears the true
// one.
dead, stop := context.WithCancel(context.Background())
stop()
b.sessions = append(b.sessions, &session{vc: VCenter{Name: "v309"}, ctx: dead,
client: sessions[0].client, cancel: stop})
b.answered, b.lost = []string{vc.Name, "v309"}, nil
if err := b.resweep(); err == nil {
t.Error("a refresh over a dead session reported no trouble")
}
if contains(b.answered, "v309") {
t.Errorf("v309 did not answer and is still named as holding machines: %v", b.answered)
}
if !contains(b.lost, "v309") {
t.Errorf("v309 is not named as lost: %v", b.lost)
}
if !contains(b.answered, vc.Name) {
t.Errorf("%s answered and is not named: %v", vc.Name, b.answered)
}
b.sessions = b.sessions[:len(b.sessions)-1]
// And it really is the open session it reads over: with that closed, the
// refresh fails instead of quietly logging in again. Three logins a minute
// is what live mode exists not to do.
closeSessions(sessions)
if err := b.resweep(); err == nil {
t.Error("the refresh worked with every session closed, so it logged in again")
}
// The rows of a server that stopped answering are kept rather than dropped:
// an empty list because a vCenter is restarting would be worse than one
// that is a minute old.
if len(b.rows) != before {
t.Errorf("a failed refresh left %d of %d machines on screen", len(b.rows), before)
}
}
// Changing what a machine has, against a server that answers: the refusal on a
// running machine, and the change itself once it is off. The numbers are read
// back from the vCenter afterwards rather than assumed — a reconfigure that is
// accepted and does nothing would otherwise look exactly like one that worked.
func TestSimResizeAMachine(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
sz, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
if !sz.known {
t.Fatal("the machine's configuration came back empty")
}
// The simulator's machines run, and are built without hot-plug — which is
// the case the objection exists for.
if !r.running() {
t.Fatal("the simulated machine is not running, so there is nothing to refuse")
}
if err := checkSize(r, sz, sizeCPUs, sz.cpus+2); err == nil {
t.Error("more vCPUs were allowed on a running machine with no hot-add")
}
if err := checkSize(r, sz, sizeMemory, sz.memoryMB*2); err == nil {
t.Error("more memory was allowed on a running machine with no hot-add")
}
// Off it goes, and with it the objection.
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot power the machine off: %v", err)
}
if err := refreshOne(s, &r); err != nil {
t.Fatalf("cannot re-read the machine: %v", err)
}
wantCPUs, wantMemory := sz.cpus+2, sz.memoryMB+1024
for _, c := range []struct {
kind sizeKind
want int32
}{{sizeCPUs, wantCPUs}, {sizeMemory, wantMemory}} {
// Read again between the two, so the second change is checked and
// described against what the first one left behind rather than against
// what the machine looked like before either.
sz, err = sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
msg, err := runResize(s, r, sz, c.kind, c.want)
if err != nil {
t.Fatalf("runResize(%v): %v", c.kind.what(), err)
}
if !strings.Contains(msg, "→") {
t.Errorf("the message does not say what changed: %q", msg)
}
}
after, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf after the change: %v", err)
}
if after.cpus != wantCPUs {
t.Errorf("the machine has %d vCPUs, want %d", after.cpus, wantCPUs)
}
if after.memoryMB != wantMemory {
t.Errorf("the machine has %d MB, want %d", after.memoryMB, wantMemory)
}
// And the change that would take memory away from it while it runs is still
// refused after it has been given some.
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOn
if err := checkSize(r, after, sizeMemory, after.memoryMB-1024); err == nil {
t.Error("memory was taken away from a running machine")
}
}
// A command line that asks for two changes does neither until both are known to
// be possible: setting the vCPUs and then refusing the memory would leave half
// of what somebody asked for, which is the outcome nobody wanted.
func TestSimResizeRefusesBothOrNeither(t *testing.T) {
quiet(t)
vc := simVCenter(t)
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
if _, err := runPower(s, r, opPowerOff); err != nil {
t.Fatalf("cannot power the machine off: %v", err)
}
before, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf: %v", err)
}
// The vCPU count is good and the memory is not a multiple of 4 MB. -y so
// that a confirmation cannot be what stops it: the refusal has to come from
// the check, before anything is sent.
err = sizeCLI(vc, "DC0_C0_RP0_VM1", Itoa(int(before.cpus+2)), "1026m", true)
if err == nil {
t.Fatal("a half-impossible command line was accepted")
}
if !strings.Contains(err.Error(), "multiples of 4 MB") {
t.Errorf("refused for the wrong reason: %v", err)
}
after, err := sizingOf(s, r.ref)
if err != nil {
t.Fatalf("sizingOf after the refusal: %v", err)
}
if after.cpus != before.cpus {
t.Errorf("the vCPUs were changed anyway: %d, was %d", after.cpus, before.cpus)
}
if after.memoryMB != before.memoryMB {
t.Errorf("the memory was changed anyway: %d, was %d", after.memoryMB, before.memoryMB)
}
}
// refreshOne re-reads the properties the sweep reads, for a row a test has just
// changed something about.
func refreshOne(s *session, r *vmRow) error {
var fresh mo.VirtualMachine
vm := object.NewVirtualMachine(s.client.Client, r.ref)
if err := vm.Properties(s.ctx, r.ref, sweepProps, &fresh); err != nil {
return err
}
r.vm = fresh
return nil
}
// The instance UUID is what the vSphere client's links are made of, and the one
// thing gvm cannot work out from the configuration.
func TestSimVsphereLinkFromASession(t *testing.T) {
+40 -6
View File
@@ -82,6 +82,21 @@ func snapNew(vc VCenter, vmname string) error {
func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string) error {
vm := object.NewVirtualMachine(s.client.Client, ref)
// Without memory and without quiescing, and both are deliberate.
//
// Memory would keep the running machine's RAM as well, so that a rollback
// came back mid-flight — at the price of writing the whole of it to the
// datastore every time, and of a rollback that restores a process tree
// along with the disks. What these snapshots are for is the moment before
// a patch or an upgrade, where coming back to a machine that boots is the
// point and coming back to one that is still half way through the thing
// that went wrong is not.
//
// Quiescing would have VMware Tools still the guest's filesystems first.
// Leaving it off makes the disk state crash-consistent — what a machine
// would find after the plug was pulled — which a journalling filesystem
// handles and a database may not. It also means the snapshot does not
// depend on Tools running, and does not stop when they are not.
task, err := vm.CreateSnapshot(s.ctx, name, desc, false, false)
if err != nil {
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
@@ -310,15 +325,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()
}
+33
View File
@@ -83,6 +83,8 @@ const (
keyShiftTab
keyEnter
keyCtrlA
keyCtrlE
keyCtrlL
keyCtrlO
keyCtrlR
keyCtrlS
@@ -133,11 +135,42 @@ func (kr *keyReader) next() key {
if !ok {
return key{special: keyCtrlC} // input closed - treat like cancel
}
return kr.decode(b)
}
// nextWithin is next() with a limit on how long it waits — for live mode, which
// has to be able to stop waiting and re-read the list.
//
// The limit is on the *first* byte only, which is why it is here and not around
// next() as a whole: a control sequence arrives in one burst, and a deadline
// that could expire in the middle of "ESC [ A" would turn one arrow key into an
// Esc and a stray letter in the filter.
func (kr *keyReader) nextWithin(d time.Duration) (key, bool) {
select {
case b, ok := <-kr.ch:
if !ok {
return key{special: keyCtrlC}, true
}
return kr.decode(b), true
case <-time.After(d):
return key{}, false
}
}
// decode turns one byte, and whatever else belongs with it, into a key.
func (kr *keyReader) decode(b byte) key {
switch b {
case 0x03:
return key{special: keyCtrlC}
case 0x01:
return key{special: keyCtrlA}
case 0x05:
return key{special: keyCtrlE}
// ^l, which in a shell redraws the screen. Nothing is lost by taking it:
// gvm redraws the whole screen on every keystroke anyway, so there is
// nothing here for a redraw key to fix.
case 0x0c:
return key{special: keyCtrlL}
case 0x0f:
return key{special: keyCtrlO}
case 0x12:
+1 -1
View File
@@ -1 +1 @@
1.1.7
1.3.7