Compare commits
@@ -15,3 +15,4 @@ bin/
|
||||
tmp/
|
||||
gvm
|
||||
.gvmrc
|
||||
.claude/
|
||||
|
||||
@@ -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,130 @@ 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.
|
||||
|
||||
`^l` is not the only way in: making a machine from a template turns it on, since
|
||||
a clone that has just been started is exactly the screen this is for, and says
|
||||
so in the same line that reports the machine.
|
||||
|
||||
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 +448,237 @@ 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. `gvm new` on the
|
||||
command line does wait: a script that gets its prompt back wants the machine to
|
||||
be there.
|
||||
|
||||
**And it goes to that column.** The machine's sheet — which is where the menu
|
||||
was opened and a page of facts about the *template* — is put away, the list
|
||||
comes back with the cursor still on the row the clone hangs off, and live mode
|
||||
turns itself on if it was off, so the percentage moves on its own and the line
|
||||
under the table announces the new machine when it exists. `^l` ends it again.
|
||||
Naming the key that would make an invisible figure move is three steps of
|
||||
housekeeping after the one decision that mattered.
|
||||
|
||||
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
|
||||
|
||||
The cursor starts on gvm's own road wherever the site is set up: a domain, a
|
||||
netmask and a gateway written into `~/.gvmrc` are written down so that machines
|
||||
get them, and the answer wanted nearly every time should not be the one that has
|
||||
to be arrowed to. Where the site is not set up it starts at the top instead, on
|
||||
the answer that is never wrong. Nothing is decided by where it sits — the picker
|
||||
picks on Enter, and the confirmation still says in full what will happen.
|
||||
|
||||
Then one line for the address, which says in words what leaving it empty would
|
||||
do:
|
||||
|
||||
address for web05, empty for DHCP:
|
||||
address for web05, 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, and it is
|
||||
already standing in the line — a site with a tool hands addresses out from it,
|
||||
so that is the answer Enter gives:
|
||||
|
||||
address for web05, "auto" for one from dns, empty for DHCP: auto
|
||||
|
||||
It is offered rather than made the meaning of the empty line, so every answer
|
||||
keeps the meaning it had: erased back to nothing it is DHCP again, or whatever
|
||||
the chosen specification says. And Enter commits nothing — an address fetched
|
||||
here is handed straight back when the deployment is not confirmed.
|
||||
|
||||
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 — live on, 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 +744,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 +833,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 +1143,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
|
||||
|
||||
+487
-37
@@ -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,48 @@ 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.chooseAt(title, r, lines, 0, chosen)
|
||||
}
|
||||
|
||||
// chooseAt is the same with the cursor starting somewhere other than the top,
|
||||
// for a list whose ordinary answer is not its first line. Where the cursor
|
||||
// starts is the only default a picker has — nothing is picked until Enter — so
|
||||
// it belongs on the line somebody nearly always wants, and the others are one
|
||||
// key away.
|
||||
func (b *browser) chooseAt(title string, r vmRow, lines []string, at int, chosen func(int)) {
|
||||
if at < 0 || at >= len(lines) {
|
||||
at = 0
|
||||
}
|
||||
b.pick = &picker{title: title, row: r, lines: lines, chosen: chosen, sel: at}
|
||||
}
|
||||
|
||||
func (b *browser) closePicker() { b.pick = nil }
|
||||
@@ -285,24 +397,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 +528,355 @@ 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. The machine itself turns up in the list when it exists,
|
||||
// which live mode announces.
|
||||
//
|
||||
// Which is why the end of it is a screen and not only a sentence: the sheet
|
||||
// goes away, the list comes back with the cursor still on the template, and
|
||||
// live mode turns itself on. Telling somebody that a figure they cannot see
|
||||
// exists, and which key would make it move, is three steps of housekeeping
|
||||
// after the one decision that mattered.
|
||||
//
|
||||
// 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, kinds, at := customChoices(b.site, specs, own)
|
||||
|
||||
b.chooseAt(SF("customise %s how", name), r, lines, at, 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})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// customChoices is what the customisation picker offers, what each line means,
|
||||
// and where its cursor starts.
|
||||
//
|
||||
// kinds is the meaning, carried beside the lines rather than worked out from an
|
||||
// index: "" leaves the guest alone, "-" is the specification gvm writes from
|
||||
// ~/.gvmrc, and anything else is the name of one the vCenter holds.
|
||||
//
|
||||
// The cursor starts on gvm's own road where the site is set up. A site whose
|
||||
// domain, netmask and gateway have been written down is a site whose machines
|
||||
// are meant to be given them — that is what writing them down was for — and the
|
||||
// answer wanted nearly every time should not be the one that has to be arrowed
|
||||
// to. Where there is no such road it starts at the top, on the answer that is
|
||||
// never wrong: leave the guest as the template made it.
|
||||
//
|
||||
// Nothing is decided by the cursor sitting there. The picker chooses on Enter,
|
||||
// the address is asked for after it, and the confirmation still says in full
|
||||
// what will happen.
|
||||
func customChoices(st site, specs []string, own bool) (lines, kinds []string, at int) {
|
||||
lines = []string{"leave the guest as the template made it"}
|
||||
kinds = []string{""}
|
||||
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", st.domain, st.gateway))
|
||||
kinds = append(kinds, "-")
|
||||
at = len(lines) - 1
|
||||
}
|
||||
for _, name := range specs {
|
||||
lines = append(lines, name)
|
||||
kinds = append(kinds, name)
|
||||
}
|
||||
return lines, kinds, at
|
||||
}
|
||||
|
||||
// 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. Where the site has a tool of its own, the ordinary case is the
|
||||
// other one — so "auto" is in the line when the question appears, and Enter
|
||||
// takes it.
|
||||
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)
|
||||
|
||||
// And where there is a tool, its word is standing in the line already, so
|
||||
// Enter is the whole answer. That is what the tool is configured for: a
|
||||
// site that has one hands out addresses from it, and typing "auto" every
|
||||
// time is asking somebody to confirm the ordinary case by hand.
|
||||
//
|
||||
// Offered rather than made the meaning of the empty line, so that every
|
||||
// answer keeps the meaning it had: erased back to nothing it is DHCP again,
|
||||
// or whatever the specification says. Nor does pressing Enter commit
|
||||
// anything — an address fetched here is handed straight back where the
|
||||
// deployment is not confirmed (deployStep.giveBack).
|
||||
preset := ""
|
||||
if opts.dns.there() {
|
||||
ask = SF("address for %s, %q for one from dns, %s: ", name, autoIP, leave)
|
||||
preset = autoIP
|
||||
}
|
||||
|
||||
ip, ok := b.inputWith(ask, preset)
|
||||
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
|
||||
}
|
||||
// And then the screen it can be watched on. This was started from the
|
||||
// template's sheet — a page of facts about the machine being copied, which
|
||||
// is not what anybody is here for now. The list is: the clone's progress
|
||||
// stands in the TASK column of the row the cursor is already on, the new
|
||||
// machine turns up there when it exists, and the line under the table says
|
||||
// so. Live mode goes with it, or the percentage would sit where it was
|
||||
// until somebody pressed a key.
|
||||
//
|
||||
// Decided before done, which is what puts the task on the row, and applied
|
||||
// after it, so that the interval it schedules is the busy one.
|
||||
watching := ""
|
||||
if !b.live {
|
||||
watching = "live on, "
|
||||
}
|
||||
b.closeDetail()
|
||||
|
||||
// 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%s shows how far along it is", name, at, watching, src.name))
|
||||
b.watchLive()
|
||||
}
|
||||
|
||||
// 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 +1058,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 +1068,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 +1075,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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
@@ -945,13 +1012,7 @@ func (b *browser) detailKey(k key) {
|
||||
|
||||
switch k.special {
|
||||
case keyEsc, keyEnter, keyBackspace, keyLeft:
|
||||
b.detail, b.dscroll = nil, 0
|
||||
// The events go with the visit, not with the machine: coming back to a
|
||||
// sheet half an hour later and finding half-hour-old events under a
|
||||
// label that says nothing about when they were read would be the one
|
||||
// stale thing on an otherwise freshly read screen. They are one
|
||||
// keystroke away again.
|
||||
b.events, b.eventsOf = nil, ""
|
||||
b.closeDetail()
|
||||
b.setStatus("", "")
|
||||
case keyCtrlA:
|
||||
b.openMenu()
|
||||
@@ -1060,11 +1121,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 +1165,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 {
|
||||
@@ -1224,8 +1300,18 @@ func (e *editor) key(k key) (finished, accepted bool) {
|
||||
// input reads one line in the status area and reports whether it was finished
|
||||
// rather than abandoned. It has its own key loop, so nothing that is typed here
|
||||
// reaches the filter.
|
||||
func (b *browser) input(label string) (string, bool) {
|
||||
b.edit = &editor{label: label}
|
||||
func (b *browser) input(label string) (string, bool) { return b.inputWith(label, "") }
|
||||
|
||||
// inputWith is the same with an answer already standing in the line, for a
|
||||
// question that has one ordinary answer worth offering: Enter takes it, and
|
||||
// Backspace is how it is refused. It is a default one can see — which is the
|
||||
// only kind worth having, since a question whose answer is decided by what was
|
||||
// not typed is one nobody knows they have agreed to.
|
||||
//
|
||||
// The cursor sits at the end of it, so typing carries on from the answer rather
|
||||
// than into the middle of it.
|
||||
func (b *browser) inputWith(label, preset string) (string, bool) {
|
||||
b.edit = &editor{label: label, runes: []rune(preset), cursor: len([]rune(preset))}
|
||||
defer func() { b.edit = nil }()
|
||||
|
||||
for {
|
||||
@@ -1250,17 +1336,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 +1424,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 +1701,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
|
||||
@@ -1609,6 +1721,19 @@ func (b *browser) openDetail() {
|
||||
b.dscroll = 0
|
||||
}
|
||||
|
||||
// closeDetail puts the sheet away and leaves the list underneath it. It is what
|
||||
// Esc does, and also what an action does when what happens next is to be
|
||||
// watched in the table rather than read on one machine's page (actions.go).
|
||||
//
|
||||
// The events go with the visit, not with the machine: coming back to a sheet
|
||||
// half an hour later and finding half-hour-old events under a label that says
|
||||
// nothing about when they were read would be the one stale thing on an
|
||||
// otherwise freshly read screen. They are one keystroke away again.
|
||||
func (b *browser) closeDetail() {
|
||||
b.detail, b.dscroll = nil, 0
|
||||
b.events, b.eventsOf = nil, ""
|
||||
}
|
||||
|
||||
// eventSheet is the history as sheet lines: the label on the first, each line in
|
||||
// the colour of its own severity. It is appended to the sheet rather than built
|
||||
// into vmDetail because vmDetail asks nothing of the network and this is the one
|
||||
|
||||
+88
-1
@@ -316,6 +316,93 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the sheet leaves nothing of the visit behind. The events are the part
|
||||
// that would keep: they are read once, on request, and a sheet opened again
|
||||
// half an hour later must not show them under a label that says nothing about
|
||||
// when they were read. Esc does this, and so does a deployment, which puts the
|
||||
// sheet away to leave the list watching the clone (actions.go).
|
||||
func TestClosingTheSheetForgetsTheVisit(t *testing.T) {
|
||||
b := testBrowser("ubuntu-tpl", "web01")
|
||||
b.applySort()
|
||||
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
|
||||
b.eventsOf = b.current().id()
|
||||
b.openDetail()
|
||||
b.dscroll = 4
|
||||
|
||||
b.closeDetail()
|
||||
|
||||
if b.detail != nil || b.dscroll != 0 {
|
||||
t.Errorf("the sheet is still there: %d lines, scrolled to %d", len(b.detail), b.dscroll)
|
||||
}
|
||||
if b.events != nil || b.eventsOf != "" {
|
||||
t.Errorf("the events of %q were kept: %v", b.eventsOf, b.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSheetHasTheParameters(t *testing.T) {
|
||||
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
|
||||
|
||||
@@ -1463,7 +1550,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
@@ -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"}
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+706
@@ -0,0 +1,706 @@
|
||||
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 picker opens on the road gvm writes itself where the site has been set
|
||||
// up: a domain, a netmask and a gateway in ~/.gvmrc are there so that machines
|
||||
// get them, and the answer wanted nearly every time must not be the one that
|
||||
// has to be arrowed to. Where there is no such road it opens at the top, on the
|
||||
// answer that is never wrong.
|
||||
func TestTheCustomisationPickerOpensOnTheSiteRoad(t *testing.T) {
|
||||
st := testSite()
|
||||
|
||||
lines, kinds, at := customChoices(st, []string{"linux-static", "windows"}, true)
|
||||
if len(lines) != 4 || len(kinds) != len(lines) {
|
||||
t.Fatalf("the picker offers %d lines and %d meanings: %v", len(lines), len(kinds), lines)
|
||||
}
|
||||
if kinds[at] != "-" {
|
||||
t.Errorf("it opens on %q, which is %q — want gvm's own road", lines[at], kinds[at])
|
||||
}
|
||||
// And that line says what it will do, in the site's own words: a default
|
||||
// nobody can read is one nobody knows they are taking.
|
||||
if !strings.Contains(lines[at], st.domain) || !strings.Contains(lines[at], st.gateway) {
|
||||
t.Errorf("the line it opens on is %q, want the domain and the gateway in it", lines[at])
|
||||
}
|
||||
// The vCenter's own are still there, after it and in order.
|
||||
if lines[2] != "linux-static" || lines[3] != "windows" {
|
||||
t.Errorf("the vCenter's specifications came out as %v", lines[2:])
|
||||
}
|
||||
|
||||
// No site, and the cursor is at the top rather than on somebody else's
|
||||
// specification: leaving the guest as the template made it is the one
|
||||
// answer that cannot be wrong.
|
||||
lines, kinds, at = customChoices(site{}, []string{"linux-static"}, false)
|
||||
if at != 0 || kinds[at] != "" {
|
||||
t.Errorf("with no site it opens on %q (%q)", lines[at], kinds[at])
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("with no site the picker offers %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
tool, _ := fakeInfoblox(t) // for its there(), not for its answers
|
||||
|
||||
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"},
|
||||
// With an address tool, the answer it would give is standing in the
|
||||
// line already: that is what the tool is there for, and the question
|
||||
// still has to say what the other two answers mean.
|
||||
{"a site with an address tool",
|
||||
deployOpts{how: customSite, st: testSite(), dns: tool},
|
||||
`"auto" for one from dns, empty for DHCP: auto`},
|
||||
} {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
// 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. Starting a clone
|
||||
// turns it on as well (watchLive), because that is a screen made to be watched
|
||||
// whether or not anybody thought to ask for it first.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
// watchLive turns live mode on because something with a figure to watch was
|
||||
// just set going, and reports whether it had to. ^l is somebody asking for a
|
||||
// screen that keeps itself up to date; this is gvm deciding that a clone it has
|
||||
// just started makes the list one of those, since the progress in the TASK
|
||||
// column is the whole reason to still be looking at it. It stays on afterwards,
|
||||
// like the mode it is — ^l ends it, and the title says it is running.
|
||||
//
|
||||
// Unlike toggleLive it does not sweep at once. The caller has just re-read the
|
||||
// row it acted on, so the first tick belongs one interval away rather than now
|
||||
// — and that interval is already the busy one, because that row is the one
|
||||
// carrying the task.
|
||||
func (b *browser) watchLive() bool {
|
||||
if b.live {
|
||||
return false
|
||||
}
|
||||
b.live = true
|
||||
b.liveGap = b.liveInterval()
|
||||
b.liveNext = time.Now().Add(b.liveGap)
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// A clone that has just been started makes the list a screen worth watching, so
|
||||
// live mode turns itself on rather than leaving a percentage that moves only
|
||||
// when somebody remembers ^l. The first tick is an interval away rather than
|
||||
// now — the action re-read its own row a moment ago — and that interval is the
|
||||
// busy one, because the row it re-read is the one carrying the task.
|
||||
func TestStartingAClonePutsTheListOnWatch(t *testing.T) {
|
||||
b := testBrowser("ubuntu-tpl", "web01")
|
||||
b.rows[0].task = &runningTask{what: "clone", progress: 2}
|
||||
|
||||
if !b.watchLive() {
|
||||
t.Fatal("watchLive did not report that it had to turn live mode on")
|
||||
}
|
||||
if !b.live {
|
||||
t.Fatal("live mode is off after a clone was started")
|
||||
}
|
||||
if d := b.liveIn(); d > liveBusy || d < liveBusy/2 {
|
||||
t.Errorf("the first refresh is %s away, want about %s", d, liveBusy)
|
||||
}
|
||||
|
||||
// Already on is not a reason to touch it: a tick that was due in half a
|
||||
// second must not be pushed back to two because a second clone was started.
|
||||
b.liveNext = time.Now().Add(liveBusy / 4)
|
||||
due := b.liveNext
|
||||
if b.watchLive() {
|
||||
t.Error("watchLive reported turning on a mode that was already on")
|
||||
}
|
||||
if !b.liveNext.Equal(due) {
|
||||
t.Errorf("the pending tick moved from %s to %s", due, b.liveNext)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.1.7
|
||||
1.3.10
|
||||
|
||||
Reference in New Issue
Block a user