Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269bce195d | ||
|
|
313c9ed880 | ||
|
|
d5450d5f46 | ||
|
|
0b0412bd56 | ||
|
|
89b0029340 | ||
|
|
97d8d4191d | ||
|
|
606dc38411 | ||
|
|
db2f01908e | ||
|
|
fba15b7897 | ||
|
|
4a5477bde7 | ||
|
|
137a799399 | ||
|
|
b902059402 | ||
|
|
dda9dc1e74 |
@@ -15,3 +15,4 @@ bin/
|
||||
tmp/
|
||||
gvm
|
||||
.gvmrc
|
||||
.claude/
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
# gvm — VMware command line helper
|
||||
|
||||
A small command line tool for the VMware vCenters: list the virtual machines of
|
||||
all of them at once, take and remove snapshots, look at what the ESXi hosts are
|
||||
doing, and mail the vCenter event log.
|
||||
all of them at once, take and remove snapshots, look at what the ESXi hosts and
|
||||
the datastores are doing, report the snapshots nobody came back for, and mail
|
||||
the vCenter event log.
|
||||
|
||||
gvm # interactive list of every machine, everywhere
|
||||
gvm vm # the same thing, spelled out
|
||||
gvm vm -l # the same as plain output
|
||||
gvm vm -l -m web # only those whose name matches "web"
|
||||
gvm vm -l --issues # only the machines with something wrong
|
||||
gvm vm -l --json # the same listing as a document
|
||||
gvm -v v108 snap -l myvm # the snapshots of myvm on v108
|
||||
gvm -v v108 snap -n myvm # take one
|
||||
gvm snap --old # every snapshot older than 30 days, everywhere
|
||||
gvm -v v108 power -s myvm # ask its guest to shut down
|
||||
gvm host # cpu, memory and machine counts per host
|
||||
gvm ds # capacity, free space and over-commitment
|
||||
gvm log -l # the last hour of events
|
||||
gvm config # what gvm made of ~/.gvmrc
|
||||
|
||||
@@ -31,8 +36,11 @@ template there and says so — fill in the passwords and it works.
|
||||
vcenter.v308.insecure = true
|
||||
|
||||
`-v <name>` picks a server by its full name; an unknown name is an error rather
|
||||
than a silent fallback to the first one in the list. Every setting has an
|
||||
environment spelling that wins over the file — `GVM_VCENTER_V308_PASSWORD`,
|
||||
than a silent fallback to the first one in the list. The commands that sweep
|
||||
every server — `gvm`, `gvm vm -l`, `gvm snap --old` — also take a list,
|
||||
`-v v308,v108`, in the order given and with a repeat counted once; the commands
|
||||
that act on one machine refuse a list rather than taking the first of it. Every
|
||||
setting has an environment spelling that wins over the file — `GVM_VCENTER_V308_PASSWORD`,
|
||||
`GVM_MAILTO`, `GVM_DEFAULT` and so on — which is how to run gvm from cron
|
||||
without the password living in a file.
|
||||
|
||||
@@ -76,8 +84,11 @@ not.
|
||||
| *(nothing)* | browse the machines interactively (see below) |
|
||||
| `vm` | the same, spelled out |
|
||||
| `vm -l [-m <re>] [--sort <order>] [--reverse]` | print them instead; all vCenters unless `-v` names one |
|
||||
| `vm -l --issues` | only the machines with something wrong with them |
|
||||
| `vm -l --json` | the same listing as a JSON document |
|
||||
| `snap -l <vm>` | list a machine's snapshots |
|
||||
| `snap -n <vm>` | take a snapshot, name printed |
|
||||
| `snap --old [-d <days>] [-m]` | every snapshot older than that, on every vCenter, optionally by mail |
|
||||
| `snap -r <vm> -s <snap>` | remove one snapshot |
|
||||
| `snap --revert <vm> -s <snap>` | put the machine back to that snapshot |
|
||||
| `snap --removeall <vm>` | remove all of them |
|
||||
@@ -86,11 +97,15 @@ not.
|
||||
| `power -b <vm>` | ask the guest to reboot (needs VMware Tools) |
|
||||
| `power --off <vm>` | power off at the hypervisor — hard |
|
||||
| `power --reset <vm>` | reset at the hypervisor — hard |
|
||||
| `size --vm <vm>` | what it has: vCPUs, memory, and what may be changed while it runs |
|
||||
| `size --vm <vm> -c 8 -m 16` | give it 8 vCPUs and 16 GB — `-m 512m` for megabytes |
|
||||
| `host [-t]` | per-host cpu, memory, machine counts; `-t` also posts them |
|
||||
| `host -c` | just the machine counts |
|
||||
| `ds [-t]` | per-datastore capacity, free space, over-commitment; `-t` also posts them |
|
||||
| `log -l [-m] [-t <min>]` | the event log, optionally by mail, default 60 minutes |
|
||||
| `config` | the effective configuration, passwords not shown |
|
||||
| `config -p <vcenter>` | ask for a password and store it sealed |
|
||||
| `completion zsh\|bash` | print the shell completion script |
|
||||
|
||||
## The interactive list
|
||||
|
||||
@@ -103,10 +118,13 @@ help, as does anything gvm does not recognise:
|
||||
line, so a name, an address, a host or "off" all work, and
|
||||
the hit is picked out in the row
|
||||
↑ ↓ PgUp PgDn move, Home/End for the ends
|
||||
enter the machine's parameters: power, host, guest and tools,
|
||||
cpu and memory in use, uptime, storage, guest filesystems,
|
||||
network adapters, snapshots, uuid and moref
|
||||
enter the machine's parameters: what is wrong with it and what is
|
||||
being done to it, power, host, guest and tools, cpu and
|
||||
memory in use, uptime, storage, guest filesystems, network
|
||||
adapters, snapshots, uuid and moref
|
||||
↑ ↓ in there scroll the sheet, esc/enter back to the list
|
||||
e h y w in the sheet: recent events, ssh to the guest, copy what
|
||||
ssh would connect to, open it in the vSphere client
|
||||
|
||||
The sheet is one line per thing worth knowing, values that belong together
|
||||
joined with a middle dot and no section headings — an ordinary machine fits a
|
||||
@@ -116,29 +134,177 @@ onto a continuation line under its own label rather than cut off at the edge,
|
||||
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
|
||||
|
||||
The columns are name, vCenter, power, address, host, vCPUs, CPU load, memory and
|
||||
memory in use, and the guest's operating system. The two load figures are
|
||||
percentages of what the machine is allowed to use and of what it has configured;
|
||||
a machine that is not running has no load rather than a load of zero and shows a
|
||||
dash. Past 75 % they turn yellow, past 90 % red.
|
||||
The columns are name, vCenter, power, snapshots, address, host, vCPUs, CPU load,
|
||||
memory and memory in use, and the guest's operating system. The two load figures
|
||||
are percentages of what the machine is allowed to use and of what it has
|
||||
configured; a machine that is not running has no load rather than a load of zero
|
||||
and shows a dash. Past 75 % they turn yellow, past 90 % red.
|
||||
|
||||
The snapshot column is how many the machine is dragging along, aged by colour:
|
||||
past a week the count turns yellow, past a month red — a month being also what
|
||||
`snap --old` reports on, so a red count means "this machine is in that report".
|
||||
A machine with none shows a dash. The count is what the column says and the age
|
||||
is only how it is said, which is why the sheet spells the date out: a colour
|
||||
cannot be read in a pipe.
|
||||
|
||||
Two columns are not always there, because they hold an exception rather than a
|
||||
property, and thirteen characters of blank down two hundred rows is thirteen
|
||||
characters spent on nothing:
|
||||
|
||||
* **TASK** appears while vCenter is doing something to any machine in the list —
|
||||
a clone, a migration, a consolidation, with its progress — and is gone again
|
||||
when nothing is going on. A column that turns up because somebody started a
|
||||
clone is not the layout shifting about: it is the news.
|
||||
* **WHY** takes the guest operating system's place in the issues list (`^w`,
|
||||
`--issues`), where every row has a reason to be there — and the four figures
|
||||
go with it, so that the reason has the width. It is the last column that
|
||||
listing gives up rather than the first: in a list whose every row is there
|
||||
because of it, dropping the reason first leaves a list of machines with no
|
||||
reason showing on any of them.
|
||||
|
||||
A terminal too narrow for all of that gives columns up, least useful first: the
|
||||
guest's operating system, then the host, then the address, then the vCPU count —
|
||||
so what survives longest is what a glance is for. Each step only ever takes a
|
||||
column away, never brings one back, so dragging a window narrower does not
|
||||
rearrange the table. An eighty-column terminal keeps everything but the operating
|
||||
system and the host.
|
||||
guest's operating system, then the host, then the snapshot count, then the
|
||||
address, then the vCPU count — so what survives longest is what a glance is for.
|
||||
Each step only ever takes a column away, never brings one back, so dragging a
|
||||
window narrower does not rearrange the table. An eighty-column terminal keeps
|
||||
everything but the operating system, the host and the snapshot count: the name
|
||||
column's minimum is one notch narrower than it reads in order to buy the address
|
||||
its place there, and the count is the one column here that has somewhere else to
|
||||
be said — `^w`, `--issues` and `snap --old` all name it and date it.
|
||||
|
||||
### The estate
|
||||
|
||||
`^e` answers the question the machine list cannot: **where is there still room.**
|
||||
Every host of every server that answered, grouped under its cluster, with what
|
||||
it carries set against what it has:
|
||||
|
||||
Estate v308, v309 14 hosts · 212 machines
|
||||
|
||||
CLUSTER / HOST ON/VM vCPU x MEM ALLOC x CPU LOAD % MEM USED %
|
||||
v308 · prod 58/61 196/96 2.0x 1.1TB/768.0GB 1.5x
|
||||
▸ esx01 21/22 72/32 2.2x 384.0GB/256.0GB 1.5x ████······ 41 ███████··· 72
|
||||
esx02 19/20 68/32 2.1x 360.0GB/256.0GB 1.4x ███······· 32 ██████···· 64
|
||||
esx03 18/19 56/32 1.7x 376.0GB/256.0GB 1.4x █████····· 53 ████████·· 81
|
||||
v308 · standalone 4/4 8/16 32.0GB/128.0GB ██········ 12 ██········ 21
|
||||
|
||||
Two kinds of number, and the difference between them is the whole point — which
|
||||
is also what decides what a narrow terminal gives up. Three layouts, each the
|
||||
widest that fits whole: everything with the load drawn as bars; the same figures
|
||||
with the bars down to their percentages; and, narrower still, only the two
|
||||
ratios and the two percentages. Both kinds of number survive all three, because
|
||||
one of them alone says nothing.
|
||||
|
||||
**What is allocated** — every vCPU and every megabyte the machines on a host have
|
||||
been promised, added up. It routinely exceeds the host and is meant to: the ratio
|
||||
is the figure worth having, because 1.5x of memory is a decision somebody made
|
||||
and 8.0x is one somebody forgot. It is left blank where a host has room to
|
||||
spare, since a column of "0.4x" down a screen of healthy hosts is noise where
|
||||
the point is to spot the one that is over.
|
||||
|
||||
**What is in use** — what the host itself reports it is doing, as a bar and a
|
||||
percentage. A host at 2.2x allocation and 41 % load is fine; the same host at
|
||||
90 % is not, and no allocation figure can tell those two apart. A load that is
|
||||
not known draws nothing rather than an empty trough: a host at one per cent
|
||||
fills none of the bar either, and "almost idle" must not look like "I cannot see
|
||||
this host".
|
||||
|
||||
A host vCenter has lost touch with keeps its hardware figures — they do not
|
||||
depend on reaching it — but reports no load at all rather than the nought its
|
||||
cached statistics would give: an empty bar is what an idle host looks like.
|
||||
|
||||
Only running machines are charged to a host. A machine that is switched off has
|
||||
been promised nothing it is using, and counting it would make a host of parked
|
||||
machines look full when the whole point of parking them there was that it is
|
||||
not — they are still in the ON/VM count, which is where that belongs.
|
||||
|
||||
`⏎` on a host is the other half: it goes back to the machine list with the filter
|
||||
set to that host, so the answer to "what is on this one" is the table everybody
|
||||
already knows how to read, and `Esc` undoes it. The count it reports is the
|
||||
host's own, not the filtered list's: the filter is a text match over the whole
|
||||
row and carries no server, so it can also catch another vCenter's host of the
|
||||
same name or a machine named after a host, and a count taken from it would then
|
||||
contradict the screen it came from. `^r` reads the screen again —
|
||||
live mode deliberately does not tick here, because this screen reads the hosts
|
||||
itself and a timer doing that every ten seconds would be paying for a screen
|
||||
somebody is reading rather than watching.
|
||||
|
||||
The allocations are added up from the rows the list already holds, so no machine
|
||||
is read twice for this, and they are matched to hosts by reference rather than by
|
||||
name — the same lesson `gvm host` carries a comment about, where a host added by
|
||||
address and renamed later reported zero machines while running dozens.
|
||||
|
||||
### Live
|
||||
|
||||
`^l` and the list stops being a snapshot. It re-reads itself every ten seconds —
|
||||
every two while vCenter is doing anything at all, so a clone's progress is
|
||||
something one watches finish rather than a figure one waits for — and the title
|
||||
says so, because a screen that moves on its own with nothing to explain it reads
|
||||
as a fault.
|
||||
|
||||
It refreshes over the connections that are already open. `^r` logs in again,
|
||||
which is how a session that has died is recovered; doing that every ten seconds
|
||||
would be three logins a minute for nothing.
|
||||
|
||||
Two things come with it.
|
||||
|
||||
**The line under the table says what just changed.** A table shows what is; this
|
||||
is the only thing on the screen that says what *became*:
|
||||
|
||||
db01 off · web01 +1 snapshot · app07 clone · esx03: disks need consolidating
|
||||
|
||||
Power states, snapshots appearing and going, tasks starting and finishing,
|
||||
machines arriving and leaving, and vCenter starting to complain. Four of them and
|
||||
a count of the rest, because a line that has to be read carefully is a line
|
||||
nobody reads. It is the reason to leave the thing open.
|
||||
|
||||
Where two vCenters hold a machine of the same name the server goes in front of
|
||||
it — "v309 web01 off" — and only there: a name is not what makes a machine that
|
||||
machine, and the width is worth spending on the rare case rather than on every
|
||||
line. A quiet refresh clears that line and nothing else: a message somebody
|
||||
produced by pressing a key is theirs to keep.
|
||||
|
||||
**The `CPU~` column is where each machine has just been.** Six sweeps of its
|
||||
processor load, one character each:
|
||||
|
||||
NAME PWR CPU CPU% CPU~ MEM
|
||||
app07 on 4 18 ▃▅█▆▄▂ 8.0GB
|
||||
web01 on 4 82 ▄▂▁▃▅█ 8.0GB
|
||||
|
||||
The scale is fixed at 0 to 100 and never fitted to the samples: a line that
|
||||
scales itself would make a machine idling between 1 and 2 per cent look exactly
|
||||
like one swinging between 40 and 80. The column is there only once there is
|
||||
something in it — a machine has a history after its second sweep — and it is the
|
||||
first thing after the guest's operating system that a narrow terminal gives up:
|
||||
everything else in the table is a fact about a machine, and this is a shape.
|
||||
|
||||
A machine that is not running has no load rather than a load of zero, so nothing
|
||||
is sampled from it and it draws nothing — and what it had drawn before goes with
|
||||
it, rather than leaving a busy shape beside a CPU% of "-".
|
||||
|
||||
The refresh holds still for every screen that asks something: a menu decides what
|
||||
it offers from the state it was drawn with, the picker holds a list being chosen
|
||||
from, and a half-typed name or a confirmation is an answer in progress. Being ten
|
||||
seconds out of date is better than any of those moving under a hand. A machine's
|
||||
sheet *is* refreshed, at the line it was being read at — watching one machine's
|
||||
memory is a reason to have it open.
|
||||
|
||||
Nothing here acts on a machine. A refresh that could start or stop something
|
||||
would be a timer with the power to do it, and that is the one thing a screen left
|
||||
open unattended must not have.
|
||||
|
||||
### Sorting
|
||||
|
||||
`^o` puts a legend on the status line and the next key picks the order, so the
|
||||
list stays on screen while it rearranges itself:
|
||||
|
||||
sort: n·name p·power c·cpu% m·mem% s·size u·cpus v·vc h·host a·ip r·reverse
|
||||
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why s·size u·cpus v·vc h·host a·ip r·reverse
|
||||
|
||||
Each order comes with its own direction, because that is what asking for it
|
||||
means: by name is a to z, by processor load is the busiest first. `r` reverses
|
||||
@@ -153,6 +319,35 @@ direction the order runs. Machines that compare equal stay in name order, so
|
||||
flipping the direction on a screen full of identical figures does not reshuffle
|
||||
them.
|
||||
|
||||
Thirteen choices are ninety-three columns, so a terminal narrower than that
|
||||
gets them on two lines instead of one that runs off the right-hand edge, hiding
|
||||
the very choices the legend exists to offer. They break where the meaning
|
||||
breaks — what the machine is doing and what it wants doing to it, then what it
|
||||
is made of and where it lives — and the second line takes the help line's row,
|
||||
which describes keys that do nothing while a menu is waiting for one. Decided
|
||||
at render time, so a window dragged wider gets the one line back:
|
||||
|
||||
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why
|
||||
s·size u·cpus v·vc h·host a·ip r·reverse
|
||||
|
||||
Three of them are about the two columns that are new:
|
||||
|
||||
* `z` is by how many snapshots the machine is carrying, most first. Nought is a
|
||||
figure here and not a missing one — nothing to clean up is a fact about the
|
||||
machine — so a machine with none sorts where nought belongs: at the bottom
|
||||
going down, at the top coming back up. The letter carries no mnemonic because
|
||||
every letter that does was taken; `--sort snaps` spells it out.
|
||||
* `o` is by the age of the machine's *oldest* snapshot, oldest first, which is
|
||||
the order the housekeeping is done in — a different question from `#`, and the
|
||||
more useful one: one snapshot from March wants attention before six from this
|
||||
morning. A machine with no snapshots has no age and sorts to the bottom either
|
||||
way round, the same as a stopped machine's load does.
|
||||
* `w` is by what is wrong with the machine: broken above wants-a-look above
|
||||
nothing to report, and within each the machine with the most to answer for
|
||||
first. Sorting the reasons as text would put "alarm" above "disks need
|
||||
consolidating" and mean nothing at all. Run the other way up it is the
|
||||
machines that are fine, by name — a listing worth having too.
|
||||
|
||||
The order survives `^r`, and the selection follows the machine it was on. `gvm vm
|
||||
-l --sort cpu% --reverse` takes the same orders by letter or by name.
|
||||
|
||||
@@ -161,6 +356,54 @@ v38 unreachable` — for as long as the list is open, and the reason is on the
|
||||
status line when it opens. Only the servers whose machines are actually there are
|
||||
named as holding them.
|
||||
|
||||
### The machines that want looking at
|
||||
|
||||
A list of two hundred machines is read by running the eye down it, which is
|
||||
exactly the wrong way to find the three that are broken. `^w` narrows it to
|
||||
those, and each one carries the reason in place of its guest operating system:
|
||||
|
||||
NAME VC PWR SNAP IP HOST WHY
|
||||
old01 v108 on 1 10.0.0.31 esx02 /var 97 % full · no VMware Tools
|
||||
db01 v308 on 3 10.0.0.12 esx01 disks need consolidating · snapshot base is 63 days old
|
||||
win7 v38 on - - esx07 vCenter says yellow
|
||||
|
||||
The four figures — vCPUs, processor load, memory and memory in use — are not
|
||||
there. A machine is in this list because something is wrong with it, and how
|
||||
hard its processors happen to be working at this second says nothing about any
|
||||
of the reasons: they would be four columns of arithmetic between the machine's
|
||||
name and the answer to the question that was asked. They are one keystroke away
|
||||
in the ordinary list, and on the machine's own sheet.
|
||||
|
||||
Nothing new is asked of the servers: this is a filter over the sweep that is
|
||||
already on screen, so it costs a keystroke and no waiting. `^w` again gives the
|
||||
whole list back, the typed filter still applies inside it — `^w web` is the
|
||||
broken web servers — and the title says `issues only` for as long as it is on,
|
||||
because a filtered list that does not say so is a lie told by omission. `^o w`
|
||||
puts the worst of them at the top.
|
||||
|
||||
What counts as an issue is deliberately narrow, because a list that cries wolf
|
||||
is one nobody opens:
|
||||
|
||||
| reason | |
|
||||
| --- | --- |
|
||||
| disconnected, orphaned, inaccessible | vCenter cannot see the machine properly |
|
||||
| waiting for an answer in vCenter | a question nobody has answered; the machine is stopped until somebody does |
|
||||
| disks need consolidating | deltas left behind by a snapshot removal that did not finish, growing quietly |
|
||||
| alarm: *name* | what vCenter itself is complaining about, by the name somebody gave the alarm |
|
||||
| vCenter says red / yellow | the rolled-up status, when no alarm came with it to explain it |
|
||||
| no VMware Tools | and only while the machine is running |
|
||||
| */var* 97 % full | a guest filesystem past 90 %, named with the figure |
|
||||
| snapshot *name* is 63 days old | past a month, which is where the table's red begins |
|
||||
|
||||
Broken is red and wants-a-look is yellow, worst first — which matters because
|
||||
the column is truncated from the right. An alarm somebody has acknowledged is
|
||||
one a person has dealt with already and is not reported; a machine that is
|
||||
switched off is not a fault; and the things that are only true of a running
|
||||
machine are not held against a stopped one.
|
||||
|
||||
The same list prints: `gvm vm -l --issues`, which is the morning's glance and
|
||||
the one worth a cron job.
|
||||
|
||||
Nothing acts on a machine from the table. Everything that changes one lives in
|
||||
the machine's own sheet, which `⏎` opens — a row of a table of two hundred
|
||||
machines is something the eye runs past, not something anyone has read. In the
|
||||
@@ -168,8 +411,12 @@ sheet:
|
||||
|
||||
^a the action menu (see below)
|
||||
^s take a snapshot: a name, then a confirmation
|
||||
e recent events
|
||||
h ssh to the guest, as root
|
||||
y copy that name or address to the clipboard
|
||||
w open in the vSphere client
|
||||
|
||||
Pressing either in the table says so rather than doing nothing visible.
|
||||
Pressing `^a` or `^s` in the table says so rather than doing nothing visible.
|
||||
|
||||
`^s` takes two steps.
|
||||
|
||||
@@ -184,28 +431,83 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
|
||||
name is on the status line, and a sheet that is open jumps to its snapshot
|
||||
section so the new one is there to see.
|
||||
|
||||
### The sheet's four letters
|
||||
|
||||
The four letters are the things that change nothing, on the machine or on the
|
||||
vCenter: they read its history, copy its address, open it somewhere else. They
|
||||
are letters of the sheet rather than entries in the menu because nothing they do
|
||||
needs thinking about first, and — unlike the table, whose filter swallows every
|
||||
ordinary letter — the sheet has nothing else to do with them:
|
||||
|
||||
* **`e`** puts the machine's own recent events at the foot of its sheet and
|
||||
scrolls down to them — why is this thing off, who rebooted it, what happened
|
||||
at four this morning. `gvm log` is the whole vCenter over the last hour, which
|
||||
is the right shape for a mail and the wrong one for that question. They are
|
||||
fetched when they are asked for: opening a machine stays one call.
|
||||
* **`h`** logs in to the guest as root — `ssh root@<name>` — by its own hostname
|
||||
where it reports one and by its address otherwise. The terminal goes back to
|
||||
what it was for as long as that lasts. `ssh = ssh -l someone %h` in `~/.gvmrc`
|
||||
replaces that command whole, root and all; the target is always one argument
|
||||
and never goes through a shell, because it is a name the guest chose for
|
||||
itself. `^C` while it hangs on a machine that is not answering kills the login
|
||||
and no more than that: gvm catches the signal for as long as the child has the
|
||||
screen — the terminal is in its ordinary mode there, where the keystroke goes
|
||||
to every process in the foreground group — and comes back to the sheet saying
|
||||
the login was interrupted.
|
||||
* **`y`** copies exactly what `h` would connect to — the hostname where the
|
||||
guest reports one, the address otherwise — and the status line names which of
|
||||
the two it was and which clipboard it went into, because a clipboard is
|
||||
invisible and "copied" on its own is something one has to go and check.
|
||||
|
||||
Two routes, because neither alone is enough. `pbcopy` (or `wl-copy`, `xclip`,
|
||||
`xsel`) is the one that always works where there is one, and the terminal's own
|
||||
OSC 52 escape sequence is the only one that reaches the right machine from the
|
||||
far end of an ssh login — where a local `pbcopy` would copy into the clipboard
|
||||
of a machine nobody is sitting at. So the sequence is always sent and the
|
||||
command is used as well where there is one; a login is recognised by
|
||||
`SSH_CONNECTION`, and there the sequence is the whole story. It is also the
|
||||
route a terminal is free to ignore — iTerm2 keeps it behind *Applications in
|
||||
terminal may access clipboard*, tmux behind `set-clipboard` — which is why the
|
||||
line says when it was the only one used.
|
||||
* **`w`** opens the machine's page in the vSphere client. The link needs the
|
||||
vCenter's instance UUID, which is the serverGuid that client puts in its URLs
|
||||
and the one thing gvm cannot work out from the configuration; where there is no
|
||||
browser to hand off to, the URL is said and copied instead.
|
||||
|
||||
`h` and `y` need somewhere to connect to: on a machine whose guest is not
|
||||
reporting an address they say so on the status line rather than doing nothing.
|
||||
|
||||
### The action menu
|
||||
|
||||
In a machine's sheet, `^a` opens the menu for it. Above the choices it repeats
|
||||
the few lines of the sheet the choice depends on — state, guest, hostname,
|
||||
address — taken from the sheet itself, so the two cannot word the same fact
|
||||
differently. On a terminal too short for both, those lines go and the choices
|
||||
stay. Everything that changes a
|
||||
machine lives there and nowhere else — the list is arrowed through and its filter
|
||||
swallows every ordinary letter, so a hotkey that powered a machine off would sit
|
||||
one fumbled control key away from an outage, and the sheet has to be opened first
|
||||
anyway.
|
||||
In a machine's sheet, `^a` opens the menu for it. It is everything that changes a
|
||||
machine, and it lives there and nowhere else — the list is arrowed through and its
|
||||
filter swallows every ordinary letter, so a hotkey that powered a machine off
|
||||
would sit one fumbled control key away from an outage, and the sheet has to be
|
||||
opened first anyway.
|
||||
|
||||
Above the choices the menu repeats the few lines of the sheet the choice depends
|
||||
on — state, guest, hostname, address — taken from the sheet itself, so the two
|
||||
cannot word the same fact differently. On a terminal too short for both, those
|
||||
lines go one at a time, least useful first: the state stays longest because every
|
||||
choice depends on it, then the address and the hostname, which say which machine
|
||||
this is about.
|
||||
|
||||
n take a snapshot o power on
|
||||
r revert to a snapshot ... s shut down the guest
|
||||
d remove a snapshot ... b reboot the guest
|
||||
D remove ALL snapshots S power off (hard)
|
||||
B reset (hard)
|
||||
──────────────────────────────── 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" — 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
|
||||
@@ -247,9 +549,13 @@ that has finished shutting down in its own time — `^r` reloads everything.
|
||||
Nothing else here writes: no key changes a setting, and there is no way to delete
|
||||
a machine.
|
||||
|
||||
Everything but the snapshot tree comes out of the one inventory sweep the list
|
||||
makes at the start; the snapshots of a machine are fetched when its sheet is
|
||||
opened.
|
||||
The sweep the list makes at the start brings back everything the table and the
|
||||
sheet show, the snapshot trees and the running tasks included — they are
|
||||
properties of a machine, and reading them for every machine is one call, not one
|
||||
per row. Two things are asked for afterwards, for one machine at a time: its
|
||||
snapshots when its sheet is opened, because everything that acts on a snapshot
|
||||
addresses it by reference and a reference out of a sweep that ran minutes ago may
|
||||
name one somebody has since removed; and its events, when `e` asks for them.
|
||||
|
||||
It needs a terminal, and says so before it connects to anything — in a pipe or
|
||||
under cron, use `gvm vm -l`.
|
||||
@@ -279,10 +585,204 @@ 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.
|
||||
|
||||
## The reports
|
||||
|
||||
Two things nothing in vCenter does for you, in the shape a cron job wants:
|
||||
every server at once, one line per thing, and `-m` to put it in the post.
|
||||
|
||||
### Old snapshots
|
||||
|
||||
gvm snap --old # older than 30 days, on every vCenter
|
||||
gvm snap --old -d 7 # or than a week
|
||||
gvm snap --old -m # and mail it
|
||||
|
||||
Somebody takes a snapshot before an upgrade, the upgrade goes well, and the
|
||||
snapshot stays. Six weeks later its delta disk is bigger than the machine and
|
||||
the datastore is the thing that pages you.
|
||||
|
||||
3 snapshots older than 30 days, on 2 machines:
|
||||
|
||||
MACHINE VC SNAPSHOT AGE TAKEN SIZE
|
||||
old01 v108 base 208d 12.02.2026 03:00 8.9GB
|
||||
db01 v308 before-patch 63d 06.07.2026 22:14 41.2GB
|
||||
db01 v308 hotfix 61d 08.07.2026 09:40 2.1GB
|
||||
|
||||
52.2GB in 3 snapshots, on 2 machines
|
||||
|
||||
Oldest first, which is the order the work is done in and puts the worst line
|
||||
where a mail gets read. The age takes the table's colours — yellow past a week,
|
||||
red past a month — and the mail carries the same table with the colours left
|
||||
off, out of the same cells, so the two cannot come to different conclusions.
|
||||
|
||||
The size is what removing that snapshot would give back: its own state file and
|
||||
the last link of each of its disk chains. The links in front of those belong to
|
||||
its ancestors, and the delta the machine is writing to right now belongs to no
|
||||
snapshot at all — so summing whole chains, which is the obvious thing to do,
|
||||
reports the same delta once per descendant. A snapshot whose file layout could
|
||||
not be read shows a dash rather than 0 B: nought bytes and "not known" are
|
||||
different answers, and the second must not invite somebody to remove the wrong
|
||||
snapshot.
|
||||
|
||||
The file layout is only asked for for the machines that actually have snapshots,
|
||||
and for all of them at once per server — it lists every file of every machine,
|
||||
which is far too much to carry through the ordinary sweep.
|
||||
|
||||
### Datastores
|
||||
|
||||
gvm ds # one line per datastore
|
||||
gvm ds -t # and post the numbers
|
||||
|
||||
The gap next to `gvm host`: a cluster is watched by its processor load and its
|
||||
memory, and then it falls over because a datastore filled up.
|
||||
|
||||
DATASTORE TYPE CAPACITY FREE USED% PROVISIONED OVER% VM STATUS STATE
|
||||
ppb-ssd-1 VMFS 4.0TB 412GB 90 5.1TB 128 41 green ok
|
||||
ppb-sata-2 VMFS 8.0TB 3.2TB 60 6.0TB 75 88 green ok
|
||||
ppb-old VMFS - - - - - 3 red inaccessible
|
||||
|
||||
3 datastores 3.6TB of 12.0TB free (70 % used)
|
||||
|
||||
*Provisioned* is what has been promised out of the datastore: what is in use
|
||||
plus what thin disks are still entitled to grow into. Past the capacity that is
|
||||
a promise the datastore cannot keep if every machine takes what it was offered,
|
||||
which is why it has a column of its own rather than being folded into "used" —
|
||||
ordinary practice, so a hundred per cent is a word of warning in yellow and half
|
||||
again as much is an alarm in red.
|
||||
|
||||
Every figure comes out of the datastore's summary, and vSphere only vouches for
|
||||
those while the datastore is accessible: an unreachable one reports dashes
|
||||
rather than zeroes, because a datastore that says 0 B free looks like an
|
||||
emergency and one nobody can reach is a different one.
|
||||
|
||||
## The listing as a document
|
||||
|
||||
`gvm vm -l --json` is the same sweep, for something other than a person:
|
||||
|
||||
{
|
||||
"generated": "2026-09-08T11:42:07+02:00",
|
||||
"answered": ["v308", "v108"],
|
||||
"failed": ["v38: login failed: ..."],
|
||||
"count": 212,
|
||||
"machines": [
|
||||
{
|
||||
"name": "db01",
|
||||
"vcenter": "v308",
|
||||
"power": "poweredOn",
|
||||
"cpu_percent": 12.4,
|
||||
"memory_percent": 64.1,
|
||||
"snapshots": [{"name": "before-patch", "days": 63, "current": true, ...}],
|
||||
"oldest_snapshot_days": 63,
|
||||
"task": {"what": "consolid", "progress": 40, ...},
|
||||
"issues": ["disks need consolidating"],
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Two things about the shape, because a document is a promise:
|
||||
|
||||
It is one object and not an array of machines, because a listing that quietly
|
||||
leaves out a vCenter which did not answer is worse than no listing at all — a
|
||||
script handed a bare array cannot tell an empty cluster from an unreachable one.
|
||||
The servers that answered and the ones that did not are in the document, and a
|
||||
failure is *not* also printed as prose: a line of English in the middle of the
|
||||
JSON would break whatever is reading it.
|
||||
|
||||
And a figure that is not known is `null`, never `0`. A stopped machine has no
|
||||
processor load and a machine whose guest is silent has no address; a spreadsheet
|
||||
that averages a column of zeroes reports a fleet that is idle.
|
||||
|
||||
`--json` and `--issues` mean `-l` without having to be told twice, and both take
|
||||
`-m`, `--sort` and `--reverse` like any other listing.
|
||||
|
||||
## Shell completion
|
||||
|
||||
eval "$(gvm completion zsh)" # ~/.zshrc
|
||||
gvm completion bash > /etc/bash_completion.d/gvm
|
||||
|
||||
Machine names are long and there are hundreds of them, which is what makes the
|
||||
non-interactive half hard to type — `gvm -v v308 snap -l dbse<tab>`. The
|
||||
completion offers them after the options that take a machine, the server names
|
||||
after `-v`, and every subcommand and option otherwise.
|
||||
|
||||
That last half is not written down anywhere: flaggy generates it out of the
|
||||
parser itself, so no list can fall behind the options that exist. gvm answers
|
||||
the `completion` subcommand one step before flaggy would, keeps what flaggy
|
||||
wrote, and adds the names on top — a wrapper that falls back to flaggy's own
|
||||
function by the name it installed it under, read off the script rather than
|
||||
written down a second time. `fish`, `powershell` and `nushell` are left to
|
||||
flaggy entirely; the names are wired up for zsh and bash.
|
||||
|
||||
The names cannot come from the vCenters: a completion runs on every Tab and has
|
||||
to answer in milliseconds, and three logins take seconds. So they come out of
|
||||
what gvm last saw — every sweep of the machine list leaves them in the cache
|
||||
directory, per server and with the time on them, and `--complete-vms` reads that
|
||||
file and nothing else. A sweep of one server leaves the others' names where they
|
||||
were, so completion keeps working for a vCenter that is down.
|
||||
|
||||
Neither the subcommand nor those two options read `~/.gvmrc`: a Tab key must not
|
||||
rewrite a file, and reading the configuration seals any password standing in it
|
||||
in the clear.
|
||||
|
||||
Nothing else in gvm reads that cache. Every command resolves the name it was
|
||||
given against the server itself, because "what gvm saw last time somebody
|
||||
looked" is the right currency for a Tab key and no currency at all for anything
|
||||
that acts on a machine. `gvm config` says how old it is, so that a completion
|
||||
offering a machine deleted last month can be explained.
|
||||
|
||||
## Colours
|
||||
|
||||
The palette is [mwxcol](https://git.micw.org/mike/mwxcol), copied into
|
||||
@@ -296,6 +796,14 @@ selected row on a `darker` surface with a `violet` pointer, the filter's hits in
|
||||
`pink`, counts in `green`, questions in `yellow`, errors in `red`, headers and
|
||||
the help line in `dark`.
|
||||
|
||||
Every line at the foot of the screen that wants an answer is that one `yellow`,
|
||||
whatever kind of question it is: the sort legend, both its lines; a yes/no
|
||||
question and its hint; the label in front of a snapshot name or the `YES` of a
|
||||
confirmation. They are different kinds of question and one state — gvm is
|
||||
waiting for a key — and that state is worth learning once, in one place and one
|
||||
tone, rather than being worked out per screen. What is typed in answer stays
|
||||
`white`: it is the operator's, not part of the question.
|
||||
|
||||
Inside the table and the sheet every colour is a role, not a decoration:
|
||||
|
||||
| | |
|
||||
@@ -306,7 +814,9 @@ Inside the table and the sheet every colour is a role, not a decoration:
|
||||
| `blue` | addresses, paths and dates |
|
||||
| `orange` | sizes and counts |
|
||||
| `pink` | names a person gave: snapshots |
|
||||
| `grey` | present but seldom read: host, guest os, uuids |
|
||||
| `grey` | present but seldom read: host, guest os, uuids, an event's history |
|
||||
| `yellow` / `red` | a load past 75 / 90 %, a snapshot past a week / a month, something that wants a look / something broken |
|
||||
| `yellow` | what is being done to a machine right now: the task column |
|
||||
|
||||
A machine that is off is `dark` rather than red — being switched off is not a
|
||||
fault. Two places lift that tone to `grey`: the selected row, where `dark` would
|
||||
@@ -368,3 +878,56 @@ refused operation sends nothing, that an unavailable menu entry does not run whe
|
||||
it is picked anyway, that only the exact machine name passes the confirmation,
|
||||
and that removing one of two identically named snapshots removes the one that was
|
||||
picked.
|
||||
|
||||
So are the judgements the reports are made of, which are the ones that would go
|
||||
wrong quietly:
|
||||
|
||||
* every reason a machine can be in the issues list, one by one, and the ones
|
||||
that must *not* put it there — a stopped machine, an acknowledged alarm, a
|
||||
filesystem with room, this morning's snapshot, a status reported twice
|
||||
* that a snapshot's size counts each delta once and not once per descendant,
|
||||
which is what summing whole disk chains does
|
||||
* that an unknown figure is a dash on screen and `null` in the document, for the
|
||||
load, the address, the uptime, a datastore that cannot be reached and a
|
||||
snapshot whose file layout could not be read
|
||||
* that the ssh target is one argument and never shell code — it is a name the
|
||||
guest chose for itself
|
||||
* that `y` names what it copied and that an ssh login uses no local clipboard
|
||||
tool — the tests say they are a login, which also keeps them off the clipboard
|
||||
of whoever is running them
|
||||
* every rule a resize is held to: what a running machine may and may not be
|
||||
given, that the cores per socket are never changed to make a vCPU count fit,
|
||||
that a refusal never names 0 vCPUs as a count that would, and — against the
|
||||
simulator — that a reconfigure actually lands, read back from the server
|
||||
rather than assumed, and that a command line asking for one possible change
|
||||
and one impossible one makes neither
|
||||
* that a `^C` during a login does not take gvm with it, and that the login still
|
||||
dies of it: the signal is caught for as long as the child has the screen, and
|
||||
catching is not ignoring — an ignored one would be inherited by the ssh
|
||||
* that a refresh which half failed still leaves the rows and the view describing
|
||||
the same list, and that the screen draws — it panicked before
|
||||
* that every layout of the estate screen fits the terminal it was drawn for, at
|
||||
nine widths from 60 columns up
|
||||
* that a page step on that screen does not leap twice when it lands on a cluster
|
||||
name, and that an unreachable host draws no load
|
||||
* that the estate screen groups the hosts under their clusters, that a heading
|
||||
is the sum of what is under it, that every placed machine is charged to
|
||||
exactly one host, and that Enter comes back with the list filtered to it
|
||||
* that a ratio says nothing where there is room to spare, and that an unknown
|
||||
load does not draw the same bar as an idle one
|
||||
* that the live refresh reads over the session it already has and not a new
|
||||
login — with the session closed it fails rather than reconnecting — that it
|
||||
notices a machine stopped behind gvm's back and says so, and that the trend
|
||||
keeps its scale, is bounded, and is forgotten when a machine goes
|
||||
* that the column ladder still only ever *drops* columns with the task column in
|
||||
the table, and that a terminal of eighty still keeps the address
|
||||
* that the completion cache survives a sweep of one server, forgets a machine
|
||||
the server has forgotten, is written 0600, and that every option the
|
||||
completion scripts complete after is an option `gvm.go` actually declares
|
||||
* that the mailed report carries no escape sequences, and that `--json` stays
|
||||
readable when a vCenter does not answer
|
||||
|
||||
`gvm --version`, the help and the options answered before the flag parser are
|
||||
checked against each other too: anything the help promises has to be something
|
||||
gvm answers, and the two options the completion scripts call are deliberately
|
||||
not in the help.
|
||||
|
||||
+272
-10
@@ -51,8 +51,52 @@ func (m menuItem) isSeparator() bool { return m.key == 0 && m.label == "" }
|
||||
// actions: what it is, and whether it is running. They are the facts the choice
|
||||
// underneath depends on, and having them on the same screen means not having to
|
||||
// remember them from the sheet one keystroke ago.
|
||||
//
|
||||
// In sheet order, which is the order they are shown in.
|
||||
var menuFacts = []string{"state", "guest", "hostname", "address"}
|
||||
|
||||
// factOrder is which of them to keep when there is not room for all four —
|
||||
// most worth keeping first, the same idea as the table's expendable columns.
|
||||
//
|
||||
// The state comes first because every choice below depends on it. The address
|
||||
// and the hostname come next: they say which machine is about to be powered off,
|
||||
// which is worth having in front of one. The guest's operating system decides
|
||||
// nothing here.
|
||||
var factOrder = []string{"state", "address", "hostname", "guest"}
|
||||
|
||||
// keepFacts is as many of the facts as fit, still in sheet order.
|
||||
//
|
||||
// Giving them up one at a time rather than all at once is what a short terminal
|
||||
// gets out of this: below twenty rows the menu and all four facts no longer fit
|
||||
// on the screen together, and none of them is a worse answer than three.
|
||||
func keepFacts(info []sheetLine, room int) []sheetLine {
|
||||
if room >= len(info) {
|
||||
return info
|
||||
}
|
||||
if room <= 0 {
|
||||
return nil
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, label := range factOrder {
|
||||
if len(keep) >= room {
|
||||
break
|
||||
}
|
||||
for _, l := range info {
|
||||
if l.label == label {
|
||||
keep[label] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]sheetLine, 0, len(keep))
|
||||
for _, l := range info {
|
||||
if keep[l.label] {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sheetPick takes named lines out of a sheet, in the order asked for, skipping
|
||||
// the ones this machine has nothing to say about. The lines come from vmDetail
|
||||
// rather than being formatted again here, so the menu and the sheet cannot end up
|
||||
@@ -84,13 +128,21 @@ func (b *browser) openMenu() {
|
||||
return
|
||||
}
|
||||
|
||||
// Anything said on the way through this function is held until the end of
|
||||
// it. The last thing openMenu does is clear the status line — the menu is a
|
||||
// fresh screen and whatever was on the list behind it does not belong on it
|
||||
// — and a warning set before that was being wiped before a single frame was
|
||||
// drawn. A menu that is quietly less certain than it looks is worse than no
|
||||
// menu: these two warnings are exactly the ones that say so.
|
||||
var warn []string
|
||||
|
||||
// The row is re-read first. What the menu offers is decided from the
|
||||
// machine's state, and a state from the last full sweep is old enough to
|
||||
// matter: it would offer to power off a machine whose guest has meanwhile
|
||||
// finished shutting down. A failure here is not fatal — the menu is simply
|
||||
// built from what is known — but it is said.
|
||||
if err := b.refreshRow(); err != nil {
|
||||
b.setStatus(colWarn, "showing what was last read: "+err.Error())
|
||||
warn = append(warn, "showing what was last read: "+err.Error())
|
||||
}
|
||||
r = b.current()
|
||||
if r == nil {
|
||||
@@ -103,12 +155,28 @@ func (b *browser) openMenu() {
|
||||
return
|
||||
}
|
||||
b.menuSnaps = snaps
|
||||
b.menu = b.buildMenu(*r, snaps)
|
||||
|
||||
// What the machine has, and what of it may be changed while it runs. It is
|
||||
// not in the sweep — it is four fields of the whole configuration document
|
||||
// — so it is read here, for this one machine, so that the two hardware
|
||||
// entries can be greyed out with the reason rather than accepting a number
|
||||
// vCenter is going to refuse. A failure is not fatal to the rest of the
|
||||
// menu: those two entries grey themselves out, saying what could not be
|
||||
// read, and the warning below says it again where it cannot be missed.
|
||||
b.menuSize, err = sizingOf(r.sess, r.ref)
|
||||
if err != nil {
|
||||
warn = append(warn, err.Error())
|
||||
}
|
||||
b.menu = b.buildMenu(*r, snaps, b.menuSize)
|
||||
// vmDetail asks nothing of the network; the snapshots are left out of it
|
||||
// because the menu only wants the four lines above.
|
||||
b.menuInfo = sheetPick(vmDetail(*r, nil, ""), menuFacts)
|
||||
b.menuSel = 0
|
||||
|
||||
b.setStatus("", "")
|
||||
if len(warn) > 0 {
|
||||
b.setStatus(colWarn, strings.Join(warn, " · "))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *browser) closeMenu() {
|
||||
@@ -116,7 +184,7 @@ func (b *browser) closeMenu() {
|
||||
}
|
||||
|
||||
// buildMenu is the menu for one machine in its current state.
|
||||
func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
|
||||
func (b *browser) buildMenu(r vmRow, snaps []snapEntry, sz sizing) []menuItem {
|
||||
noSnaps, noSnapsHint := "", ""
|
||||
if len(snaps) == 0 {
|
||||
noSnaps, noSnapsHint = "the machine has no snapshots", "no snapshots"
|
||||
@@ -140,6 +208,12 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
|
||||
{key: 'D', label: "remove ALL snapshots", why: noSnaps, hint: noSnapsHint,
|
||||
run: func(b *browser, r vmRow) { b.removeAll(r, len(snaps)) }},
|
||||
separator(),
|
||||
// What the machine is, between what it has been and what it is doing.
|
||||
// Both entries end in "..." for the same reason the snapshot ones do:
|
||||
// they ask for something before anything happens.
|
||||
sizeItem('c', "change the vCPU count ...", r, sz, sizeCPUs),
|
||||
sizeItem('m', "change the memory ...", r, sz, sizeMemory),
|
||||
separator(),
|
||||
pwr('o', "power on", opPowerOn),
|
||||
pwr('s', "shut down the guest", opShutdownGuest),
|
||||
pwr('b', "reboot the guest", opRebootGuest),
|
||||
@@ -149,6 +223,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) {
|
||||
@@ -378,6 +462,78 @@ func (b *browser) power(r vmRow, op powerOp) {
|
||||
b.done(msg)
|
||||
}
|
||||
|
||||
// resize changes what the machine has: the value is typed, checked, confirmed
|
||||
// and only then sent.
|
||||
//
|
||||
// The configuration is read again here rather than taken from the menu that was
|
||||
// just drawn. It is the same read, a moment later, and the moment matters: the
|
||||
// menu's grey-out only says a change of this kind is possible at all, and
|
||||
// between drawing it and answering the question somebody else may have started
|
||||
// the machine — which turns "give it 4 GB less" from a reconfigure into
|
||||
// something vSphere will not do.
|
||||
//
|
||||
// The question at the end is the plain one, not the page that asks for YES to be
|
||||
// typed. That page is for losing something: pulling the plug, reverting,
|
||||
// removing snapshots. Nothing here is lost — a number set wrongly is set back —
|
||||
// and a confirmation asked for everything is a confirmation nobody reads.
|
||||
func (b *browser) resize(r vmRow, k sizeKind) {
|
||||
b.closeMenu()
|
||||
if r.sess == nil {
|
||||
b.setStatus(colErr, "no connection to "+r.vc.Name)
|
||||
return
|
||||
}
|
||||
|
||||
sz, err := sizingOf(r.sess, r.ref)
|
||||
if err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
if err := checkSize(r, sz, k, 0); err != nil {
|
||||
b.setStatus(colWarn, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
typed, ok := b.input(sizePrompt(r, sz, k))
|
||||
if !ok || typed == "" {
|
||||
b.setStatus(colDim, "nothing done")
|
||||
return
|
||||
}
|
||||
want, err := parseSize(k, typed)
|
||||
if err != nil {
|
||||
b.setStatus(colWarn, err.Error())
|
||||
return
|
||||
}
|
||||
if err := checkSize(r, sz, k, want); err != nil {
|
||||
b.setStatus(colWarn, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
from, to := k.shown(sz.now(k)), k.shown(want)
|
||||
if !b.ask(SF("%s on %s: %s → %s?", r.name, r.vc.Name, from, to)) {
|
||||
b.setStatus(colDim, "nothing done")
|
||||
return
|
||||
}
|
||||
|
||||
b.working(SF("%s: %s → %s ...", r.name, from, to))
|
||||
msg, err := runResize(r.sess, r, sz, k, want)
|
||||
if err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.done(msg)
|
||||
}
|
||||
|
||||
// sizePrompt is the label on that line. It carries the current value, so the
|
||||
// number being replaced is in front of the person replacing it, and for memory
|
||||
// the unit as well — a field that takes 16 and means gigabytes has to say so
|
||||
// where it is typed, not in a manual.
|
||||
func sizePrompt(r vmRow, sz sizing, k sizeKind) string {
|
||||
if k == sizeCPUs {
|
||||
return SF("vCPUs for %s (now %d): ", r.name, sz.cpus)
|
||||
}
|
||||
return SF("memory for %s in GB (now %s, or 512m): ", r.name, k.shown(sz.memoryMB))
|
||||
}
|
||||
|
||||
// working puts a line on the screen before an operation that will block the loop
|
||||
// — a vCenter task can take minutes, and a terminal that goes silent for that
|
||||
// long looks like a hang.
|
||||
@@ -429,12 +585,22 @@ func (b *browser) refreshRow() error {
|
||||
if r.sess == nil {
|
||||
return errf("no connection to %s", r.vc.Name)
|
||||
}
|
||||
// The same properties the sweep reads, so a re-read row is the same kind of
|
||||
// row as its neighbours: one that lost its snapshots or its task on being
|
||||
// refreshed would quietly disagree with the rest of the table.
|
||||
var fresh mo.VirtualMachine
|
||||
vm := object.NewVirtualMachine(r.sess.client.Client, r.ref)
|
||||
if err := vm.Properties(r.sess.ctx, r.ref, []string{"summary", "guest"}, &fresh); err != nil {
|
||||
if err := vm.Properties(r.sess.ctx, r.ref, sweepProps, &fresh); err != nil {
|
||||
return err
|
||||
}
|
||||
r.vm = fresh
|
||||
r.snaps = snapshotsIn(fresh.Snapshot)
|
||||
r.task = nil
|
||||
if busy := runningTasks(r.sess, []mo.VirtualMachine{fresh}); len(busy) > 0 {
|
||||
if t, ok := busy[r.ref]; ok {
|
||||
r.task = &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -490,11 +656,12 @@ func (b *browser) renderMenu() {
|
||||
|
||||
// The actions are what the menu is for; the facts above them are a courtesy.
|
||||
// On a terminal too short for both, the facts are what goes — a menu whose
|
||||
// entries have scrolled off the top is worse than one without a header.
|
||||
info := b.menuInfo
|
||||
if 3+len(info)+len(b.menu) > rows-2 {
|
||||
info = nil
|
||||
}
|
||||
// entries have scrolled off the top is worse than one without a header — and
|
||||
// they go one at a time, least useful first (keepFacts).
|
||||
//
|
||||
// The three is the title, the blank line under it, and the blank line under
|
||||
// the facts; the two at the end is the status line and the help line.
|
||||
info := keepFacts(b.menuInfo, rows-2-3-len(b.menu))
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(scrClear + scrHide)
|
||||
@@ -547,7 +714,8 @@ func (b *browser) renderMenu() {
|
||||
if b.status != "" {
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
} else {
|
||||
segLine(&sb, cols, seg{colDim, "lowercase asks the guest, uppercase acts at the hypervisor"})
|
||||
segLine(&sb, cols, seg{colDim,
|
||||
"of the power pairs, lowercase asks the guest and uppercase the hypervisor"})
|
||||
}
|
||||
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
|
||||
b.write(sb.String())
|
||||
@@ -702,3 +870,97 @@ func wrap(s string, width int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- the sheet's own keys
|
||||
//
|
||||
// The four below change nothing, on the machine or on the vCenter: they read its
|
||||
// history, copy its address, open it somewhere else. That is why they are on
|
||||
// letters of the sheet itself rather than in the action menu, which is for the
|
||||
// things one has to be sure about before pressing.
|
||||
|
||||
// showEvents puts the machine's recent history at the foot of its sheet and
|
||||
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
|
||||
// machine is one call, and this is another.
|
||||
func (b *browser) showEvents(r vmRow) {
|
||||
b.working(SF("reading the events of %s ...", r.name))
|
||||
|
||||
lines, err := eventsOf(r)
|
||||
if err != nil {
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
b.events, b.eventsOf = lines, r.id()
|
||||
if len(lines) == 0 {
|
||||
b.setStatus(colDim, "vCenter has no recent events for "+r.name)
|
||||
} else {
|
||||
b.setStatus(colInfo, SF("%s of %s", plural(len(lines), "event"), r.name))
|
||||
}
|
||||
|
||||
b.openDetail() // rebuilt, so the sheet carries them
|
||||
b.scrollToSection("events")
|
||||
}
|
||||
|
||||
// hasAddress reports whether there is somewhere to connect to, and says so where
|
||||
// the key was pressed when there is not. A guest that is not talking reports no
|
||||
// address, and ssh or copy doing nothing at all would read as gvm having hung —
|
||||
// the greyed-out menu entries these two replaced said as much in their own line.
|
||||
func (b *browser) hasAddress(r vmRow) bool {
|
||||
if r.sshTarget() != "" {
|
||||
return true
|
||||
}
|
||||
b.setStatus(colWarn, SF("%s has no address or hostname — its guest is not reporting one", r.name))
|
||||
return false
|
||||
}
|
||||
|
||||
// sshTo logs in to the guest. The terminal goes back to what it was for as long
|
||||
// as that lasts (guest.go), and the list is redrawn afterwards.
|
||||
func (b *browser) sshTo(r vmRow) {
|
||||
target := r.sshTarget()
|
||||
argv := sshCommand(b.ssh, target)
|
||||
|
||||
err := b.runInTerminal(argv)
|
||||
switch {
|
||||
case err == nil:
|
||||
b.setStatus(colDim, "back from "+target)
|
||||
case interrupted(err):
|
||||
// Ctrl-C during a login is somebody changing their mind, not a fault:
|
||||
// gvm now survives it (holdTerminalSignals) and says so in the colour
|
||||
// of an ordinary remark.
|
||||
b.setStatus(colDim, "the login to "+target+" was interrupted")
|
||||
default:
|
||||
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
|
||||
}
|
||||
}
|
||||
|
||||
// copyAddress puts what `h` would connect to where the next paste will find it.
|
||||
//
|
||||
// What it says is longer than "copied web01.example" was, and deliberately: a
|
||||
// clipboard is invisible, so the line has to name what went into it — the
|
||||
// hostname or the address, since the sheet shows both — and which clipboard it
|
||||
// is. Where the escape sequence was the only route it also says so, because that
|
||||
// is the case where it may quietly not have arrived.
|
||||
func (b *browser) copyAddress(r vmRow) {
|
||||
target, kind := r.sshTargetIs()
|
||||
if where := b.toClipboard(target); where != "" {
|
||||
b.setStatus(colInfo, SF("copied its %s %s to the clipboard (%s)", kind, target, where))
|
||||
return
|
||||
}
|
||||
b.setStatus(colWarn, SF("sent its %s %s to the terminal's own clipboard — it has to allow that (OSC 52)", kind, target))
|
||||
}
|
||||
|
||||
// openVSphere opens the machine's page in the vSphere client, and says the URL
|
||||
// either way: a workstation with no browser to hand off to still gets the one
|
||||
// thing that was wanted, and so does anyone running gvm over ssh.
|
||||
func (b *browser) openVSphere(r vmRow) {
|
||||
url := vsphereURL(r)
|
||||
if url == "" {
|
||||
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
|
||||
return
|
||||
}
|
||||
if err := openURL(url); err != nil {
|
||||
b.toClipboard(url) // the same two routes as `y`
|
||||
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
|
||||
return
|
||||
}
|
||||
b.setStatus(colInfo, "opened "+url)
|
||||
}
|
||||
|
||||
+57
-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
|
||||
}
|
||||
@@ -598,19 +598,23 @@ func TestMenuFactsSkipWhatIsUnknown(t *testing.T) {
|
||||
func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
|
||||
// The facts are given up one at a time, least useful first, so what is
|
||||
// checked here is the one that goes third: with room for three facts the
|
||||
// hostname is still there, with room for two it is not.
|
||||
for _, c := range []struct {
|
||||
rows string
|
||||
wantFacts bool
|
||||
}{
|
||||
{"30", true}, // room for both
|
||||
{"20", true}, // exactly enough: 1 title + 1 + 4 facts + 1 + 11 choices + status + help
|
||||
{"18", false}, // two short, so the facts go
|
||||
{"30", true}, // room for both, all four facts
|
||||
{"22", true}, // room for three of them, the hostname among them
|
||||
{"21", false}, // room for two: the state and the address
|
||||
{"16", false}, // no room at all, so the choices have the screen
|
||||
} {
|
||||
t.Setenv("LINES", c.rows)
|
||||
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
b.menu = b.buildMenu(r, nil)
|
||||
b.menu = b.buildMenu(r, nil, testSizing())
|
||||
b.menuInfo = sheetPick(vmDetail(r, nil, ""), menuFacts)
|
||||
|
||||
frame := renderToPipe(t, b, b.renderMenu)
|
||||
@@ -742,6 +746,9 @@ func TestEveryTaskWaitIsBounded(t *testing.T) {
|
||||
if strings.Contains(code, "wg.Wait()") {
|
||||
continue // a WaitGroup, not a vCenter task
|
||||
}
|
||||
if strings.Contains(code, "cmd.Wait()") {
|
||||
continue // an exec.Cmd — a browser being handed a URL (guest.go)
|
||||
}
|
||||
found++
|
||||
// The one place a task may be waited on is inside waitTask, which
|
||||
// gives it a deadline of its own.
|
||||
@@ -754,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}
|
||||
}
|
||||
|
||||
@@ -39,20 +39,29 @@ const (
|
||||
// The screen's colours. The comment on each is the name mwxcol's fzf theme gives
|
||||
// the same job, so the two stay in step.
|
||||
var (
|
||||
colRow = cGrey.fg() // fg
|
||||
colRowSel = cWhite.fg() // fg+
|
||||
colSurface = cDarker.bg() // bg+
|
||||
colMatch = cPink.fg() // hl, hl+
|
||||
colPointer = cViolet.fg() // pointer
|
||||
colHeader = cDark.fg() // header, label
|
||||
colInfo = cGreen.fg() // info
|
||||
colQuery = cWhite.fg() // query
|
||||
colErr = cRed.fg() // prompt
|
||||
colWarn = cYellow.fg() // not in the theme: a question, a wait
|
||||
colTitle = attrBold + cWhite.fg() //
|
||||
colLabel = cDark.fg() // the sheet's field names
|
||||
colValue = cWhite.fg() // its values, where nothing better applies
|
||||
colDim = cDark.fg() // disabled
|
||||
colRow = cGrey.fg() // fg
|
||||
colRowSel = cWhite.fg() // fg+
|
||||
colSurface = cDarker.bg() // bg+
|
||||
colMatch = cPink.fg() // hl, hl+
|
||||
colPointer = cViolet.fg() // pointer
|
||||
colHeader = cDark.fg() // header, label
|
||||
colInfo = cGreen.fg() // info
|
||||
colQuery = cWhite.fg() // query
|
||||
colErr = cRed.fg() // prompt
|
||||
colWarn = cYellow.fg() // not in the theme: a question, a wait
|
||||
// colPrompt is every line at the foot of the screen that wants an answer:
|
||||
// the sort legend, a yes/no question, the label in front of something being
|
||||
// typed. One colour for all of them, whatever kind of question it is —
|
||||
// yellow, which is the job mwxcol's own theme gives it — so that "gvm is
|
||||
// waiting for me" is learned once, in one place and one tone, rather than
|
||||
// being a thing to work out per screen. It is the same value as colWarn and
|
||||
// a name of its own, because the two mean different things and only one of
|
||||
// them may ever change.
|
||||
colPrompt = cYellow.fg()
|
||||
colTitle = attrBold + cWhite.fg() //
|
||||
colLabel = cDark.fg() // the sheet's field names
|
||||
colValue = cWhite.fg() // its values, where nothing better applies
|
||||
colDim = cDark.fg() // disabled
|
||||
)
|
||||
|
||||
// What the columns and the sheet's values are coloured by. Every one of these is
|
||||
@@ -73,8 +82,8 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^r reload esc clear/quit"
|
||||
detailHelp = "↑/↓ scroll ^a actions ^s snapshot esc/⏎ back ^c quit"
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^e estate ^o sort ^w issues ^l live ^r reload esc clear/quit"
|
||||
detailHelp = "↑/↓ scroll e events h ssh y copy w vsphere ^a actions ^s snapshot esc back ^c quit"
|
||||
gutter = 2 // the pointer's two columns, in front of every row
|
||||
colSep = 2
|
||||
labelWidth = 12
|
||||
@@ -89,6 +98,19 @@ type vmRow struct {
|
||||
name string
|
||||
host string // ESXi host, short
|
||||
vm mo.VirtualMachine
|
||||
|
||||
// What the sweep learned about the machine besides its summary. Both are
|
||||
// carried on the row rather than asked for when they are shown: a column
|
||||
// that has to make a call to fill itself in is a column that makes the list
|
||||
// slow in proportion to how many machines are on the screen.
|
||||
snaps []snapEntry // its snapshots, as the sweep found them
|
||||
task *runningTask // what vCenter is doing to it right now, if anything
|
||||
|
||||
// trend is the machine's recent CPU load drawn as one column of text. It is
|
||||
// not read from anywhere: it is written here by live.go out of samples the
|
||||
// browser keeps across sweeps, because a row is thrown away and rebuilt by
|
||||
// every one of them and a history cannot live on something that short-lived.
|
||||
trend string
|
||||
}
|
||||
|
||||
// id is what makes this machine this machine. A name does not: two vCenters may
|
||||
@@ -200,11 +222,95 @@ func loadColor(pct float64, known bool) string {
|
||||
return colSize
|
||||
}
|
||||
|
||||
// The ages at which a snapshot stops being today's work. A snapshot taken for
|
||||
// this afternoon's patch run should be gone this evening; a week is where it
|
||||
// stops being that and starts being something nobody remembers taking. Thirty
|
||||
// days is also what `snap --old` reports on by default, so a red count in the
|
||||
// table means exactly "this machine is in that report".
|
||||
const (
|
||||
snapStaleDays = 7
|
||||
snapOldDays = 30
|
||||
)
|
||||
|
||||
// snapCount is how many snapshots the sweep found on the machine.
|
||||
func (r vmRow) snapCount() int { return len(r.snaps) }
|
||||
|
||||
// oldest is the snapshot that has been there longest, which is the one that
|
||||
// decides whether the machine needs attention. false when it has none, or when
|
||||
// none of them came back with a date.
|
||||
func (r vmRow) oldest() (snapEntry, bool) {
|
||||
var best snapEntry
|
||||
found := false
|
||||
for _, e := range r.snaps {
|
||||
if e.when.IsZero() {
|
||||
continue
|
||||
}
|
||||
if !found || e.when.Before(best.when) {
|
||||
best, found = e, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// snapAge is the age of that snapshot in days, in the shape the sort orders and
|
||||
// the colour thresholds want. A machine without snapshots has no age rather than
|
||||
// an age of zero — nothing to clean up is not the same as cleaned up a moment ago.
|
||||
func (r vmRow) snapAge() (float64, bool) {
|
||||
e, ok := r.oldest()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
d, ok := e.age()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return d.Hours() / 24, true
|
||||
}
|
||||
|
||||
// snapCell is the count as the table shows it, and snapColor ages it: past a
|
||||
// week the number turns yellow, past a month red. The count is what the column
|
||||
// says and the colour is how urgent it is, which is why the sheet spells the
|
||||
// date out — a colour cannot be read in a pipe.
|
||||
func (r vmRow) snapCell() string {
|
||||
if r.snapCount() == 0 {
|
||||
return "-"
|
||||
}
|
||||
return Itoa(r.snapCount())
|
||||
}
|
||||
|
||||
func (r vmRow) snapColor() string {
|
||||
if r.snapCount() == 0 {
|
||||
return colOff
|
||||
}
|
||||
days, ok := r.snapAge()
|
||||
switch {
|
||||
case !ok:
|
||||
return colChosen // there are snapshots, but no date to judge them by
|
||||
case days >= snapOldDays:
|
||||
return colFull
|
||||
case days >= snapStaleDays:
|
||||
return colBusy
|
||||
}
|
||||
return colSize
|
||||
}
|
||||
|
||||
// taskCell is what is being done to the machine, or nothing at all.
|
||||
func (r vmRow) taskCell() string {
|
||||
if r.task == nil {
|
||||
return ""
|
||||
}
|
||||
return r.task.cell()
|
||||
}
|
||||
|
||||
// haystack is what the filter matches against: everything on the line, so typing
|
||||
// an address or a host name narrows the list just as well as a name does.
|
||||
// an address or a host name narrows the list just as well as a name does — and
|
||||
// nothing that is not, because the filter's hit is picked out in the row and a
|
||||
// match on something invisible would leave a row in the list with no reason
|
||||
// showing anywhere on it.
|
||||
func (r vmRow) haystack() string {
|
||||
return strings.ToLower(strings.Join([]string{
|
||||
r.name, r.vc.Name, r.ip(), r.host, r.guestOS(), r.powerShort(),
|
||||
r.taskCell(),
|
||||
}, " "))
|
||||
}
|
||||
|
||||
@@ -221,6 +327,11 @@ type browseColumn struct {
|
||||
// narrow for all of them: 1 goes first. Zero means never — the name and the
|
||||
// power state are what a list of machines is for.
|
||||
//
|
||||
// Only the order matters, not the figures, and 2 is the trend column
|
||||
// (live.go) rather than anything here: it is given up before every fact
|
||||
// about a machine but after the guest's operating system, which is the least
|
||||
// read column in the table and the one a shape is worth more than.
|
||||
//
|
||||
// It is deliberately separate from the order the columns are shown in. The
|
||||
// two used to be the same thing, so what a narrow terminal lost was simply
|
||||
// whatever happened to be listed last, and the memory figures went before the
|
||||
@@ -238,28 +349,112 @@ var browseColumns = []browseColumn{
|
||||
// its place on a terminal of eighty.
|
||||
{header: "NAME", width: 22, flex: true, color: fixed(colName),
|
||||
cell: func(r vmRow) string { return r.name }},
|
||||
{header: "VC", width: 4, color: fixed(colWhere), expendable: 8,
|
||||
{header: "VC", width: 4, color: fixed(colWhere), expendable: 11,
|
||||
cell: func(r vmRow) string { return r.vc.Name }},
|
||||
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
|
||||
// How many rollback points the machine is dragging along, aged by colour.
|
||||
//
|
||||
// It is given up early — before the address, which is the column the widths
|
||||
// above were tuned around — because it is the one column here that has
|
||||
// somewhere else to be said. A snapshot old enough to matter is in ^w, in
|
||||
// `vm -l --issues` and in `snap --old`, all of which name it and date it; an
|
||||
// address has no other home. So a terminal of eighty gives up the count and
|
||||
// keeps the address, and the count is back from eighty-five.
|
||||
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 4},
|
||||
// The address is the widest thing here that is not a name, and it is a
|
||||
// lookup field: on a terminal this narrow nobody is looking an address up,
|
||||
// they are glancing at what is busy. So it goes before the small figures do.
|
||||
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 3},
|
||||
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
|
||||
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 5},
|
||||
{header: "HOST", width: 10, color: fixed(colAside), expendable: 3,
|
||||
cell: func(r vmRow) string { return r.host }},
|
||||
{header: "CPU", width: 3, color: fixed(colSize), expendable: 4,
|
||||
{header: "CPU", width: 3, color: fixed(colSize), expendable: 6,
|
||||
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
|
||||
{header: "CPU%", width: 4, expendable: 7,
|
||||
{header: "CPU%", width: 4, expendable: 10,
|
||||
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
|
||||
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
|
||||
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 5},
|
||||
{header: "MEM%", width: 4, expendable: 6,
|
||||
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 7},
|
||||
{header: "MEM%", width: 4, expendable: 9,
|
||||
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
|
||||
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
|
||||
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
|
||||
expendable: 1},
|
||||
}
|
||||
|
||||
// taskColumn and whyColumn are the two columns that are not always there.
|
||||
//
|
||||
// Every other column holds a property of a machine and can say "-" when the
|
||||
// machine has none. These two hold an exception, and an exception has no "-":
|
||||
// a column that is blank down two hundred rows is thirteen characters of width
|
||||
// spent on nothing. So they exist only while there is something in them — the
|
||||
// task column when anything at all is being done on the cluster, the reason
|
||||
// column only in the listing that is made of reasons (^i, --issues).
|
||||
//
|
||||
// A column appearing because somebody started a clone is not the layout
|
||||
// shifting about: it is the news. What must not happen — a column coming back
|
||||
// as the terminal is dragged *narrower* — is fitColumns' business, and this
|
||||
// does not touch it.
|
||||
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 8,
|
||||
cell: vmRow.taskCell, color: fixed(colBusy)}
|
||||
|
||||
// The reason outranks every other column but the machine's name and its power
|
||||
// state: in a listing whose every row is there because of it, giving it up
|
||||
// first — which is what inheriting the guest operating system's rank would have
|
||||
// done — leaves a list of machines with no reason showing on any of them. It is
|
||||
// still expendable rather than fixed, so that a terminal too narrow for it
|
||||
// falls down the same ladder as everything else instead of into the one-column
|
||||
// fallback.
|
||||
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 12,
|
||||
cell: vmRow.issueCell, color: vmRow.issueColor}
|
||||
|
||||
// The figures the issues listing leaves out. A machine is in that list because
|
||||
// something is wrong with it, and how hard its processors happen to be working
|
||||
// at this second says nothing about any of the reasons — it is four columns of
|
||||
// arithmetic between the machine's name and the answer to the question that was
|
||||
// asked. They are one keystroke away in the ordinary list, and on the machine's
|
||||
// own sheet.
|
||||
var issueHides = []string{"CPU", "CPU%", "MEM", "MEM%"}
|
||||
|
||||
// listColumns is the table for this particular listing: the standing columns,
|
||||
// with the two conditional ones put in where they belong. The task goes next to
|
||||
// the power state, because both answer "what is this machine doing"; the reason
|
||||
// takes the guest operating system's place, which is the least read column there
|
||||
// is and the only one wide enough to hold a sentence.
|
||||
func listColumns(rows []vmRow, why bool) []browseColumn {
|
||||
busy := false
|
||||
for _, r := range rows {
|
||||
if r.task != nil {
|
||||
busy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
trend := anyTrend(rows)
|
||||
|
||||
out := make([]browseColumn, 0, len(browseColumns)+2)
|
||||
for _, c := range browseColumns {
|
||||
if why {
|
||||
if c.header == "GUEST OS" {
|
||||
out = append(out, whyColumn)
|
||||
continue
|
||||
}
|
||||
if contains(issueHides, c.header) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
if busy && c.header == "PWR" {
|
||||
out = append(out, taskColumn)
|
||||
}
|
||||
// Next to the figure it is the history of, which is where the eye
|
||||
// already is when it wonders whether 80 per cent is where the machine
|
||||
// has been sitting all morning or where it went a moment ago.
|
||||
if trend && c.header == "CPU%" {
|
||||
out = append(out, trendColumn)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fitColumns decides which columns a terminal of this width shows: give up the
|
||||
// least useful until what is left fits, then hand the width left over to the ones
|
||||
// allowed to grow. The columns keep the order they are declared in; what changes
|
||||
@@ -272,21 +467,26 @@ var browseColumns = []browseColumn{
|
||||
// improvement — makes a narrower terminal show *more* columns than a wider one,
|
||||
// and a column that appears as the window shrinks is worse than a column that is
|
||||
// simply gone. Where a column deserves to survive longer, its rank says so.
|
||||
func fitColumns(width int) []browseColumn {
|
||||
shown := make([]bool, len(browseColumns))
|
||||
func fitColumns(width int) []browseColumn { return fitColumnsOf(browseColumns, width) }
|
||||
|
||||
// fitColumnsOf is that, for a table that is not the standing one: the listing
|
||||
// may have a task column in it, or a reason column instead of the guest's
|
||||
// operating system (see listColumns).
|
||||
func fitColumnsOf(cols []browseColumn, width int) []browseColumn {
|
||||
shown := make([]bool, len(cols))
|
||||
for i := range shown {
|
||||
shown[i] = true
|
||||
}
|
||||
|
||||
for shownWidth(shown) > width {
|
||||
i := leastUseful(shown)
|
||||
for shownWidth(cols, shown) > width {
|
||||
i := leastUseful(cols, shown)
|
||||
if i < 0 {
|
||||
break // nothing left that may be given up
|
||||
}
|
||||
shown[i] = false
|
||||
}
|
||||
chosen := make([]browseColumn, 0, len(browseColumns))
|
||||
for i, c := range browseColumns {
|
||||
chosen := make([]browseColumn, 0, len(cols))
|
||||
for i, c := range cols {
|
||||
if shown[i] {
|
||||
chosen = append(chosen, c)
|
||||
}
|
||||
@@ -295,7 +495,7 @@ func fitColumns(width int) []browseColumn {
|
||||
// A terminal too narrow even for what may not be given up: the name, cut to
|
||||
// whatever there is. Better one column of truth than a row that wraps.
|
||||
if tableWidth(chosen) > width {
|
||||
first := browseColumns[0]
|
||||
first := cols[0]
|
||||
first.width = max(width, 1)
|
||||
return []browseColumn{first}
|
||||
}
|
||||
@@ -331,12 +531,12 @@ func tableWidth(cs []browseColumn) int {
|
||||
return w
|
||||
}
|
||||
|
||||
func shownWidth(shown []bool) int {
|
||||
func shownWidth(cols []browseColumn, shown []bool) int {
|
||||
n, w := 0, 0
|
||||
for i, on := range shown {
|
||||
if on {
|
||||
n++
|
||||
w += browseColumns[i].width
|
||||
w += cols[i].width
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
@@ -347,10 +547,10 @@ func shownWidth(shown []bool) int {
|
||||
|
||||
// leastUseful is the column to give up next, or -1 when every one that is left
|
||||
// may not be.
|
||||
func leastUseful(shown []bool) int {
|
||||
func leastUseful(cols []browseColumn, shown []bool) int {
|
||||
at, worst := -1, 0
|
||||
for i, on := range shown {
|
||||
e := browseColumns[i].expendable
|
||||
e := cols[i].expendable
|
||||
if !on || e == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -381,6 +581,22 @@ type sweep struct {
|
||||
lost []string // their names alone, for the title
|
||||
}
|
||||
|
||||
// sweepProps is what one pass reads off every machine. It is one property
|
||||
// collector call per vCenter whatever is in this list, so the question for each
|
||||
// entry is not "does it cost a round trip" but "is it small enough to carry for
|
||||
// every machine in the inventory".
|
||||
//
|
||||
// summary, guest the table and most of the sheet
|
||||
// snapshot the snapshot column, the age report and --issues; a
|
||||
// tree of names and dates, and empty for most machines
|
||||
// triggeredAlarmState what vCenter itself is complaining about
|
||||
// recentTask what is being done to the machine (tasks.go)
|
||||
//
|
||||
// layoutEx is deliberately not here: it lists every file of every machine, which
|
||||
// is a great deal of wire for a figure only the snapshot report wants, and that
|
||||
// report asks for it separately, for the machines that actually have snapshots.
|
||||
var sweepProps = []string{"summary", "guest", "snapshot", "triggeredAlarmState", "recentTask"}
|
||||
|
||||
// gatherVMs asks every server at once and returns what came back. A server that
|
||||
// does not answer costs a line in the report, not the listing.
|
||||
func gatherVMs(targets []VCenter) (sweep, error) {
|
||||
@@ -425,6 +641,11 @@ func gatherVMs(targets []VCenter) (sweep, error) {
|
||||
}
|
||||
return s.rows[a].vc.Name < s.rows[b].vc.Name
|
||||
})
|
||||
|
||||
// Every sweep leaves the names behind for the shell to complete against
|
||||
// (complete.go). Nothing reads them back but the completion, and it costs
|
||||
// one small file write per run of gvm.
|
||||
saveInventory(s.answered, s.rows)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -436,19 +657,28 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows, err := sweepOne(vc, s)
|
||||
return rows, s, err
|
||||
}
|
||||
|
||||
vms, err := s.vms("summary", "guest")
|
||||
// sweepOne is that pass over one server, over a connection that is already
|
||||
// open. It is split out for live mode (live.go), which re-reads the same
|
||||
// properties every few seconds and must not log in again to do it.
|
||||
func sweepOne(vc VCenter, s *session) ([]vmRow, error) {
|
||||
vms, err := s.vms(sweepProps...)
|
||||
if err != nil {
|
||||
return nil, s, err
|
||||
return nil, err
|
||||
}
|
||||
hosts, err := s.hosts("name")
|
||||
if err != nil {
|
||||
return nil, s, err
|
||||
return nil, err
|
||||
}
|
||||
hostName := make(map[types.ManagedObjectReference]string, len(hosts))
|
||||
for _, h := range hosts {
|
||||
hostName[h.Reference()] = shortHost(h.Name)
|
||||
}
|
||||
busy := runningTasks(s, vms)
|
||||
s.alarms = s.alarmNames(triggeredAlarms(vms))
|
||||
|
||||
rows := make([]vmRow, 0, len(vms))
|
||||
for _, vm := range vms {
|
||||
@@ -462,9 +692,31 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
|
||||
host = n
|
||||
}
|
||||
}
|
||||
rows = append(rows, vmRow{vc: vc, sess: s, ref: vm.Reference(), name: name, host: host, vm: vm})
|
||||
r := vmRow{vc: vc, sess: s, ref: vm.Reference(), name: name, host: host, vm: vm,
|
||||
snaps: snapshotsIn(vm.Snapshot)}
|
||||
if t, ok := busy[vm.Reference()]; ok {
|
||||
r.task = &t
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
return rows, s, nil
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// triggeredAlarms is every alarm definition this sweep saw complaining, once
|
||||
// each. Nothing is asked of the server when nothing is alarming, which is the
|
||||
// ordinary case.
|
||||
func triggeredAlarms(vms []mo.VirtualMachine) []types.ManagedObjectReference {
|
||||
seen := map[types.ManagedObjectReference]bool{}
|
||||
var out []types.ManagedObjectReference
|
||||
for _, vm := range vms {
|
||||
for _, a := range vm.TriggeredAlarmState {
|
||||
if !seen[a.Alarm] {
|
||||
seen[a.Alarm] = true
|
||||
out = append(out, a.Alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func closeSessions(sessions []*session) {
|
||||
@@ -482,8 +734,12 @@ type browser struct {
|
||||
|
||||
view []int // indexes into rows: what the filter left
|
||||
filter string
|
||||
sel int // index into view
|
||||
scroll int
|
||||
// issuesOnly narrows the list to the machines with something wrong with them
|
||||
// (issues.go). It is a second filter rather than a mode of its own: the typed
|
||||
// filter still applies inside it, so "^w web" is the broken web servers.
|
||||
issuesOnly bool
|
||||
sel int // index into view
|
||||
scroll int
|
||||
|
||||
sortBy int // which of sortOrders the rows are in (sort.go)
|
||||
sortDesc bool // that order reversed
|
||||
@@ -491,6 +747,23 @@ type browser struct {
|
||||
answered []string // the vCenters this list actually holds
|
||||
lost []string // and the ones it does not, because they did not answer
|
||||
|
||||
// The events of one machine, once somebody has asked for them (actions.go).
|
||||
// They are kept against the machine they belong to, so arrowing on to the
|
||||
// next machine does not show it somebody else's history.
|
||||
events []eventLine
|
||||
eventsOf string // the id of the machine they are of
|
||||
|
||||
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
|
||||
|
||||
// Live mode (live.go): the list re-reading itself on a timer rather than on
|
||||
// a keystroke. hist is what the trend column is drawn from, kept here
|
||||
// because every sweep throws the rows away and builds new ones.
|
||||
live bool
|
||||
liveNext time.Time
|
||||
liveGap time.Duration // what the pending tick was scheduled with, for the title
|
||||
hist map[string][]float64
|
||||
saidLive string // the last changed line, so a quiet tick clears only its own
|
||||
|
||||
detail []sheetLine // non-nil while a machine's sheet is on screen
|
||||
dtitle string
|
||||
dvc string // the vCenter, in the title, in the colour its column has
|
||||
@@ -504,10 +777,12 @@ type browser struct {
|
||||
// underneath without having to remember where it came from.
|
||||
menu []menuItem
|
||||
menuSnaps []snapEntry
|
||||
menuSize sizing // what the machine has, for the two hardware entries
|
||||
menuInfo []sheetLine // the few lines of the sheet shown above the choices
|
||||
menuSel int
|
||||
pick *picker
|
||||
confirm *confirmation
|
||||
estate *estate // the whole estate on one screen (estate.go)
|
||||
|
||||
status string
|
||||
statusCol string
|
||||
@@ -520,7 +795,11 @@ type browser struct {
|
||||
}
|
||||
|
||||
// browseVMs is the command: gather, then hand the terminal over to the loop.
|
||||
func browseVMs(targets []VCenter, filter string) error {
|
||||
//
|
||||
// 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 {
|
||||
// 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
|
||||
@@ -529,7 +808,7 @@ func browseVMs(targets []VCenter, filter string) error {
|
||||
return errf("the interactive list needs a terminal (%v) — use 'gvm vm -l' instead", err)
|
||||
}
|
||||
|
||||
b := &browser{targets: targets, filter: filter}
|
||||
b := &browser{targets: targets, filter: filter, ssh: ssh}
|
||||
defer func() { closeSessions(b.sessions) }()
|
||||
|
||||
PF("asking %s ...\n", vcNames(targets))
|
||||
@@ -598,7 +877,19 @@ func (b *browser) loop() {
|
||||
for {
|
||||
b.render()
|
||||
|
||||
k := b.keys.next()
|
||||
// A key, or the next refresh, whichever comes first. Only where a tick
|
||||
// is allowed to happen at all (liveReady): with a menu or a half-typed
|
||||
// answer on screen the read blocks the way it always has.
|
||||
k := key{}
|
||||
if b.liveReady() {
|
||||
var ok bool
|
||||
if k, ok = b.keys.nextWithin(b.liveIn()); !ok {
|
||||
b.liveTick()
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
k = b.keys.next()
|
||||
}
|
||||
if k.special == keyCtrlC {
|
||||
return
|
||||
}
|
||||
@@ -610,6 +901,8 @@ func (b *browser) loop() {
|
||||
b.pickerKey(k)
|
||||
case b.menu != nil:
|
||||
b.menuKey(k)
|
||||
case b.estate != nil:
|
||||
b.estateKey(k)
|
||||
case b.detail != nil:
|
||||
b.detailKey(k)
|
||||
default:
|
||||
@@ -631,6 +924,8 @@ func (b *browser) render() {
|
||||
b.renderPicker()
|
||||
case b.menu != nil:
|
||||
b.renderMenu()
|
||||
case b.estate != nil:
|
||||
b.renderEstate()
|
||||
case b.detail != nil:
|
||||
b.renderDetail()
|
||||
default:
|
||||
@@ -667,6 +962,8 @@ func (b *browser) listKey(k key) bool {
|
||||
b.openDetail()
|
||||
case keyCtrlO:
|
||||
b.sortPrompt()
|
||||
case keyCtrlW:
|
||||
b.toggleIssues()
|
||||
case keyCtrlA, keyCtrlS:
|
||||
// Nothing acts on a machine from the list. The cursor sits on a row that
|
||||
// is one line of a table, and a table of two hundred machines is read by
|
||||
@@ -678,6 +975,10 @@ func (b *browser) listKey(k key) bool {
|
||||
b.setStatus(colDim, "open the machine first: ⏎ for its details, then ^a")
|
||||
case keyCtrlR:
|
||||
b.reload()
|
||||
case keyCtrlL:
|
||||
b.toggleLive()
|
||||
case keyCtrlE:
|
||||
b.openEstate()
|
||||
case keyBackspace:
|
||||
if r := []rune(b.filter); len(r) > 0 {
|
||||
b.filter = string(r[:len(r)-1])
|
||||
@@ -702,11 +1003,19 @@ func (b *browser) detailKey(k key) {
|
||||
switch k.special {
|
||||
case keyEsc, keyEnter, keyBackspace, keyLeft:
|
||||
b.detail, b.dscroll = nil, 0
|
||||
// The events go with the visit, not with the machine: coming back to a
|
||||
// sheet half an hour later and finding half-hour-old events under a
|
||||
// label that says nothing about when they were read would be the one
|
||||
// stale thing on an otherwise freshly read screen. They are one
|
||||
// keystroke away again.
|
||||
b.events, b.eventsOf = nil, ""
|
||||
b.setStatus("", "")
|
||||
case keyCtrlA:
|
||||
b.openMenu()
|
||||
case keyCtrlS:
|
||||
b.snapshot()
|
||||
case keyRune:
|
||||
b.detailRune(k.r)
|
||||
case keyUp:
|
||||
b.dscroll = max(b.dscroll-1, 0)
|
||||
case keyDown:
|
||||
@@ -722,6 +1031,32 @@ func (b *browser) detailKey(k key) {
|
||||
}
|
||||
}
|
||||
|
||||
// detailRune answers the sheet's own letters: the four things that only read the
|
||||
// machine or point somewhere else at it. They are letters here rather than
|
||||
// entries at the foot of the action menu because nothing they do needs thinking
|
||||
// about first, and the sheet — unlike the list — has no filter to swallow them.
|
||||
// A letter that means nothing here is ignored, not complained about.
|
||||
func (b *browser) detailRune(r rune) {
|
||||
row := b.current()
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
switch r {
|
||||
case 'e':
|
||||
b.showEvents(*row)
|
||||
case 'h':
|
||||
if b.hasAddress(*row) {
|
||||
b.sshTo(*row)
|
||||
}
|
||||
case 'y':
|
||||
if b.hasAddress(*row) {
|
||||
b.copyAddress(*row)
|
||||
}
|
||||
case 'w':
|
||||
b.openVSphere(*row)
|
||||
}
|
||||
}
|
||||
|
||||
// refilter rebuilds the visible set. The selection stays on the machine it was
|
||||
// on where that machine is still in the list, which is what makes typing a few
|
||||
// letters and pressing enter feel like one motion.
|
||||
@@ -734,6 +1069,9 @@ func (b *browser) refilter() {
|
||||
needle := strings.ToLower(strings.TrimSpace(b.filter))
|
||||
b.view = b.view[:0]
|
||||
for i, r := range b.rows {
|
||||
if b.issuesOnly && !r.hasIssues() {
|
||||
continue
|
||||
}
|
||||
if needle == "" || strings.Contains(r.haystack(), needle) {
|
||||
b.view = append(b.view, i)
|
||||
}
|
||||
@@ -750,11 +1088,54 @@ func (b *browser) refilter() {
|
||||
}
|
||||
}
|
||||
|
||||
// toggleIssues turns the issues filter on and off. Turning it on says how many
|
||||
// machines there are to answer for, because none at all is the answer one hopes
|
||||
// for and an empty screen on its own does not read as good news.
|
||||
func (b *browser) toggleIssues() {
|
||||
b.issuesOnly = !b.issuesOnly
|
||||
b.refilter()
|
||||
|
||||
if !b.issuesOnly {
|
||||
b.setStatus(colInfo, SF("all %d machines", len(b.rows)))
|
||||
return
|
||||
}
|
||||
switch n := len(withIssues(b.rows)); n {
|
||||
case 0:
|
||||
b.setStatus(colOK, "nothing to report on any of them")
|
||||
case 1:
|
||||
b.setStatus(colWarn, "1 machine wants looking at")
|
||||
default:
|
||||
b.setStatus(colWarn, SF("%d machines want looking at", n))
|
||||
}
|
||||
}
|
||||
|
||||
// columns is the table as this list is showing it right now: the standing
|
||||
// columns, a task column while anything is running, and the reason column in
|
||||
// place of the guest's operating system while the issues filter is on.
|
||||
//
|
||||
// It is asked of every row, not of the ones the filter left, so that narrowing
|
||||
// the list cannot make a column come and go under the cursor.
|
||||
func (b *browser) columns() []browseColumn { return listColumns(b.rows, b.issuesOnly) }
|
||||
|
||||
// current is the machine under the cursor, or nil where there is none.
|
||||
//
|
||||
// Both steps are bounds-checked, and the second is not paranoia: the view holds
|
||||
// indexes into the rows, and there is a moment in every refresh — the rows
|
||||
// replaced, the view not yet rebuilt — when an index of the old list points
|
||||
// past the end of the new one. refilter itself asks this question in that
|
||||
// moment, to remember which machine the cursor was on. Checking only the view's
|
||||
// own length left a panic there that needed nothing but a machine disappearing
|
||||
// from the inventory while the cursor was near the end of the list: rare with
|
||||
// ^r, and every ten seconds with live mode.
|
||||
func (b *browser) current() *vmRow {
|
||||
if b.sel < 0 || b.sel >= len(b.view) {
|
||||
return nil
|
||||
}
|
||||
return &b.rows[b.view[b.sel]]
|
||||
i := b.view[b.sel]
|
||||
if i < 0 || i >= len(b.rows) {
|
||||
return nil
|
||||
}
|
||||
return &b.rows[i]
|
||||
}
|
||||
|
||||
func (b *browser) move(n int) {
|
||||
@@ -780,6 +1161,7 @@ func (b *browser) reload() {
|
||||
b.rows, b.sessions = found.rows, found.sessions
|
||||
b.answered, b.lost = found.answered, found.lost
|
||||
b.applySort() // which refilters; the order the table was in survives a reload
|
||||
b.sample() // a reload is a sweep, and the trend column is drawn from sweeps
|
||||
closeSessions(old)
|
||||
|
||||
if len(found.failed) > 0 {
|
||||
@@ -940,12 +1322,17 @@ 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 string
|
||||
}
|
||||
|
||||
// ask puts one question on the status line and waits for a single key. Only "y"
|
||||
// means yes — every other key, Esc and Ctrl-C included, means no.
|
||||
func (b *browser) ask(question string) bool {
|
||||
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
|
||||
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colPrompt}
|
||||
b.render()
|
||||
k := b.keys.next()
|
||||
b.prompt = nil
|
||||
@@ -976,7 +1363,7 @@ func segLine(sb *strings.Builder, cols int, segs ...seg) {
|
||||
|
||||
func (b *browser) renderList() {
|
||||
cols, rows := termSize()
|
||||
cs := fitColumns(cols - gutter)
|
||||
cs := fitColumnsOf(b.columns(), cols-gutter)
|
||||
|
||||
const head = 3 // title, filter, column headers
|
||||
const foot = 2 // status, help
|
||||
@@ -1003,6 +1390,11 @@ func (b *browser) renderList() {
|
||||
{colDim, " on "},
|
||||
{colWhere, strings.Join(b.answered, ", ")}, // the colour the VC column has
|
||||
}
|
||||
// A filtered list that does not say so is a lie told by omission: the count
|
||||
// in front of it is of every machine, and the rows underneath are not.
|
||||
if b.issuesOnly {
|
||||
title = append(title, seg{colDim, " "}, seg{colWarn, "issues only"})
|
||||
}
|
||||
if len(b.lost) > 0 {
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
@@ -1011,6 +1403,18 @@ func (b *browser) renderList() {
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
seg{colMatch, b.sortLabel()}) // pink: the one thing here that was chosen
|
||||
// A screen that changes on its own has to say so, and how often: a list
|
||||
// that moves under somebody's hands with nothing to explain it reads as a
|
||||
// fault. The busy colour, because that is what the task column wears.
|
||||
if b.live {
|
||||
// The gap the pending tick was scheduled with, not one worked out again
|
||||
// here: a clone that started in the meantime would make the title
|
||||
// promise every two seconds while the refresh that is actually pending
|
||||
// is nine seconds away.
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
seg{colBusy, SF("live %s", b.liveGap)})
|
||||
}
|
||||
segLine(&sb, cols, title...)
|
||||
|
||||
if b.filter == "" {
|
||||
@@ -1053,23 +1457,30 @@ func (b *browser) renderList() {
|
||||
seg{colDim, b.prompt.hint})
|
||||
case b.status != "":
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
case len(b.view) == 0 && b.issuesOnly && b.filter == "":
|
||||
segLine(&sb, cols, seg{colOK, "nothing to report on any of them — ^w for all of them again"})
|
||||
case len(b.view) == 0:
|
||||
segLine(&sb, cols, seg{colDim, "nothing matches"})
|
||||
default:
|
||||
segLine(&sb, cols)
|
||||
}
|
||||
sb.WriteString(colDim + truncate(listHelp, cols) + attrOff + scrEOL)
|
||||
help, helpCol := listHelp, colDim
|
||||
if b.prompt != nil && b.prompt.more != "" {
|
||||
help, helpCol = b.prompt.more, b.prompt.col
|
||||
}
|
||||
sb.WriteString(helpCol + truncate(help, cols) + attrOff + scrEOL)
|
||||
b.parkCursor(&sb, cols, rows)
|
||||
|
||||
b.write(sb.String())
|
||||
}
|
||||
|
||||
// editLine draws the input in the status area: the label stays put, the typed
|
||||
// text is the query colour, and a hint says what Enter alone would do.
|
||||
// editLine draws the input in the status area: the label stays put in the colour
|
||||
// every question at the foot of the screen has, and the typed text is the query
|
||||
// colour, because it is the answer and not part of the question.
|
||||
func (b *browser) editLine(sb *strings.Builder, cols int) {
|
||||
segLine(sb, cols,
|
||||
seg{colWarn, b.edit.label},
|
||||
seg{colQuery, string(b.edit.runes)})
|
||||
seg{colPrompt, b.edit.label},
|
||||
seg{colQuery, string(b.edit.runes)}) // the answer is the operator's, and white
|
||||
}
|
||||
|
||||
// parkCursor puts the terminal's own cursor where the typing happens and shows
|
||||
@@ -1269,6 +1680,13 @@ func sheetFor(sheet []sheetLine, cols int) []sheetLine {
|
||||
func (b *browser) openDetail() {
|
||||
r := b.current()
|
||||
if r == nil {
|
||||
// Nothing to show it of. A sheet that is already open is then a page of
|
||||
// facts about a machine that has gone — deleted, or on a vCenter that
|
||||
// stopped answering — and leaving it on the screen would be the one
|
||||
// thing worse than closing it: an entire screen of stale truth with
|
||||
// nothing to say it is stale. It is reached by a live refresh, which
|
||||
// rebuilds an open sheet without anybody pressing anything.
|
||||
b.detail, b.dscroll = nil, 0
|
||||
return
|
||||
}
|
||||
b.dtitle = r.name
|
||||
@@ -1276,9 +1694,31 @@ func (b *browser) openDetail() {
|
||||
b.dwhere = SF(" · %s · %s", r.vc.Datacenter, r.host)
|
||||
snaps, snapCol := snapshotLines(*r)
|
||||
b.detail = vmDetail(*r, snaps, snapCol)
|
||||
if b.eventsOf == r.id() {
|
||||
b.detail = append(b.detail, eventSheet(b.events)...)
|
||||
}
|
||||
b.dscroll = 0
|
||||
}
|
||||
|
||||
// eventSheet is the history as sheet lines: the label on the first, each line in
|
||||
// the colour of its own severity. It is appended to the sheet rather than built
|
||||
// into vmDetail because vmDetail asks nothing of the network and this is the one
|
||||
// section that had to be fetched.
|
||||
func eventSheet(events []eventLine) []sheetLine {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]sheetLine, 0, len(events))
|
||||
for i, e := range events {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "events"
|
||||
}
|
||||
out = append(out, sheetLine{label: label, value: e.text, col: e.col})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// vmDetail is the parameter sheet of one machine. snaps is what snapshotLines
|
||||
// found, passed in rather than fetched here so that the sheet itself asks no
|
||||
// questions and can be built from a machine that was never connected to.
|
||||
@@ -1331,6 +1771,25 @@ func vmDetail(r vmRow, snaps []string, snapCol string) []sheetLine {
|
||||
// eye should find without reading.
|
||||
kv("state", join(state), r.powerColor())
|
||||
|
||||
// What is wrong with the machine, and what is being done to it: the two
|
||||
// facts that were true a minute ago rather than since the machine was built,
|
||||
// and the only reason anyone opens a sheet in a hurry. Each reason keeps its
|
||||
// own colour — a filesystem at 97 % and a missing VMware Tools are not the
|
||||
// same news, and painting the pair in one colour would say they were.
|
||||
for i, is := range r.issueList() {
|
||||
col, label := colBusy, ""
|
||||
if is.bad {
|
||||
col = colFull
|
||||
}
|
||||
if i == 0 {
|
||||
label = "issues"
|
||||
}
|
||||
out = append(out, sheetLine{label: label, value: is.text, col: col})
|
||||
}
|
||||
if r.task != nil {
|
||||
kv("task", r.task.line(), colBusy)
|
||||
}
|
||||
|
||||
kv("guest", r.guestOS(), colValue)
|
||||
if g := r.vm.Guest; g != nil {
|
||||
kv("hostname", g.HostName, colValue)
|
||||
@@ -1485,23 +1944,44 @@ func appendIf(list []string, values ...string) []string {
|
||||
// snapshotLines asks this one machine for its snapshots, drawn as the tree they
|
||||
// are. The colour comes back with them: a name someone chose is not the same kind
|
||||
// of thing as an absence of one, or as a failure to look.
|
||||
//
|
||||
// Where the machine cannot be asked, what the sweep found is shown instead,
|
||||
// with a line saying that is what it is. The alternative is a sheet reading
|
||||
// "not read" beside a table that says the machine has three of them, which
|
||||
// leaves the operator to work out which of the two to believe — and the answer
|
||||
// would be "both": the sweep did find three, and this could not confirm it.
|
||||
func snapshotLines(r vmRow) ([]string, string) {
|
||||
if r.sess == nil {
|
||||
return []string{"not read"}, colOff
|
||||
return staleSnapshots(r, "not read: no connection to "+r.vc.Name)
|
||||
}
|
||||
entries, err := snapshotsOf(r.sess, r.ref)
|
||||
if err != nil {
|
||||
return []string{err.Error()}, colErr
|
||||
return staleSnapshots(r, err.Error())
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return []string{"none"}, colOff
|
||||
}
|
||||
return snapshotTree(entries), colChosen
|
||||
}
|
||||
|
||||
// staleSnapshots is what the last sweep found, under the reason it could not be
|
||||
// asked again. Nothing here is safe to act on — everything that removes or
|
||||
// reverts a snapshot reads the tree itself, over a connection it has — so this
|
||||
// is a report and says so.
|
||||
func staleSnapshots(r vmRow, why string) ([]string, string) {
|
||||
if len(r.snaps) == 0 {
|
||||
return []string{why}, colOff
|
||||
}
|
||||
return append([]string{why + "; as the last sweep found them:"},
|
||||
snapshotTree(r.snaps)...), colWarn
|
||||
}
|
||||
|
||||
func snapshotTree(entries []snapEntry) []string {
|
||||
out := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = e.line()
|
||||
}
|
||||
return out, colChosen
|
||||
return out
|
||||
}
|
||||
|
||||
func uptime(d time.Duration) string {
|
||||
|
||||
+481
-6
@@ -251,6 +251,135 @@ func TestListViewHasNoActionKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The four that only read a machine are the sheet's own letters now, not entries
|
||||
// at the foot of the action menu. Each one has to reach its own action from
|
||||
// there, and a letter that means nothing must be dropped rather than answered.
|
||||
func TestSheetLettersReachTheirActions(t *testing.T) {
|
||||
// Said to be an ssh login, which keeps the test off the clipboard of whoever
|
||||
// is running it: with no local tool to hand, `y` uses the terminal's own
|
||||
// escape sequence and nothing else.
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
|
||||
// That sequence goes to the terminal, so the sheet needs one to write to.
|
||||
pipe := func(b *browser) func() string {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = w
|
||||
return func() string {
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
return string(out)
|
||||
}
|
||||
}
|
||||
|
||||
// y copies. It is the one of the four that needs neither a session nor a
|
||||
// browser, so it is checked all the way through.
|
||||
b := testBrowser("web01")
|
||||
read := pipe(b)
|
||||
b.detailRune('y')
|
||||
if got := read(); !strings.Contains(got, "\x1b]52;c;") {
|
||||
t.Errorf("y sent no clipboard sequence: %q", got)
|
||||
}
|
||||
// What the line says about which clipboard is TestCopyAddressSaysWhatWentWhere's
|
||||
// business; here it only has to name the thing that was copied.
|
||||
for _, want := range []string{"clipboard", "web01.example"} {
|
||||
if !strings.Contains(b.status, want) {
|
||||
t.Errorf("y said %q, which does not mention %q", b.status, want)
|
||||
}
|
||||
}
|
||||
|
||||
// e and w need the connection the machine was read over, and these rows have
|
||||
// none: what matters is that the letter arrived at the action, which says so.
|
||||
for _, c := range []struct {
|
||||
k rune
|
||||
want string
|
||||
}{{'e', "v308"}, {'w', "v308"}} {
|
||||
b := testBrowser("web01")
|
||||
read := pipe(b)
|
||||
b.detailRune(c.k)
|
||||
read()
|
||||
if !strings.Contains(b.status, c.want) {
|
||||
t.Errorf("%q on a machine with no connection said %q", string(c.k), b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A letter nothing is bound to is ignored — silently, because the sheet is
|
||||
// not a filter and there is nothing to correct.
|
||||
b = testBrowser("web01")
|
||||
b.setStatus("", "")
|
||||
b.detailRune('q')
|
||||
if b.status != "" {
|
||||
t.Errorf("an unbound letter said %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// The view holds indexes into the rows, and every refresh has a moment where
|
||||
// the rows have been replaced and the view has not been rebuilt yet. refilter
|
||||
// asks which machine the cursor was on in exactly that moment, so a machine
|
||||
// disappearing from the inventory while the cursor sat near the end of the list
|
||||
// used to take gvm down with an index out of range — rare with ^r, and every
|
||||
// ten seconds once live mode ticks on its own.
|
||||
func TestASweepThatCameBackShorterDoesNotPanic(t *testing.T) {
|
||||
b := testBrowser("web01", "db01", "app07", "mail02")
|
||||
b.applySort()
|
||||
b.sel = len(b.view) - 1 // where somebody scrolling to the bottom leaves it
|
||||
|
||||
// What a reload and a live tick both do: the rows in place, then sort and
|
||||
// refilter against a view that still describes the longer list.
|
||||
b.rows = b.rows[:1]
|
||||
b.applySort()
|
||||
|
||||
if len(b.view) != 1 || b.sel != 0 {
|
||||
t.Errorf("the shorter list left view %v and the cursor at %d", b.view, b.sel)
|
||||
}
|
||||
if cur := b.current(); cur == nil {
|
||||
t.Error("there is a machine left, and the cursor is on none of them")
|
||||
}
|
||||
|
||||
// And with nothing left at all, the cursor is on nothing rather than on
|
||||
// memory that is no longer there.
|
||||
b.rows = nil
|
||||
b.applySort()
|
||||
if cur := b.current(); cur != nil {
|
||||
t.Errorf("an empty list has a machine under the cursor: %q", cur.name)
|
||||
}
|
||||
|
||||
// A stale view against empty rows is the same question asked the other way
|
||||
// round, and it is the one refilter walks into.
|
||||
b.view = []int{0, 1, 2}
|
||||
if cur := b.current(); cur != nil {
|
||||
t.Errorf("a view pointing past the rows gave back %q", cur.name)
|
||||
}
|
||||
}
|
||||
|
||||
// A sheet is not left on screen for a machine that has gone. It is reached by a
|
||||
// live refresh, which rebuilds an open sheet with nobody pressing anything: a
|
||||
// whole screen of facts about something that no longer exists, with nothing on
|
||||
// it to say so, is worse than no screen.
|
||||
func TestTheSheetClosesWhenItsMachineGoes(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
b.openDetail()
|
||||
if b.detail == nil {
|
||||
t.Fatal("no sheet to begin with")
|
||||
}
|
||||
was := b.dtitle
|
||||
|
||||
b.rows = nil
|
||||
b.refilter()
|
||||
b.openDetail() // what a live tick does
|
||||
|
||||
if b.detail != nil {
|
||||
t.Errorf("the sheet of %q is still on screen with %d lines", was, len(b.detail))
|
||||
}
|
||||
if b.dscroll != 0 {
|
||||
t.Errorf("the closed sheet kept a scroll position of %d", b.dscroll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSheetHasTheParameters(t *testing.T) {
|
||||
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
|
||||
|
||||
@@ -913,8 +1042,9 @@ func TestColumnLadder(t *testing.T) {
|
||||
// each rung are given, so a width change moves the boundary and not only the
|
||||
// example.
|
||||
const (
|
||||
all = "NAME VC PWR IP HOST CPU CPU% MEM MEM% GUEST OS"
|
||||
nine = "NAME VC PWR IP HOST CPU CPU% MEM MEM%"
|
||||
all = "NAME VC PWR SNAP IP HOST CPU CPU% MEM MEM% GUEST OS"
|
||||
ten = "NAME VC PWR SNAP IP HOST CPU CPU% MEM MEM%"
|
||||
nine = "NAME VC PWR SNAP IP CPU CPU% MEM MEM%"
|
||||
eight = "NAME VC PWR IP CPU CPU% MEM MEM%"
|
||||
seven = "NAME VC PWR CPU CPU% MEM MEM%"
|
||||
six = "NAME VC PWR CPU% MEM MEM%"
|
||||
@@ -928,9 +1058,12 @@ func TestColumnLadder(t *testing.T) {
|
||||
width int
|
||||
want string
|
||||
}{
|
||||
{200, all}, {109, all},
|
||||
{108, nine}, {89, nine},
|
||||
{88, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
|
||||
{200, all}, {115, all},
|
||||
{114, ten}, {95, ten},
|
||||
{94, nine}, {83, nine},
|
||||
// The snapshot count is what a terminal of eighty gives up to keep the
|
||||
// address, which is what the name column's minimum was tuned for.
|
||||
{82, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
|
||||
{76, seven}, {60, seven},
|
||||
{59, six}, {55, six},
|
||||
{54, five}, {46, five},
|
||||
@@ -1046,7 +1179,7 @@ func TestPipedListingCutsNothing(t *testing.T) {
|
||||
testRow("web01", true, "10.0.0.6"),
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { printList(rows) })
|
||||
out := captureStdout(t, func() { printList(rows, listColumns(rows, false)) })
|
||||
|
||||
if !strings.Contains(out, long) {
|
||||
t.Errorf("the long name was cut:\n%s", out)
|
||||
@@ -1125,3 +1258,345 @@ func TestSelectionFollowsTheMachineNotTheName(t *testing.T) {
|
||||
got.vc.Name, got.name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- the conditional columns
|
||||
|
||||
// The task column is there while anything is being done on the cluster and not
|
||||
// otherwise. Every other column holds a property and can say "-"; this one
|
||||
// holds an exception, and thirteen characters of blank down two hundred rows is
|
||||
// thirteen characters spent on nothing.
|
||||
func TestTheTaskColumnIsThereWhenThereIsATask(t *testing.T) {
|
||||
quiet := []vmRow{testRow("web01", true, "10.0.0.5")}
|
||||
if headers(listColumns(quiet, false)) != headers(browseColumns) {
|
||||
t.Errorf("a quiet cluster shows %s", headers(listColumns(quiet, false)))
|
||||
}
|
||||
|
||||
busy := []vmRow{testRow("web01", true, "10.0.0.5"), testRow("db01", true, "10.0.0.6")}
|
||||
busy[1].task = &runningTask{what: "clone", progress: 40}
|
||||
|
||||
got := headers(listColumns(busy, false))
|
||||
if !strings.Contains(got, "TASK") {
|
||||
t.Fatalf("a busy cluster shows %s", got)
|
||||
}
|
||||
// Next to the power state: both answer "what is this machine doing".
|
||||
if !strings.Contains(got, "PWR TASK") {
|
||||
t.Errorf("the task column is not beside the power state: %s", got)
|
||||
}
|
||||
// And the machine that is not busy has an empty cell rather than a dash,
|
||||
// which would read as a value of its own.
|
||||
if cell := busy[0].taskCell(); cell != "" {
|
||||
t.Errorf("an idle machine's task cell is %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
// In the listing that is made of reasons, the reason takes the place of the
|
||||
// guest's operating system: the least read column, and the only one wide enough
|
||||
// to hold a sentence.
|
||||
func TestTheReasonColumnReplacesTheGuestOS(t *testing.T) {
|
||||
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
|
||||
got := headers(listColumns(rows, true))
|
||||
|
||||
if strings.Contains(got, "GUEST OS") {
|
||||
t.Errorf("the issues listing still shows the guest os: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "WHY") {
|
||||
t.Errorf("the issues listing has no reason column: %s", got)
|
||||
}
|
||||
|
||||
// And the four figures go with it: how hard a machine's processors happen
|
||||
// to be working says nothing about what is wrong with it, and they are four
|
||||
// columns between its name and the answer.
|
||||
for _, gone := range issueHides {
|
||||
if strings.Contains(got, gone) {
|
||||
t.Errorf("the issues listing still shows %s: %s", gone, got)
|
||||
}
|
||||
}
|
||||
// What is left is what identifies the machine, plus the reason.
|
||||
if want := "NAME VC PWR SNAP IP HOST WHY"; got != want {
|
||||
t.Errorf("the issues listing shows\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The ladder still only ever takes columns away, with the task column in the
|
||||
// table as well: a column that comes back as the terminal is dragged narrower
|
||||
// is worse than a column that is simply gone.
|
||||
func TestTheLadderStillOnlyDropsWithATaskColumn(t *testing.T) {
|
||||
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
|
||||
rows[0].task = &runningTask{what: "clone"}
|
||||
table := listColumns(rows, false)
|
||||
|
||||
var wider []string
|
||||
for width := 10; width <= 220; width++ {
|
||||
var here []string
|
||||
for _, c := range fitColumnsOf(table, width) {
|
||||
here = append(here, c.header)
|
||||
}
|
||||
for _, h := range wider {
|
||||
if !slices.Contains(here, h) && h != "NAME" {
|
||||
t.Fatalf("%d columns wide lost %s, which a narrower terminal showed: %v", width, h, here)
|
||||
}
|
||||
}
|
||||
wider = here
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal of eighty keeps the address, which is what the name column's
|
||||
// minimum was tuned for. The snapshot count is what it gives up instead.
|
||||
func TestATerminalOfEightyStillKeepsTheAddress(t *testing.T) {
|
||||
got := headers(fitColumns(78))
|
||||
if !strings.Contains(got, "IP") {
|
||||
t.Errorf("a terminal of eighty shows %s", got)
|
||||
}
|
||||
if strings.Contains(got, "SNAP") {
|
||||
t.Errorf("a terminal of eighty shows the snapshot count as well: %s", got)
|
||||
}
|
||||
if !strings.Contains(headers(fitColumns(83)), "SNAP") {
|
||||
t.Errorf("a wider terminal does not show it either: %s", headers(fitColumns(83)))
|
||||
}
|
||||
}
|
||||
|
||||
func headers(cs []browseColumn) string {
|
||||
var h []string
|
||||
for _, c := range cs {
|
||||
h = append(h, c.header)
|
||||
}
|
||||
return strings.Join(h, " ")
|
||||
}
|
||||
|
||||
// The count is what the column says and its age is the colour it is said in.
|
||||
// A colour cannot be read in a pipe, which is why the sheet spells the date out.
|
||||
func TestTheSnapshotColumn(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if r.snapCell() != "-" || r.snapColor() != colOff {
|
||||
t.Errorf("a machine with no snapshots shows %q", r.snapCell())
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
days int
|
||||
want string
|
||||
}{
|
||||
{1, colSize}, {snapStaleDays, colBusy}, {snapOldDays, colFull},
|
||||
} {
|
||||
r.snaps = []snapEntry{aged("s", c.days)}
|
||||
if r.snapColor() != c.want {
|
||||
t.Errorf("one snapshot of %d days is coloured wrongly", c.days)
|
||||
}
|
||||
if r.snapCell() != "1" {
|
||||
t.Errorf("one snapshot shows as %q", r.snapCell())
|
||||
}
|
||||
}
|
||||
|
||||
// The oldest is what decides, not the newest or the first in the tree.
|
||||
r.snaps = []snapEntry{aged("new", 1), aged("old", 200), aged("middling", 20)}
|
||||
if r.snapCell() != "3" {
|
||||
t.Errorf("three snapshots show as %q", r.snapCell())
|
||||
}
|
||||
if r.snapColor() != colFull {
|
||||
t.Error("a machine whose oldest snapshot is 200 days old is not painted red")
|
||||
}
|
||||
if e, ok := r.oldest(); !ok || e.name != "old" {
|
||||
t.Errorf("the oldest snapshot came back as %v", e.name)
|
||||
}
|
||||
if days, ok := r.snapAge(); !ok || int(days) != 200 {
|
||||
t.Errorf("the age came back as %v", days)
|
||||
}
|
||||
|
||||
// Snapshots with no date at all: there is something to clean up, but
|
||||
// nothing to judge it by, so it is neither yellow nor red.
|
||||
r.snaps = []snapEntry{{name: "undated"}}
|
||||
if _, ok := r.snapAge(); ok {
|
||||
t.Error("an undated snapshot reported an age")
|
||||
}
|
||||
if r.snapColor() != colChosen {
|
||||
t.Error("an undated snapshot is aged as though it had a date")
|
||||
}
|
||||
}
|
||||
|
||||
// The sheet says what the table paints: what is wrong with the machine and what
|
||||
// is being done to it, above everything that has been true since it was built.
|
||||
func TestTheSheetLeadsWithWhatIsWrong(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
r.task = &runningTask{what: "consolid", progress: 12}
|
||||
|
||||
var labels []string
|
||||
var issues, task string
|
||||
for _, l := range vmDetail(r, nil, "") {
|
||||
if l.label != "" {
|
||||
labels = append(labels, l.label)
|
||||
}
|
||||
if l.label == "issues" {
|
||||
issues = l.value
|
||||
}
|
||||
if l.label == "task" {
|
||||
task = l.value
|
||||
}
|
||||
}
|
||||
if !strings.Contains(issues, "consolidating") {
|
||||
t.Errorf("the sheet's issues line reads %q", issues)
|
||||
}
|
||||
if !strings.Contains(task, "consolid") || !strings.Contains(task, "12") {
|
||||
t.Errorf("the sheet's task line reads %q", task)
|
||||
}
|
||||
|
||||
order := strings.Join(labels, " ")
|
||||
if !strings.HasPrefix(order, "state issues task guest") {
|
||||
t.Errorf("the sheet begins %q", order)
|
||||
}
|
||||
}
|
||||
|
||||
// Each reason keeps its own colour: a filesystem at 97 % and a missing VMware
|
||||
// Tools are not the same news.
|
||||
func TestTheSheetColoursEachReasonForItself(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true // red
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // yellow
|
||||
|
||||
var cols []string
|
||||
for _, l := range vmDetail(r, nil, "") {
|
||||
if l.label == "issues" || (len(cols) > 0 && l.label == "") {
|
||||
if l.col == colFull || l.col == colBusy {
|
||||
cols = append(cols, l.col)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(cols) != 2 || cols[0] != colFull || cols[1] != colBusy {
|
||||
t.Errorf("the reasons came out coloured %v", cols)
|
||||
}
|
||||
}
|
||||
|
||||
// Every screen, drawn with a machine that has something to say on all of them:
|
||||
// what is wrong with it, what is being done to it, snapshots, and a history
|
||||
// that has been fetched. It is the check that each screen actually shows what
|
||||
// it was given — the reason column was once first in the queue to be given up,
|
||||
// which left a listing of nothing but machines with the reasons cut off.
|
||||
func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "120")
|
||||
t.Setenv("LINES", "30")
|
||||
|
||||
b := testBrowser("web01", "db01")
|
||||
b.answered = []string{"v308"}
|
||||
b.rows[1].task = &runningTask{what: "clone", progress: 40}
|
||||
b.rows[1].snaps = []snapEntry{aged("base", 63), aged("hotfix", 3)}
|
||||
b.rows[1].vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
b.applySort()
|
||||
|
||||
list := stripEscapes(renderToPipe(t, b, b.renderList))
|
||||
for _, want := range []string{"TASK", "clone 40%", "SNAP", "db01"} {
|
||||
if !strings.Contains(list, want) {
|
||||
t.Errorf("the list does not show %q:\n%s", want, list)
|
||||
}
|
||||
}
|
||||
|
||||
b.issuesOnly = true
|
||||
b.refilter()
|
||||
issues := stripEscapes(renderToPipe(t, b, b.renderList))
|
||||
for _, want := range []string{"WHY", "consolidating", "issues only"} {
|
||||
if !strings.Contains(issues, want) {
|
||||
t.Errorf("the issues listing does not show %q:\n%s", want, issues)
|
||||
}
|
||||
}
|
||||
if strings.Contains(issues, "web01") {
|
||||
t.Errorf("the issues listing kept a healthy machine:\n%s", issues)
|
||||
}
|
||||
|
||||
b.issuesOnly = false
|
||||
b.refilter()
|
||||
for i, r := range b.rows {
|
||||
if r.name == "db01" {
|
||||
b.sel = i
|
||||
}
|
||||
}
|
||||
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
|
||||
b.eventsOf = b.current().id()
|
||||
b.openDetail()
|
||||
|
||||
sheet := stripEscapes(renderToPipe(t, b, b.renderDetail))
|
||||
for _, want := range []string{"issues", "consolidating", "task", "clone", "events", "esx03"} {
|
||||
if !strings.Contains(sheet, want) {
|
||||
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
// The four that only read the machine are the sheet's own letters, so the
|
||||
// sheet is where they have to be advertised.
|
||||
for _, want := range []string{"e events", "h ssh", "y copy", "w vsphere", "^a actions"} {
|
||||
if !strings.Contains(sheet, want) {
|
||||
t.Errorf("the sheet's help does not offer %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
b.menu = b.buildMenu(*b.current(), b.current().snaps, testSizing())
|
||||
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
|
||||
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
|
||||
for _, want := range []string{"take a snapshot", "power off", "reset"} {
|
||||
if !strings.Contains(menu, want) {
|
||||
t.Errorf("the menu does not show %q:\n%s", want, menu)
|
||||
}
|
||||
}
|
||||
// And the menu is now only the things that change a machine.
|
||||
for _, gone := range []string{"recent events", "ssh to the guest",
|
||||
"copy the address", "vSphere client"} {
|
||||
if strings.Contains(menu, gone) {
|
||||
t.Errorf("the menu still holds %q:\n%s", gone, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every line at the foot of the screen that wants an answer wears one colour.
|
||||
// The sort legend is a menu, a confirmation is a yes/no question and a snapshot
|
||||
// name is something typed, and they are three different kinds of question — but
|
||||
// "gvm is waiting for me" is one thing, and it is learned once rather than
|
||||
// worked out per screen.
|
||||
func TestEveryQuestionWearsOneColour(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "12")
|
||||
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
set func()
|
||||
text string
|
||||
lines int // how many of the bottom rows the question occupies
|
||||
}{
|
||||
{"the sort legend", func() {
|
||||
// Narrower than the whole legend, so it is the two-line one: the
|
||||
// second line is the part that could quietly lose its colour.
|
||||
lines := sortLegend(60)
|
||||
b.prompt = &prompt{text: lines[0], col: colPrompt, more: lines[1]}
|
||||
b.edit = nil
|
||||
}, "sort:", 2},
|
||||
{"a yes/no question", func() {
|
||||
b.prompt = &prompt{text: "power on web01 on v308?", col: colPrompt}
|
||||
b.edit = nil
|
||||
}, "power on web01", 1},
|
||||
{"a name being typed", func() {
|
||||
b.prompt = nil
|
||||
b.edit = &editor{label: "name the snapshot of web01: "}
|
||||
}, "name the snapshot", 1},
|
||||
} {
|
||||
c.set()
|
||||
frame := renderToPipe(t, b, b.renderList)
|
||||
|
||||
if !strings.Contains(frame, colPrompt+c.text) {
|
||||
t.Errorf("%s is not in the colour every question has:\n%s",
|
||||
c.what, frame[max(len(frame)-400, 0):])
|
||||
}
|
||||
// And the second line of a two-line question is in it as well, or the
|
||||
// menu would fade out halfway down.
|
||||
if c.lines == 2 {
|
||||
second := stripEscapes(frame)
|
||||
if !strings.Contains(second, "r·reverse") {
|
||||
t.Errorf("%s lost its second line:\n%s", c.what, second)
|
||||
}
|
||||
if strings.Count(frame, colPrompt) < 2 {
|
||||
t.Errorf("%s does not carry the colour onto its second line", c.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.prompt, b.edit = nil, nil
|
||||
}
|
||||
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
// complete.go — shell completion, and the inventory cache behind it.
|
||||
//
|
||||
// The names one types at gvm are machine names, and they are long, and there
|
||||
// are hundreds of them on three servers. Completing them is what makes the
|
||||
// non-interactive half usable — `gvm -v v308 snap -l dbse<tab>` — but it cannot
|
||||
// be done by asking the vCenters: a shell completion runs on every Tab and has
|
||||
// to answer in milliseconds, and three logins take seconds.
|
||||
//
|
||||
// So it answers out of what gvm last saw. Every sweep of the machine list
|
||||
// leaves the names behind in the cache directory, per vCenter and with the time
|
||||
// on them, and `--complete-vms` reads that file and nothing else. The cache is
|
||||
// therefore always exactly as fresh as the last time somebody looked at the
|
||||
// list — which is the right currency for a Tab key, and no currency at all for
|
||||
// anything that acts on a machine. Nothing else in gvm reads this file: every
|
||||
// command resolves the name it was given against the server itself.
|
||||
//
|
||||
// `gvm config` says how old it is, because a completion that quietly offers a
|
||||
// machine deleted last month is a small mystery worth being able to explain.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/integrii/flaggy"
|
||||
)
|
||||
|
||||
// completionFlagNames are the options answered before the flag parser, the same
|
||||
// way the update options are (gvm.go). They are what the generated scripts call
|
||||
// on every Tab, so they must work on a machine whose configuration is broken —
|
||||
// and must never print anything but the candidates. Nobody types them, which is
|
||||
// why they are deliberately not in the help.
|
||||
var completionFlagNames = []string{"--complete-vms", "--complete-vcenters"}
|
||||
|
||||
// isCompletionFlag reports whether this argument is one of them, so that the
|
||||
// help can be checked against what is actually answered — the same guard the
|
||||
// update options have (see the tests).
|
||||
func isCompletionFlag(arg string) bool { return contains(completionFlagNames, arg) }
|
||||
|
||||
// completionFlags answers those options and reports whether it did.
|
||||
func completionFlags() bool {
|
||||
args := os.Args[1:]
|
||||
for i, a := range args {
|
||||
if !contains(completionFlagNames, a) {
|
||||
continue
|
||||
}
|
||||
// The word after the option, skipping the "--" the completion scripts
|
||||
// put in front of it so that a prefix beginning with a dash cannot be
|
||||
// taken for an option of gvm's own.
|
||||
rest := ""
|
||||
for _, a := range args[i+1:] {
|
||||
if a == "--" {
|
||||
continue
|
||||
}
|
||||
rest = a
|
||||
break
|
||||
}
|
||||
switch a {
|
||||
case "--complete-vms":
|
||||
for _, name := range cachedNames(rest) {
|
||||
P(name)
|
||||
}
|
||||
case "--complete-vcenters":
|
||||
for _, name := range cachedVCenters() {
|
||||
P(name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the cache
|
||||
|
||||
// inventoryPath is where the names are kept: the cache directory, beside the
|
||||
// update note, and never in the configuration — losing it costs one Tab that
|
||||
// offers nothing.
|
||||
func inventoryPath() (string, error) {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, selfUpdate.asset, "inventory"), nil
|
||||
}
|
||||
|
||||
// cacheEntry is one machine as the cache remembers it.
|
||||
type cacheEntry struct {
|
||||
vc string
|
||||
when time.Time
|
||||
name string
|
||||
}
|
||||
|
||||
// saveInventory writes the machines of the servers that answered.
|
||||
//
|
||||
// The servers that did not are left exactly as they were: a vCenter that is
|
||||
// down, or that this run was not asked about (`-v v308`), must not lose its
|
||||
// machines out of the cache — the point of completion is to work when things
|
||||
// are not working. Best effort throughout: a cache that cannot be written is
|
||||
// not worth a word on the screen, let alone an error.
|
||||
func saveInventory(answered []string, rows []vmRow) {
|
||||
path, err := inventoryPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fresh := map[string]bool{}
|
||||
for _, name := range answered {
|
||||
fresh[name] = true
|
||||
}
|
||||
|
||||
kept := make([]cacheEntry, 0, len(rows))
|
||||
for _, e := range loadInventory() {
|
||||
if !fresh[e.vc] {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
for _, r := range rows {
|
||||
kept = append(kept, cacheEntry{vc: r.vc.Name, when: now, name: r.name})
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, e := range kept {
|
||||
// One line per machine: the server, when it was read, and the name.
|
||||
// Tab separated because a machine name may hold a space and never a tab.
|
||||
sb.WriteString(e.vc + "\t" + e.when.Format(time.RFC3339) + "\t" + e.name + "\n")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if os.WriteFile(tmp, []byte(sb.String()), 0o600) != nil {
|
||||
return
|
||||
}
|
||||
if os.Rename(tmp, path) != nil {
|
||||
os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// loadInventory reads it back. A line that does not parse is dropped rather
|
||||
// than reported: this file is a convenience and a broken one means one Tab
|
||||
// without an answer.
|
||||
func loadInventory() []cacheEntry {
|
||||
path, err := inventoryPath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []cacheEntry
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) != 3 || f[0] == "" || f[2] == "" {
|
||||
continue
|
||||
}
|
||||
when, err := time.Parse(time.RFC3339, f[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, cacheEntry{vc: f[0], when: when, name: f[2]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cachedNames are the machine names that begin with the prefix, once each and
|
||||
// in order. Once each because the same name on two vCenters is one thing to
|
||||
// type; in order because a completion list that moves about is a completion
|
||||
// list nobody reads.
|
||||
func cachedNames(prefix string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range loadInventory() {
|
||||
if seen[e.name] || !strings.HasPrefix(strings.ToLower(e.name), strings.ToLower(prefix)) {
|
||||
continue
|
||||
}
|
||||
seen[e.name] = true
|
||||
out = append(out, e.name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// cachedVCenters are the servers the cache has seen, which is what `-v`
|
||||
// completes against. It comes out of the cache and not out of ~/.gvmrc on
|
||||
// purpose: reading the configuration would seal a password standing in the
|
||||
// clear in it, and a Tab key must not rewrite a file.
|
||||
func cachedVCenters() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range loadInventory() {
|
||||
if seen[e.vc] {
|
||||
continue
|
||||
}
|
||||
seen[e.vc] = true
|
||||
out = append(out, e.vc)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// inventoryAge is what `gvm config` says about the cache: how many machines it
|
||||
// holds and how long ago each server was read.
|
||||
func inventoryAge() string {
|
||||
entries := loadInventory()
|
||||
if len(entries) == 0 {
|
||||
return "-"
|
||||
}
|
||||
newest := map[string]time.Time{}
|
||||
var order []string
|
||||
for _, e := range entries {
|
||||
if _, seen := newest[e.vc]; !seen {
|
||||
order = append(order, e.vc)
|
||||
}
|
||||
if e.when.After(newest[e.vc]) {
|
||||
newest[e.vc] = e.when
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, vc := range order {
|
||||
parts = append(parts, SF("%s %s ago", vc, uptime(time.Since(newest[vc]))))
|
||||
}
|
||||
return SF("%s from %s", plural(len(entries), "machine"), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- the scripts
|
||||
|
||||
// vmFlags are the options that take a machine name and vcFlags the ones that
|
||||
// take a vCenter — the only thing about gvm's own command line that the
|
||||
// completion has to be told, because it is the only thing flaggy's generated
|
||||
// script cannot know: it knows every option there is, and nothing about what
|
||||
// any of them means.
|
||||
//
|
||||
// completionOptionsAreReal (see the tests) checks each one against gvm.go, so
|
||||
// an option renamed there cannot leave a completion quietly offering the wrong
|
||||
// thing.
|
||||
var (
|
||||
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
|
||||
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
|
||||
"--off", "--reset", "--vm"}
|
||||
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
|
||||
)
|
||||
|
||||
// installedFunction is the completion function flaggy's own script installs,
|
||||
// read off the line where it installs it — "compdef _gvm gvm" in zsh,
|
||||
// "complete -F _gvm_complete gvm" in bash.
|
||||
//
|
||||
// Taken from the script rather than written down here, because the name is
|
||||
// flaggy's to choose: it builds it out of the parser's name, and a version that
|
||||
// built it differently would leave the addendum below calling a function that
|
||||
// does not exist, which in a shell is a completion that silently offers
|
||||
// nothing.
|
||||
func installedFunction(script string) string {
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
f := strings.Fields(line)
|
||||
switch {
|
||||
case len(f) >= 2 && f[0] == "compdef":
|
||||
return f[1]
|
||||
case len(f) >= 3 && f[0] == "complete" && f[1] == "-F":
|
||||
return f[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// completionRequest recognises `gvm completion <shell>` — the subcommand flaggy
|
||||
// offers and lists in the help, answered here instead so that the script can
|
||||
// carry the machine names as well. A shell this does not know is left to
|
||||
// flaggy, whose own message names the ones it can write.
|
||||
func completionRequest(args []string) (shell string, ok bool) {
|
||||
if len(args) < 2 || !strings.EqualFold(args[0], "completion") {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(args[1]), true
|
||||
}
|
||||
|
||||
// completionScript is flaggy's script for that shell with the names put on top:
|
||||
//
|
||||
// eval "$(gvm completion zsh)"
|
||||
// gvm completion bash > /etc/bash_completion.d/gvm
|
||||
//
|
||||
// flaggy generates the half that is about gvm's own command line, from the
|
||||
// parser itself, so no list here can fall behind the options that exist. This
|
||||
// adds the half that is about the estate: after an option that takes a machine,
|
||||
// the machines; after -v, the servers. Both go through --complete-vms, which
|
||||
// reads the cache and never a vCenter.
|
||||
func completionScript(shell, flaggyScript string) (string, bool) {
|
||||
names, ok := map[string]func(string) string{"zsh": zshNames, "bash": bashNames}[shell]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
// Without a function of flaggy's to fall back to there is nothing to add
|
||||
// to: half a completion — machine names and no options — would be worse
|
||||
// than the whole of flaggy's, which is what this then leaves in place.
|
||||
delegate := installedFunction(flaggyScript)
|
||||
if delegate == "" {
|
||||
return flaggyScript, true
|
||||
}
|
||||
return flaggyScript + names(delegate), true
|
||||
}
|
||||
|
||||
func zshNames(delegate string) string {
|
||||
return strings.Join([]string{
|
||||
"",
|
||||
"# gvm: the machines and the servers, from what gvm last saw",
|
||||
"_gvm_names() {",
|
||||
" local prev=${words[CURRENT-1]} cur=${words[CURRENT]}",
|
||||
" case $prev in",
|
||||
" " + strings.Join(vmFlags, "|") + ")",
|
||||
" compadd -- ${(f)\"$(gvm --complete-vms -- ${cur} 2>/dev/null)\"}; return;;",
|
||||
" " + strings.Join(vcFlags, "|") + ")",
|
||||
" compadd -- ${(f)\"$(gvm --complete-vcenters 2>/dev/null)\"}; return;;",
|
||||
" esac",
|
||||
" " + delegate + " \"$@\"",
|
||||
"}",
|
||||
"compdef _gvm_names gvm",
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func bashNames(delegate string) string {
|
||||
return strings.Join([]string{
|
||||
"",
|
||||
"# gvm: the machines and the servers, from what gvm last saw",
|
||||
"_gvm_names() {",
|
||||
" local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]}",
|
||||
" case $prev in",
|
||||
" " + strings.Join(vmFlags, "|") + ")",
|
||||
" COMPREPLY=($(compgen -W \"$(gvm --complete-vms -- \"$cur\" 2>/dev/null)\" -- \"$cur\")); return;;",
|
||||
" " + strings.Join(vcFlags, "|") + ")",
|
||||
" COMPREPLY=($(compgen -W \"$(gvm --complete-vcenters 2>/dev/null)\" -- \"$cur\")); return;;",
|
||||
" esac",
|
||||
" " + delegate,
|
||||
"}",
|
||||
"complete -F _gvm_names gvm",
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// flaggyCompletion is flaggy's own script for that shell, out of the parser as
|
||||
// it stands — every subcommand and every option, without a list here to fall
|
||||
// behind them. Empty for a shell flaggy does not write, which is the caller's
|
||||
// signal to let flaggy answer for itself.
|
||||
func flaggyCompletion(shell string) string {
|
||||
switch shell {
|
||||
case "zsh":
|
||||
return flaggy.GenerateZshCompletion(flaggy.DefaultParser)
|
||||
case "bash":
|
||||
return flaggy.GenerateBashCompletion(flaggy.DefaultParser)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/integrii/flaggy"
|
||||
)
|
||||
|
||||
// cacheHome points the cache directory at a temporary one, so a test never
|
||||
// reads or writes the cache of the person running it. os.UserCacheDir goes by
|
||||
// HOME on macOS and by XDG_CACHE_HOME on Linux, so both are set.
|
||||
func cacheHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache"))
|
||||
return dir
|
||||
}
|
||||
|
||||
func cacheRows(vc string, names ...string) []vmRow {
|
||||
var rows []vmRow
|
||||
for _, n := range names {
|
||||
rows = append(rows, vmRow{vc: VCenter{Name: vc}, name: n})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestInventoryCacheRoundTrip(t *testing.T) {
|
||||
cacheHome(t)
|
||||
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
|
||||
if got := cachedNames(""); !slices.Equal(got, []string{"db01", "web01"}) {
|
||||
t.Errorf("the cache gave back %v", got)
|
||||
}
|
||||
if got := cachedVCenters(); !slices.Equal(got, []string{"v308"}) {
|
||||
t.Errorf("the servers came back as %v", got)
|
||||
}
|
||||
if got := cachedNames("web"); !slices.Equal(got, []string{"web01"}) {
|
||||
t.Errorf("the prefix web matched %v", got)
|
||||
}
|
||||
// A shell completes what has been typed so far, whichever case it is in.
|
||||
if got := cachedNames("WEB"); !slices.Equal(got, []string{"web01"}) {
|
||||
t.Errorf("the prefix WEB matched %v", got)
|
||||
}
|
||||
if got := cachedNames("nothing-like-this"); len(got) != 0 {
|
||||
t.Errorf("a prefix that matches nothing gave %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A sweep of one server must not lose the others' machines. The point of
|
||||
// completing out of a cache is that it works when a vCenter is down — or when
|
||||
// the last command was `gvm -v v308 vm -l`.
|
||||
func TestASweepOfOneServerKeepsTheOthers(t *testing.T) {
|
||||
cacheHome(t)
|
||||
|
||||
saveInventory([]string{"v308", "v108"},
|
||||
append(cacheRows("v308", "web01"), cacheRows("v108", "old01")...))
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "web02"))
|
||||
|
||||
if got := cachedNames(""); !slices.Equal(got, []string{"old01", "web01", "web02"}) {
|
||||
t.Errorf("after a sweep of one server the cache holds %v", got)
|
||||
}
|
||||
if got := cachedVCenters(); !slices.Equal(got, []string{"v108", "v308"}) {
|
||||
t.Errorf("the servers came back as %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that has gone is gone from the cache of the server it was on.
|
||||
func TestTheCacheForgetsWhatTheServerHasForgotten(t *testing.T) {
|
||||
cacheHome(t)
|
||||
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "temp01"))
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
|
||||
|
||||
if got := cachedNames(""); !slices.Equal(got, []string{"web01"}) {
|
||||
t.Errorf("the cache still holds %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A cache that cannot be read is one Tab without an answer, never an error.
|
||||
func TestABrokenCacheIsSilent(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
|
||||
|
||||
path, err := inventoryPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("nonsense\nv308\tnot-a-date\tweb01\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cachedNames(""); len(got) != 0 {
|
||||
t.Errorf("a broken cache offered %v", got)
|
||||
}
|
||||
if got := inventoryAge(); got != "-" {
|
||||
t.Errorf("a broken cache is described as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The file holds machine names read off a vCenter, and the cache directory is
|
||||
// not private, so it is written the way the configuration is.
|
||||
func TestTheCacheIsNotWorldReadable(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01"))
|
||||
|
||||
path, _ := inventoryPath()
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the cache is mode %v", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// `gvm config` says how old the cache is, because a completion offering a
|
||||
// machine deleted last month should be explicable.
|
||||
func TestConfigSaysHowOldTheCacheIs(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "web01", "db01"))
|
||||
|
||||
got := inventoryAge()
|
||||
if !strings.Contains(got, "2 machines") || !strings.Contains(got, "v308") {
|
||||
t.Errorf("inventoryAge = %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "ago") {
|
||||
t.Errorf("inventoryAge does not say when: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The scripts complete against the cache and never against a vCenter: a Tab key
|
||||
// that logs in three times is a Tab key nobody presses twice.
|
||||
func TestTheCompletionScriptsAskGvmAndNothingElse(t *testing.T) {
|
||||
for _, shell := range []string{"zsh", "bash"} {
|
||||
script, ok := completionScript(shell, "# flaggy's half\n_half() {\n}\ncompdef _half gvm\n")
|
||||
if !ok {
|
||||
t.Fatalf("no script for %s", shell)
|
||||
}
|
||||
if !strings.Contains(script, "--complete-vms") || !strings.Contains(script, "--complete-vcenters") {
|
||||
t.Errorf("the %s script does not ask gvm for the names:\n%s", shell, script)
|
||||
}
|
||||
// flaggy's half is carried, not replaced: that is the half that knows
|
||||
// every subcommand and every option.
|
||||
if !strings.Contains(script, "# flaggy's half") {
|
||||
t.Errorf("the %s script threw flaggy's own half away", shell)
|
||||
}
|
||||
if !strings.Contains(script, "_gvm_names") {
|
||||
t.Errorf("the %s script does not install itself", shell)
|
||||
}
|
||||
if !strings.Contains(script, "_half") {
|
||||
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
|
||||
}
|
||||
if !strings.Contains(script, "_half") {
|
||||
t.Errorf("the %s script does not fall back to what flaggy installed", shell)
|
||||
}
|
||||
}
|
||||
|
||||
// A shell neither half knows is left to flaggy, whose own message names the
|
||||
// ones it can write.
|
||||
if _, ok := completionScript("klingon", ""); ok {
|
||||
t.Error("a script was written for a shell nobody has")
|
||||
}
|
||||
}
|
||||
|
||||
// The addendum falls back to the function flaggy's own script installs, whose
|
||||
// name is read off the script rather than written down twice. If flaggy ever
|
||||
// names it differently the wrapper follows it there.
|
||||
func TestTheAddendumDelegatesToFlaggysOwnFunction(t *testing.T) {
|
||||
for _, c := range []struct{ script, want string }{
|
||||
{"_gvm() {\n}\ncompdef _gvm gvm\n", "_gvm"},
|
||||
{"_gvm_complete() {\n}\ncomplete -F _gvm_complete gvm\n", "_gvm_complete"},
|
||||
{"_thing() {\n}\ncompdef _some_other_name thing\n", "_some_other_name"},
|
||||
{"nothing installs anything here\n", ""},
|
||||
} {
|
||||
if got := installedFunction(c.script); got != c.want {
|
||||
t.Errorf("installedFunction found %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And on the real thing: flaggy builds the name out of the parser's name,
|
||||
// which is the only part of the parser this depends on.
|
||||
flaggy.SetName("gvm")
|
||||
for _, shell := range []string{"zsh", "bash"} {
|
||||
generated := flaggyCompletion(shell)
|
||||
delegate := installedFunction(generated)
|
||||
if delegate == "" {
|
||||
t.Fatalf("nothing could be found to delegate to in flaggy's %s script:\n%s", shell, generated)
|
||||
}
|
||||
if !strings.Contains(generated, delegate+"()") {
|
||||
t.Errorf("flaggy's %s script installs %s without defining it", shell, delegate)
|
||||
}
|
||||
script, _ := completionScript(shell, generated)
|
||||
if !strings.Contains(script, delegate) {
|
||||
t.Errorf("the %s addendum does not fall back to %s", shell, delegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A shell script that is not valid shell is worse than none: the shell says so
|
||||
// on every Tab. Both are checked with the shell's own parser, where there is one.
|
||||
func TestTheCompletionScriptsAreValidShell(t *testing.T) {
|
||||
flaggy.SetName("gvm")
|
||||
for _, c := range []struct{ shell, flag string }{{"zsh", "-n"}, {"bash", "-n"}} {
|
||||
if _, err := exec.LookPath(c.shell); err != nil {
|
||||
t.Logf("no %s here to check with", c.shell)
|
||||
continue
|
||||
}
|
||||
script, ok := completionScript(c.shell, flaggyCompletion(c.shell))
|
||||
if !ok {
|
||||
t.Fatalf("no %s script", c.shell)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "completion."+c.shell)
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command(c.shell, c.flag, path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Errorf("the %s script does not parse: %v\n%s\n%s", c.shell, err, out, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `gvm completion <shell>` is flaggy's own subcommand, answered a step earlier.
|
||||
// It has to be recognised exactly as flaggy would recognise it, or the two
|
||||
// disagree about what the command line said.
|
||||
func TestCompletionRequest(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
shell string
|
||||
ok bool
|
||||
}{
|
||||
{[]string{"completion", "zsh"}, "zsh", true},
|
||||
{[]string{"completion", "BASH"}, "bash", true},
|
||||
{[]string{"Completion", "zsh"}, "zsh", true},
|
||||
{[]string{"completion"}, "", false}, // flaggy asks which shell
|
||||
{[]string{"vm", "-l"}, "", false},
|
||||
{nil, "", false},
|
||||
} {
|
||||
shell, ok := completionRequest(c.args)
|
||||
if ok != c.ok || shell != c.shell {
|
||||
t.Errorf("completionRequest(%v) = %q, %v; want %q, %v", c.args, shell, ok, c.shell, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every option the names are offered after is an option gvm actually has. The
|
||||
// two lists cannot be one — a shell script has to name them as strings — so
|
||||
// this is what keeps them from drifting apart. The subcommands and the rest of
|
||||
// the options need no such check: flaggy writes those out of the parser itself.
|
||||
func TestCompletionOptionsAreReal(t *testing.T) {
|
||||
src, err := os.ReadFile("gvm.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(src)
|
||||
|
||||
for _, f := range append(append([]string{}, vmFlags...), vcFlags...) {
|
||||
if !strings.Contains(text, `"`+strings.TrimLeft(f, "-")+`"`) {
|
||||
t.Errorf("the completion offers %s, which gvm.go does not declare", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix beginning with a dash is still a prefix: the scripts put "--" in
|
||||
// front of it so it cannot be taken for an option of gvm's own.
|
||||
func TestACompletionPrefixMayLookLikeAnOption(t *testing.T) {
|
||||
cacheHome(t)
|
||||
saveInventory([]string{"v308"}, cacheRows("v308", "-odd-name", "web01"))
|
||||
|
||||
if got := cachedNames("-odd"); !slices.Equal(got, []string{"-odd-name"}) {
|
||||
t.Errorf("the prefix -odd matched %v", got)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type Config struct {
|
||||
SMTPHost string // relay to hand it to
|
||||
SMTPPort string // its port (default 25)
|
||||
Telemetry string // URL `host -t` posts to; unset turns the posting off
|
||||
SSH string // the command the sheet's `h` runs; %h is the machine
|
||||
}
|
||||
|
||||
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
|
||||
@@ -130,6 +131,16 @@ func (c Config) pick(name string) (VCenter, error) {
|
||||
return VCenter{}, c.notConfigured(incomplete)
|
||||
}
|
||||
|
||||
// A list where one server is wanted is refused rather than half obeyed.
|
||||
// The machine listing takes -v v308,v108; a snapshot, a power operation and
|
||||
// the event log are about one server, and taking the first of a list would
|
||||
// be picking a production cluster on the operator's behalf.
|
||||
if strings.Contains(name, ",") {
|
||||
return VCenter{}, fmt.Errorf("this command works on one vCenter at a time, "+
|
||||
"and -v was given %d (%s) — a list of servers is for 'gvm vm' and 'gvm vm -l'",
|
||||
len(splitList(name)), name)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = c.Default
|
||||
}
|
||||
@@ -157,14 +168,28 @@ func (c Config) pick(name string) (VCenter, error) {
|
||||
}
|
||||
|
||||
// targets is what the commands that sweep every server work on: all of them
|
||||
// when -v was not given, the named one when it was.
|
||||
// when -v was not given, and otherwise the ones -v named — one, or several
|
||||
// separated by commas, in the order they were given. An unknown name among
|
||||
// them is an error rather than a shorter list.
|
||||
func (c Config) targets(name string) ([]VCenter, error) {
|
||||
if name != "" {
|
||||
v, err := c.pick(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var out []VCenter
|
||||
seen := map[string]bool{}
|
||||
for _, one := range splitList(name) {
|
||||
v, err := c.pick(one)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[v.Name] {
|
||||
continue // named twice; it is still one server and one login
|
||||
}
|
||||
seen[v.Name] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
return []VCenter{v}, nil
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("-v was given nothing to work on (known: %s)", c.names())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
ok, incomplete := c.usable()
|
||||
if len(ok) == 0 {
|
||||
@@ -176,6 +201,18 @@ func (c Config) targets(name string) ([]VCenter, error) {
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// splitList takes -v apart. Empty pieces are dropped, so a trailing comma or a
|
||||
// space after one is a typo that costs nothing.
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c Config) notConfigured(incomplete []string) error {
|
||||
if len(incomplete) > 0 {
|
||||
return fmt.Errorf("no usable vCenter in %s: %s", configFile(), strings.Join(incomplete, "; "))
|
||||
@@ -192,6 +229,19 @@ func (c Config) smtpPort() int {
|
||||
return 25
|
||||
}
|
||||
|
||||
// telemetryURL is where the numbers are posted, when they are asked to be.
|
||||
// Asked for here rather than at each command, so that a missing setting is one
|
||||
// message and not one per subcommand that grew a -t.
|
||||
func (c Config) telemetryURL(wanted bool) (string, error) {
|
||||
if !wanted {
|
||||
return "", nil
|
||||
}
|
||||
if c.Telemetry == "" {
|
||||
return "", errf("no 'telemetry' url in %s", configFile())
|
||||
}
|
||||
return c.Telemetry, nil
|
||||
}
|
||||
|
||||
// mailReady reports whether `log -m` has everything it needs.
|
||||
func (c Config) mailReady() error {
|
||||
var miss []string
|
||||
@@ -361,6 +411,7 @@ func applyConfig(c *Config, m map[string]string) {
|
||||
set("smtphost", &c.SMTPHost)
|
||||
set("smtpport", &c.SMTPPort)
|
||||
set("telemetry", &c.Telemetry)
|
||||
set("ssh", &c.SSH)
|
||||
applyVCenters(c, m)
|
||||
}
|
||||
|
||||
@@ -426,6 +477,7 @@ func applyEnv(c *Config) {
|
||||
env("GVM_SMTPHOST", &c.SMTPHost)
|
||||
env("GVM_SMTPPORT", &c.SMTPPort)
|
||||
env("GVM_TELEMETRY", &c.Telemetry)
|
||||
env("GVM_SSH", &c.SSH)
|
||||
applyVCenterEnv(c)
|
||||
}
|
||||
|
||||
@@ -528,13 +580,17 @@ func writeConfigTemplate(path string) {
|
||||
fmt.Fprintf(&b, "vcenter.%s.insecure = true\n\n", v.Name)
|
||||
}
|
||||
|
||||
b.WriteString("# --- mail for `gvm log -m` ---\n")
|
||||
b.WriteString("# --- mail for `gvm log -m` and `gvm snap --old -m` ---\n")
|
||||
b.WriteString("# mailfrom = root@fhi.mpg.de\n")
|
||||
b.WriteString("# mailto = you@example.com\n")
|
||||
b.WriteString("# smtphost = m0.fhi-berlin.mpg.de\n")
|
||||
b.WriteString("# smtpport = 25\n\n")
|
||||
b.WriteString("# --- where `gvm host -t` posts its numbers ---\n")
|
||||
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n")
|
||||
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
|
||||
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
|
||||
b.WriteString("# --- how the sheet's 'h' logs in to a guest ---\n")
|
||||
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
|
||||
b.WriteString("# not written anywhere. Unset means '" + defaultSSH + "'.\n")
|
||||
b.WriteString("# ssh = ssh -l someone %h\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
|
||||
PE("could not create "+path, err.Error())
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
// datastore.go — what the datastores are doing.
|
||||
//
|
||||
// The gap next to `gvm host`. A cluster is watched by its processor load and its
|
||||
// memory, and then it falls over because a datastore filled up — which nothing
|
||||
// in gvm could show, and which is the one figure a snapshot report (snapold.go)
|
||||
// makes you want to look at next.
|
||||
//
|
||||
// Same shape as hoststat: one line per datastore, the numbers in the palette's
|
||||
// roles, and -t posts the same figures to the monitoring server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/units"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
var dsColumns = []printColumn{
|
||||
{header: "DATASTORE", width: 20},
|
||||
{header: "TYPE", width: 5},
|
||||
{header: "CAPACITY", width: 9, right: true},
|
||||
{header: "FREE", width: 9, right: true},
|
||||
{header: "USED%", width: 6, right: true},
|
||||
{header: "PROVISIONED", width: 12, right: true},
|
||||
{header: "OVER%", width: 6, right: true},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "STATUS", width: 7},
|
||||
{header: "STATE", width: 14},
|
||||
}
|
||||
|
||||
// dsstat prints one line per datastore: how big it is, what is left, what has
|
||||
// been promised out of it, and how many machines live on it.
|
||||
//
|
||||
// Every figure comes out of summary, and vSphere only guarantees those while
|
||||
// the datastore is accessible. An unreachable datastore therefore reports
|
||||
// dashes rather than zeroes — a datastore that says 0 B free looks like an
|
||||
// emergency, and a datastore nobody can reach is a different one.
|
||||
func dsstat(vc VCenter, telemetry string) error {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.close()
|
||||
|
||||
stores, err := s.datastores("name", "summary", "overallStatus", "vm")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(stores, func(a, b int) bool {
|
||||
return strings.ToLower(dsName(stores[a])) < strings.ToLower(dsName(stores[b]))
|
||||
})
|
||||
|
||||
var capacity, free int64
|
||||
printRow(dsColumns, "", nil)
|
||||
for _, ds := range stores {
|
||||
sum := ds.Summary
|
||||
used, usedKnown := dsUsedPercent(sum)
|
||||
over, overKnown := dsOverPercent(sum)
|
||||
if sum.Accessible {
|
||||
capacity += sum.Capacity
|
||||
free += sum.FreeSpace
|
||||
}
|
||||
|
||||
printRow(dsColumns, "", []cell{
|
||||
{dsName(ds), cWhite.fg()},
|
||||
{sum.Type, colAside},
|
||||
dsSize(sum.Capacity, sum.Accessible),
|
||||
dsSize(sum.FreeSpace, sum.Accessible),
|
||||
pctCell(used, usedKnown, loadColor(used, usedKnown)),
|
||||
dsSize(dsProvisioned(sum), sum.Accessible),
|
||||
pctCell(over, overKnown, overColor(over, overKnown)),
|
||||
{Itoa(len(ds.Vm)), colSize},
|
||||
{string(ds.OverallStatus), statusColor(ds.OverallStatus)},
|
||||
{dsState(sum), dsStateColor(sum)},
|
||||
})
|
||||
|
||||
if telemetry != "" {
|
||||
post(telemetry, SF("ds,%s,%d,%d,%.2f,%d,%d,%s",
|
||||
dsName(ds), sum.Capacity, sum.FreeSpace, used,
|
||||
dsProvisioned(sum), len(ds.Vm), ds.OverallStatus))
|
||||
}
|
||||
}
|
||||
|
||||
// One line of estate: the figure somebody asks for immediately after
|
||||
// reading the table, and the reason the table is worth printing at all.
|
||||
if capacity > 0 {
|
||||
P()
|
||||
PF("%s %s of %s free (%s used)\n",
|
||||
Cwb(plural(len(stores), "datastore")),
|
||||
Co(units.ByteSize(free).String()),
|
||||
Co(units.ByteSize(capacity).String()),
|
||||
Co(SF("%.0f %%", 100.0-100.0/float64(capacity)*float64(free))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsName prefers the summary's name over the entity's: they are two different
|
||||
// vSphere properties and a datastore that was renamed can answer differently to
|
||||
// each, the same way a host can (see countOn in host.go).
|
||||
func dsName(ds mo.Datastore) string {
|
||||
if ds.Summary.Name != "" {
|
||||
return ds.Summary.Name
|
||||
}
|
||||
return ds.Name
|
||||
}
|
||||
|
||||
// dsProvisioned is what has been promised out of the datastore: what is in use
|
||||
// plus what thin disks are entitled to grow into. Past the capacity that is a
|
||||
// promise the datastore cannot keep if every machine takes what it was offered,
|
||||
// which is why it has a column of its own rather than being folded into "used".
|
||||
func dsProvisioned(sum types.DatastoreSummary) int64 {
|
||||
return sum.Capacity - sum.FreeSpace + sum.Uncommitted
|
||||
}
|
||||
|
||||
func dsUsedPercent(sum types.DatastoreSummary) (float64, bool) {
|
||||
if !sum.Accessible || sum.Capacity <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 100.0 - 100.0/float64(sum.Capacity)*float64(sum.FreeSpace), true
|
||||
}
|
||||
|
||||
func dsOverPercent(sum types.DatastoreSummary) (float64, bool) {
|
||||
if !sum.Accessible || sum.Capacity <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 100.0 / float64(sum.Capacity) * float64(dsProvisioned(sum)), true
|
||||
}
|
||||
|
||||
// overColor: thin provisioning past the capacity is ordinary and not a fault,
|
||||
// so a hundred per cent is a word of warning rather than an alarm; half again
|
||||
// as much as there is, is an alarm.
|
||||
func overColor(pct float64, known bool) string {
|
||||
switch {
|
||||
case !known:
|
||||
return colOff
|
||||
case pct >= 150:
|
||||
return colFull
|
||||
case pct >= 100:
|
||||
return colBusy
|
||||
}
|
||||
return colSize
|
||||
}
|
||||
|
||||
// dsSize is a byte figure, or a dash where the datastore cannot vouch for it.
|
||||
func dsSize(b int64, accessible bool) cell {
|
||||
if !accessible {
|
||||
return cell{"-", colOff}
|
||||
}
|
||||
return cell{units.ByteSize(b).String(), colSize}
|
||||
}
|
||||
|
||||
// pctCell is a percentage in a narrow column: no sign, because the header has
|
||||
// one, and a dash where there is no figure rather than a nought.
|
||||
func pctCell(pct float64, known bool, col string) cell {
|
||||
if !known {
|
||||
return cell{"-", colOff}
|
||||
}
|
||||
return cell{SF("%.0f", pct), col}
|
||||
}
|
||||
|
||||
// dsState is what vSphere says about the datastore itself, as opposed to the
|
||||
// alarms rolled up in its status: whether it can be reached at all, and whether
|
||||
// it is being emptied for removal.
|
||||
func dsState(sum types.DatastoreSummary) string {
|
||||
var parts []string
|
||||
if !sum.Accessible {
|
||||
parts = append(parts, "inaccessible")
|
||||
}
|
||||
switch sum.MaintenanceMode {
|
||||
case "", string(types.DatastoreSummaryMaintenanceModeStateNormal):
|
||||
default:
|
||||
parts = append(parts, string(sum.MaintenanceMode))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "ok"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func dsStateColor(sum types.DatastoreSummary) string {
|
||||
if !sum.Accessible {
|
||||
return colFull
|
||||
}
|
||||
if sum.MaintenanceMode != "" &&
|
||||
sum.MaintenanceMode != string(types.DatastoreSummaryMaintenanceModeStateNormal) {
|
||||
return colBusy
|
||||
}
|
||||
return colOK
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
func dsSummary(capacity, free, uncommitted int64, accessible bool) types.DatastoreSummary {
|
||||
return types.DatastoreSummary{
|
||||
Name: "LocalDS_0", Type: "VMFS", Capacity: capacity,
|
||||
FreeSpace: free, Uncommitted: uncommitted, Accessible: accessible,
|
||||
}
|
||||
}
|
||||
|
||||
// What has been promised out of a datastore is what is in use plus what thin
|
||||
// disks may still grow into — the figure that says whether the datastore can
|
||||
// keep its promises, and the reason it is a column of its own.
|
||||
func TestProvisionedAndUsed(t *testing.T) {
|
||||
sum := dsSummary(1000, 400, 800, true)
|
||||
|
||||
if got := dsProvisioned(sum); got != 1400 {
|
||||
t.Errorf("provisioned = %d, want 1400", got)
|
||||
}
|
||||
used, ok := dsUsedPercent(sum)
|
||||
if !ok || used != 60 {
|
||||
t.Errorf("used = %v (known %v), want 60", used, ok)
|
||||
}
|
||||
over, ok := dsOverPercent(sum)
|
||||
if !ok || over != 140 {
|
||||
t.Errorf("over = %v (known %v), want 140", over, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A datastore nobody can reach cannot vouch for its own figures, so it reports
|
||||
// none. A datastore that says 0 B free looks like an emergency; one that cannot
|
||||
// be reached is a different one.
|
||||
func TestAnUnreachableDatastoreReportsNothing(t *testing.T) {
|
||||
sum := dsSummary(1000, 0, 0, false)
|
||||
|
||||
if _, ok := dsUsedPercent(sum); ok {
|
||||
t.Error("an inaccessible datastore reported a usage figure")
|
||||
}
|
||||
if _, ok := dsOverPercent(sum); ok {
|
||||
t.Error("an inaccessible datastore reported an over-commitment figure")
|
||||
}
|
||||
if got := dsSize(1000, false); got.text != "-" || got.col != colOff {
|
||||
t.Errorf("its capacity is shown as %q", got.text)
|
||||
}
|
||||
if dsState(sum) != "inaccessible" || dsStateColor(sum) != colFull {
|
||||
t.Errorf("its state is %q", dsState(sum))
|
||||
}
|
||||
}
|
||||
|
||||
// Thin provisioning past the capacity is ordinary practice, not a fault: a word
|
||||
// of warning at a hundred per cent, an alarm at half again as much.
|
||||
func TestOverCommitmentColours(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pct float64
|
||||
want string
|
||||
}{{50, colSize}, {99, colSize}, {100, colBusy}, {149, colBusy}, {150, colFull}} {
|
||||
if got := overColor(c.pct, true); got != c.want {
|
||||
t.Errorf("%.0f %% over-committed is coloured wrongly", c.pct)
|
||||
}
|
||||
}
|
||||
if overColor(0, false) != colOff {
|
||||
t.Error("an unknown over-commitment is coloured as a figure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentCellHasNoSignAndNoNought(t *testing.T) {
|
||||
if got := pctCell(93.4, true, colSize); got.text != "93" {
|
||||
t.Errorf("a percentage is shown as %q", got.text)
|
||||
}
|
||||
if got := pctCell(0, false, colSize); got.text != "-" {
|
||||
t.Errorf("an unknown percentage is shown as %q", got.text)
|
||||
}
|
||||
}
|
||||
|
||||
// A datastore in maintenance is being emptied on purpose: worth saying, not
|
||||
// worth an alarm.
|
||||
func TestMaintenanceModeIsSaidButNotAlarmed(t *testing.T) {
|
||||
sum := dsSummary(1000, 500, 0, true)
|
||||
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateEnteringMaintenance)
|
||||
|
||||
if got := dsState(sum); got != "enteringMaintenance" {
|
||||
t.Errorf("the state reads %q", got)
|
||||
}
|
||||
if dsStateColor(sum) != colBusy {
|
||||
t.Error("entering maintenance is painted as a fault")
|
||||
}
|
||||
|
||||
sum.MaintenanceMode = string(types.DatastoreSummaryMaintenanceModeStateNormal)
|
||||
if got := dsState(sum); got != "ok" {
|
||||
t.Errorf("an ordinary datastore reads %q", got)
|
||||
}
|
||||
if dsStateColor(sum) != colOK {
|
||||
t.Error("an ordinary datastore is not painted as ordinary")
|
||||
}
|
||||
}
|
||||
|
||||
// The name in the summary and the name of the entity are two different vSphere
|
||||
// properties, and a renamed datastore can answer differently to each — the same
|
||||
// trap the host counts fell into.
|
||||
func TestDatastoreNamePrefersTheSummary(t *testing.T) {
|
||||
ds := mo.Datastore{Summary: types.DatastoreSummary{Name: "new-name"}}
|
||||
ds.Name = "old-name"
|
||||
if got := dsName(ds); got != "new-name" {
|
||||
t.Errorf("dsName = %q", got)
|
||||
}
|
||||
|
||||
ds.Summary.Name = ""
|
||||
if got := dsName(ds); got != "old-name" {
|
||||
t.Errorf("with no summary name, dsName = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// events.go — one machine's recent history.
|
||||
//
|
||||
// `gvm log` is the whole vCenter over the last hour, which is the right shape
|
||||
// for a mail and the wrong one for the question actually being asked in front
|
||||
// of a machine's sheet: why is this thing off, who rebooted it, what happened
|
||||
// at four this morning. vCenter keeps the answer per object, so this asks it
|
||||
// per object.
|
||||
//
|
||||
// It is not part of the sheet's own reading. Opening a machine costs one call
|
||||
// for its snapshots and nothing else, and it stays that way: the events are
|
||||
// fetched when they are asked for (the action menu's 'e'), for the one machine
|
||||
// on screen.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/event"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// How many events are worth having, and how long to wait for them. The page is
|
||||
// short on purpose: this answers "what just happened to this machine", and
|
||||
// anything older than the last couple of dozen lines is a question for `log`.
|
||||
const (
|
||||
eventPage = 25
|
||||
eventWait = 20 * time.Second
|
||||
)
|
||||
|
||||
// eventLine is one event as the sheet shows it.
|
||||
type eventLine struct {
|
||||
text string
|
||||
col string
|
||||
}
|
||||
|
||||
// eventsOf reads the machine's most recent events, oldest first — the order a
|
||||
// history reads in, so the last line is the latest thing that happened.
|
||||
func eventsOf(r vmRow) ([]eventLine, error) {
|
||||
if r.sess == nil {
|
||||
return nil, errf("no connection to %s", r.vc.Name)
|
||||
}
|
||||
|
||||
// Bounded. The underlying collector waits for vCenter to hand over its
|
||||
// first page, and this runs in the interactive loop: a server that accepts
|
||||
// the request and then says nothing would otherwise freeze the screen
|
||||
// mid-draw with no key being read.
|
||||
ctx, cancel := context.WithTimeout(r.sess.ctx, eventWait)
|
||||
defer cancel()
|
||||
|
||||
var found []types.BaseEvent
|
||||
err := event.NewManager(r.sess.client.Client).Events(ctx,
|
||||
[]types.ManagedObjectReference{r.ref}, eventPage, false, false,
|
||||
func(_ types.ManagedObjectReference, evs []types.BaseEvent) error {
|
||||
found = append(found, evs...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, errf("%s did not answer within %s", r.vc.Name, eventWait)
|
||||
}
|
||||
return nil, errf("%s: cannot read the events of %s: %w", r.vc.Name, r.name, err)
|
||||
}
|
||||
|
||||
out := make([]eventLine, 0, len(found))
|
||||
for _, e := range found {
|
||||
out = append(out, eventLineOf(e))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// eventLineOf is one event: when, and what. The severity decides the colour and
|
||||
// is otherwise left out — "info" down twenty lines is twenty times four
|
||||
// characters spent saying nothing.
|
||||
func eventLineOf(e types.BaseEvent) eventLine {
|
||||
base := e.GetEvent()
|
||||
msg := strings.TrimSpace(base.FullFormattedMessage)
|
||||
if msg == "" {
|
||||
msg = SF("%T", e)
|
||||
}
|
||||
// One line per event: a formatted vSphere message can carry newlines, and a
|
||||
// value with a newline in it would break the sheet's own line counting.
|
||||
msg = strings.Join(strings.Fields(msg), " ")
|
||||
|
||||
return eventLine{
|
||||
text: base.CreatedTime.Local().Format("02.01. 15:04") + " " + msg,
|
||||
col: eventColor(severity(e)),
|
||||
}
|
||||
}
|
||||
|
||||
// eventColor takes log.go's reading of the severity into the palette. Only the
|
||||
// two that matter are coloured; the rest is history, and history is grey.
|
||||
func eventColor(sev string) string {
|
||||
switch strings.ToLower(sev) {
|
||||
case "error":
|
||||
return colFull
|
||||
case "warning":
|
||||
return colBusy
|
||||
}
|
||||
return colAside
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// guest.go — the three ways out of a machine's sheet.
|
||||
//
|
||||
// Everything else in gvm looks at machines. These look them up: log in to one,
|
||||
// take its address away with you, open it in the vSphere client. They are the
|
||||
// keystrokes that stop gvm being a viewer you then have to type an address out
|
||||
// of by hand — and none of them touches the vCenter at all, which is why they
|
||||
// are letters of the sheet itself rather than entries in the action menu.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// sshTarget is what to connect to: the name the guest calls itself, or its
|
||||
// address. The name is preferred where there is one — it is what is in the known
|
||||
// hosts file, and an address that came out of VMware Tools may be one of several.
|
||||
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
|
||||
|
||||
// sshTargetIs is the same, plus which of the two it turned out to be. What was
|
||||
// copied is worth naming — a sheet shows a hostname and an address, and
|
||||
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
|
||||
// working that out a second time somewhere else is how two answers drift apart.
|
||||
func (r vmRow) sshTargetIs() (target, kind string) {
|
||||
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
|
||||
return strings.TrimSpace(g.HostName), "hostname"
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
return ip, "address"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// defaultSSH is what `h` runs when the configuration says nothing.
|
||||
const defaultSSH = "ssh root@%h"
|
||||
|
||||
// sshCommand is the command line to run, as argv: the configured template with
|
||||
// the target put where %h stands, or appended when it does not stand anywhere.
|
||||
//
|
||||
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
|
||||
// anything else is a second step once the session is up — and having it in the
|
||||
// default means the common case needs no configuration file at all. A template
|
||||
// of one's own overrides it entirely, root and all.
|
||||
//
|
||||
// The target is its own argument and never goes through a shell. It comes from
|
||||
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
|
||||
// and a guest that called itself `; rm -rf ~` would otherwise be running that
|
||||
// on the operator's workstation. The template is the operator's own line out of
|
||||
// their own configuration file, so it is split on spaces and no further:
|
||||
// quoting is not supported, which is a limit worth having here.
|
||||
func sshCommand(template, target string) []string {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
template = defaultSSH
|
||||
}
|
||||
fields := strings.Fields(template)
|
||||
|
||||
argv := make([]string, 0, len(fields)+1)
|
||||
placed := false
|
||||
for _, f := range fields {
|
||||
if strings.Contains(f, "%h") {
|
||||
argv = append(argv, strings.ReplaceAll(f, "%h", target))
|
||||
placed = true
|
||||
continue
|
||||
}
|
||||
argv = append(argv, f)
|
||||
}
|
||||
if !placed {
|
||||
argv = append(argv, target)
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
// vsphereURL is the machine's page in the vSphere client.
|
||||
//
|
||||
// The shape is the H5 client's own: the object's reference and the vCenter's
|
||||
// instance UUID, which is the serverGuid that client puts in every link. The
|
||||
// UUID cannot be worked out from the configuration — it is asked of the server
|
||||
// on connecting (session.instanceUUID) — so a machine read over a connection
|
||||
// that has gone has no URL rather than a wrong one.
|
||||
func vsphereURL(r vmRow) string {
|
||||
if r.sess == nil {
|
||||
return ""
|
||||
}
|
||||
return vsphereLink(r.vc.URL, r.ref.Value, r.sess.instanceUUID())
|
||||
}
|
||||
|
||||
// vsphereLink is the link itself, from the three things it is made of — so the
|
||||
// shape can be checked without a server, which is the only way it can be
|
||||
// checked at all: a wrong link opens a client that says "object not found",
|
||||
// which looks like a vCenter problem rather than a gvm one.
|
||||
func vsphereLink(vcURL, moref, guid string) string {
|
||||
if vcURL == "" || moref == "" || guid == "" {
|
||||
return ""
|
||||
}
|
||||
return SF("%s/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:%s:%s/summary",
|
||||
strings.TrimSuffix(vcURL, "/"), moref, guid)
|
||||
}
|
||||
|
||||
// openerCommand is how this operating system opens a URL. Nothing is opened
|
||||
// where there is no answer rather than something being guessed at, and the
|
||||
// caller says the URL out loud instead — which is the useful half anyway.
|
||||
func openerCommand() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "open"
|
||||
case "linux":
|
||||
return "xdg-open"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// openURL hands the URL to the desktop and does not wait for it. A browser
|
||||
// takes seconds to start and prints its own complaints; neither belongs in a
|
||||
// full-screen list.
|
||||
func openURL(url string) error {
|
||||
opener := openerCommand()
|
||||
if opener == "" {
|
||||
return errf("no way to open a browser on %s", runtime.GOOS)
|
||||
}
|
||||
cmd := exec.Command(opener, url)
|
||||
cmd.Stdout, cmd.Stderr = nil, nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return errf("cannot run %s: %w", opener, err)
|
||||
}
|
||||
go cmd.Wait() // reaped in the background; nothing here waits on a browser
|
||||
return nil
|
||||
}
|
||||
|
||||
// osc52 is the escape sequence that puts text in the clipboard of the terminal
|
||||
// that is being looked at, wherever that terminal is running. It is the only way
|
||||
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
|
||||
// end of a login copies into the clipboard of a machine nobody is sitting at.
|
||||
//
|
||||
// It is also the way a terminal is free to ignore, and several do until they are
|
||||
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
|
||||
// is why it is not the only thing tried. See toClipboard.
|
||||
func osc52(text string) string {
|
||||
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
|
||||
}
|
||||
|
||||
// toClipboard puts text where the next paste will find it and reports the way it
|
||||
// got there, named — "pbcopy" — or empty when the escape sequence was the only
|
||||
// thing on offer. The caller says so on the status line: a copy nobody can see
|
||||
// happen is one that has to be described, or the only way to find out whether it
|
||||
// worked is to paste somewhere and look.
|
||||
//
|
||||
// Both routes are used, because either alone leaves somebody with nothing: the
|
||||
// local command always works where there is one, and the sequence is what
|
||||
// carries the text home from the far end of an ssh login.
|
||||
func (b *browser) toClipboard(text string) string {
|
||||
b.write(osc52(text))
|
||||
|
||||
argv := clipTool()
|
||||
if argv == nil {
|
||||
return ""
|
||||
}
|
||||
if err := runClipTool(argv, text); err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(argv[0])
|
||||
}
|
||||
|
||||
// clipTool is the command that puts something in this machine's clipboard, where
|
||||
// this is the machine the person is sitting at. Over an ssh login it is not:
|
||||
// there the terminal's own sequence is the only route that ends up where the
|
||||
// person can paste it, and a local clipboard would be the wrong machine's.
|
||||
func clipTool() []string {
|
||||
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = [][]string{{"pbcopy"}}
|
||||
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
|
||||
// A Linux console or a machine with no session to speak of: there is
|
||||
// nothing for xclip to hand the text to, and it would sit there waiting.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if path, err := exec.LookPath(c[0]); err == nil {
|
||||
return append([]string{path}, c[1:]...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runClipTool feeds the text to it on standard input, which is how all of them
|
||||
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
|
||||
// hostname into a pasted hostname and a return.
|
||||
func runClipTool(argv []string, text string) error {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// runInTerminal gives the terminal back, runs a command in it, and takes it
|
||||
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
|
||||
// and the keyboard.
|
||||
//
|
||||
// The keystroke reader is rebuilt on the way back in: the one that was running
|
||||
// is reading a file descriptor that closing the terminal has taken away from
|
||||
// it, and its goroutine ends when that read fails.
|
||||
func (b *browser) runInTerminal(argv []string) error {
|
||||
if len(argv) == 0 {
|
||||
return errf("nothing to run")
|
||||
}
|
||||
b.close() // clears the screen, puts the cursor back, hands the tty back
|
||||
|
||||
release := holdTerminalSignals()
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
err := cmd.Run()
|
||||
release()
|
||||
|
||||
if err != nil && !interrupted(err) {
|
||||
// Something to read: the message would be wiped by the next frame, so
|
||||
// the screen is held until somebody has seen it.
|
||||
PF("\n%s %v\n", Crb(argv[0]+":"), err)
|
||||
PF("%s", Cd("press enter to come back to gvm "))
|
||||
os.Stdin.Read(make([]byte, 1))
|
||||
}
|
||||
|
||||
// Coming back in. If the terminal cannot be taken again there is nothing
|
||||
// left to draw on, so it is said here, in the ordinary terminal that is
|
||||
// still on screen — and the loop ends on its own: the reader that was
|
||||
// running is reading a closed descriptor and reports that as a Ctrl-C.
|
||||
if oerr := b.open(); oerr != nil {
|
||||
PE("cannot take the terminal back", oerr.Error())
|
||||
return oerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
|
||||
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
|
||||
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
|
||||
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
|
||||
// the list one was going back to was not.
|
||||
//
|
||||
// They are caught rather than ignored, and the difference matters: exec resets a
|
||||
// caught signal to its default in the child, while an ignored one is inherited.
|
||||
// An ssh that cannot be interrupted while it hangs on a machine that is not
|
||||
// answering would be worse than what this fixes.
|
||||
//
|
||||
// The returned func puts them back the way they were, which is gvm's own raw
|
||||
// mode reading Ctrl-C as a key like any other.
|
||||
func holdTerminalSignals() func() {
|
||||
// Buffered and never read: the signal package sends without blocking and
|
||||
// drops what does not fit, which is the whole intent — these are being
|
||||
// swallowed, not handled.
|
||||
ch := make(chan os.Signal, 4)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
|
||||
return func() { signal.Stop(ch) }
|
||||
}
|
||||
|
||||
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
|
||||
// Ctrl-\) rather than because something went wrong. Nothing is held on the
|
||||
// screen for it: the person who pressed it knows what happened and wants to be
|
||||
// back in the list, not reading that ssh got a signal.
|
||||
func interrupted(err error) bool {
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
return false
|
||||
}
|
||||
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
|
||||
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
|
||||
}
|
||||
// A shell between gvm and the signal reports it as its own exit status
|
||||
// instead, in the shells' 128+signal spelling.
|
||||
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The name the guest calls itself is what is in the known hosts file; the
|
||||
// address is the fallback, and a machine whose guest says nothing has neither.
|
||||
func TestSSHTarget(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if got := r.sshTarget(); got != "web01.example" {
|
||||
t.Errorf("sshTarget = %q, want the hostname", got)
|
||||
}
|
||||
|
||||
r.vm.Guest.HostName = ""
|
||||
if got := r.sshTarget(); got != "10.0.0.5" {
|
||||
t.Errorf("with no hostname, sshTarget = %q", got)
|
||||
}
|
||||
|
||||
r.vm.Guest = nil
|
||||
if got := r.sshTarget(); got != "" {
|
||||
t.Errorf("with no guest information at all, sshTarget = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommand(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
template string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
|
||||
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
|
||||
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
|
||||
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
|
||||
{"mosh %h", []string{"mosh", "web01"}},
|
||||
{"ssh root@%h", []string{"ssh", "root@web01"}},
|
||||
} {
|
||||
if got := sshCommand(c.template, "web01"); !slices.Equal(got, c.want) {
|
||||
t.Errorf("sshCommand(%q) = %v, want %v", c.template, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A clipboard is invisible, so what the status line says about it has to be
|
||||
// exact: which of the two the machine gave up — the hostname or the address —
|
||||
// and which clipboard it went into.
|
||||
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
|
||||
// Pretending to be an ssh login does two things: it is the case where the
|
||||
// escape sequence is the only route, and it keeps the tests off the
|
||||
// clipboard of whoever is running them.
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
row func() vmRow
|
||||
want []string
|
||||
}{
|
||||
{"a guest that reports its name", func() vmRow {
|
||||
return testRow("web01", true, "10.0.0.5")
|
||||
}, []string{"hostname", "web01.example"}},
|
||||
{"a guest that reports only an address", func() vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.HostName = ""
|
||||
return r
|
||||
}, []string{"address", "10.0.0.5"}},
|
||||
} {
|
||||
r := c.row()
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = pw
|
||||
b.copyAddress(r)
|
||||
pw.Close()
|
||||
sent, _ := io.ReadAll(pr)
|
||||
pr.Close()
|
||||
|
||||
for _, want := range c.want {
|
||||
if !strings.Contains(b.status, want) {
|
||||
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
|
||||
}
|
||||
}
|
||||
// And the sequence carried the same string, base64 and all.
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
|
||||
if !strings.Contains(string(sent), payload) {
|
||||
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
|
||||
// far end of a login copies into the clipboard of a machine nobody is sitting
|
||||
// at, and the terminal's own sequence is the only route home.
|
||||
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
if got := clipTool(); got != nil {
|
||||
t.Errorf("an ssh session offered %v as a clipboard", got)
|
||||
}
|
||||
|
||||
t.Setenv("SSH_CONNECTION", "")
|
||||
t.Setenv("SSH_TTY", "/dev/ttys004")
|
||||
if got := clipTool(); got != nil {
|
||||
t.Errorf("an ssh session offered %v as a clipboard", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the tool is, it takes the text on standard input and gets it verbatim
|
||||
// — no trailing newline, which in a clipboard turns a pasted hostname into a
|
||||
// pasted hostname and a return.
|
||||
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "clipboard")
|
||||
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
|
||||
t.Fatalf("runClipTool: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "web01.example" {
|
||||
t.Errorf("the clipboard would get %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
|
||||
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
|
||||
// reads but a SIGINT to the whole foreground process group, and gvm is in that
|
||||
// group. While a child has the screen the signal has to be caught and dropped:
|
||||
// were it not, this test would kill the test binary rather than fail.
|
||||
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
|
||||
release := holdTerminalSignals()
|
||||
defer release()
|
||||
|
||||
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
|
||||
if err := syscall.Kill(os.Getpid(), sig); err != nil {
|
||||
t.Fatalf("cannot send myself a %v: %v", sig, err)
|
||||
}
|
||||
}
|
||||
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// And the child must still die of it, which is why the signal is caught and
|
||||
// not ignored: exec resets a caught signal to its default in the child,
|
||||
// while an ignored one is inherited — signal.Ignore here would leave an ssh
|
||||
// that cannot be interrupted while it hangs on a machine that is not
|
||||
// answering.
|
||||
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
|
||||
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
|
||||
}
|
||||
}
|
||||
|
||||
// And a child that died of that keystroke is told apart from one that failed, so
|
||||
// the screen is not held with "signal: interrupt" over something somebody meant
|
||||
// to do.
|
||||
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
script string
|
||||
want bool
|
||||
}{
|
||||
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
|
||||
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
|
||||
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
|
||||
{"exit 1", false}, // a remote command that failed
|
||||
{"exit 255", false}, // ssh's own "could not connect"
|
||||
{"exit 0", false}, // nothing wrong at all
|
||||
} {
|
||||
err := exec.Command("sh", "-c", c.script).Run()
|
||||
if got := interrupted(err); got != c.want {
|
||||
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Something that never got as far as a child at all is not an interruption.
|
||||
if interrupted(errf("nothing to run")) {
|
||||
t.Error("a plain error was taken for a Ctrl-C")
|
||||
}
|
||||
}
|
||||
|
||||
// The target is one argument and never a piece of a shell command. It comes
|
||||
// from the guest — a name the guest chose for itself — so a machine that called
|
||||
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
|
||||
// as a command that runs.
|
||||
func TestTheTargetIsNeverShellCode(t *testing.T) {
|
||||
nasty := "; rm -rf ~"
|
||||
got := sshCommand("ssh -l root %h", nasty)
|
||||
|
||||
if len(got) != 4 || got[3] != nasty {
|
||||
t.Fatalf("the target was taken apart: %v", got)
|
||||
}
|
||||
for _, arg := range got[:3] {
|
||||
if strings.Contains(arg, "rm") {
|
||||
t.Errorf("the target leaked into %q", arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The link is the H5 client's own shape: the object's reference and the
|
||||
// vCenter's instance UUID, which is the serverGuid that client wants.
|
||||
func TestVsphereLink(t *testing.T) {
|
||||
got := vsphereLink("https://v308.example/", "vm-42", "6ff1a05e-1111")
|
||||
want := "https://v308.example/ui/app/vm;nav=h/urn:vmomi:VirtualMachine:vm-42:6ff1a05e-1111/summary"
|
||||
if got != want {
|
||||
t.Errorf("vsphereLink =\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
|
||||
// A missing piece gives no link rather than a wrong one: a link that opens
|
||||
// a client saying "object not found" looks like a vCenter fault.
|
||||
for _, c := range [][3]string{
|
||||
{"", "vm-42", "guid"}, {"https://v308.example", "", "guid"}, {"https://v308.example", "vm-42", ""},
|
||||
} {
|
||||
if got := vsphereLink(c[0], c[1], c[2]); got != "" {
|
||||
t.Errorf("vsphereLink(%q, %q, %q) = %q, want nothing", c[0], c[1], c[2], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that was read over a connection that has gone has no link.
|
||||
func TestVsphereURLNeedsTheConnection(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if got := vsphereURL(r); got != "" {
|
||||
t.Errorf("a row with no session produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The clipboard is the terminal's, not the machine's: gvm is run over ssh as
|
||||
// often as not, and pbcopy would then copy into a clipboard nobody is looking
|
||||
// at. This is the escape sequence that asks the terminal itself.
|
||||
func TestOsc52CarriesTheTextItself(t *testing.T) {
|
||||
got := osc52("10.0.0.5")
|
||||
if !strings.HasPrefix(got, "\x1b]52;c;") || !strings.HasSuffix(got, "\a") {
|
||||
t.Fatalf("osc52 = %q", got)
|
||||
}
|
||||
payload := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]52;c;"), "\a")
|
||||
back, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("the payload is not base64: %v", err)
|
||||
}
|
||||
if string(back) != "10.0.0.5" {
|
||||
t.Errorf("the clipboard would get %q", back)
|
||||
}
|
||||
}
|
||||
|
||||
// The sheet says why a key cannot do anything rather than swallowing it, and the
|
||||
// two that need somewhere to connect to say exactly that.
|
||||
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest = nil
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
|
||||
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
for _, k := range []rune{'h', 'y'} {
|
||||
b.setStatus("", "")
|
||||
b.detailRune(k)
|
||||
if !strings.Contains(b.status, "no address") {
|
||||
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// With an address there is nothing to object to. Only the check is asked
|
||||
// here — what follows it is an ssh session and a clipboard.
|
||||
b.rows[0] = testRow("web01", true, "10.0.0.5")
|
||||
if !b.hasAddress(b.rows[0]) {
|
||||
t.Errorf("a machine with an address was refused: %s", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// Every letter in the menu reaches exactly one entry, or one of them is
|
||||
// unreachable — and the snapshot half must not have taken a letter the
|
||||
// power half already uses.
|
||||
func TestMenuLettersAreDistinct(t *testing.T) {
|
||||
b := &browser{}
|
||||
seen := map[rune]string{}
|
||||
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil, testSizing()) {
|
||||
if m.isSeparator() {
|
||||
continue
|
||||
}
|
||||
if other, ok := seen[m.key]; ok {
|
||||
t.Errorf("%q is the letter for both %q and %q", string(m.key), other, m.label)
|
||||
}
|
||||
seen[m.key] = m.label
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ var helpTail = strings.Join([]string{
|
||||
" --update Fetch the newest release and replace this binary",
|
||||
" --check-update Look for a newer release, change nothing",
|
||||
"",
|
||||
" Shell completion:",
|
||||
" gvm completion zsh The completion script, machine names included",
|
||||
"",
|
||||
" Run 'gvm' with no subcommand to browse the machines interactively.",
|
||||
}, "\n")
|
||||
|
||||
@@ -44,7 +47,7 @@ var helpTail = strings.Join([]string{
|
||||
// number with -ldflags "-X main.version=...". The value here is what a plain
|
||||
// `go build` produces, and it tracks the line of development rather than the
|
||||
// latest build: version.txt holds that.
|
||||
var version = "1.0.0"
|
||||
var version = "1.2.0"
|
||||
|
||||
func main() {
|
||||
// Answered before anything else: an update has to work on a machine that
|
||||
@@ -53,6 +56,16 @@ func main() {
|
||||
if updateFlags() {
|
||||
return
|
||||
}
|
||||
// The two options the completion scripts call on every Tab, answered here
|
||||
// for the same reason: they have to work where the configuration does not,
|
||||
// they must print nothing but the candidates, and they must not read
|
||||
// ~/.gvmrc — reading it seals a password standing in it in the clear, and a
|
||||
// Tab key has no business rewriting a file. (`gvm completion <shell>`, which
|
||||
// is what writes those scripts, is answered in run(): it needs the flag
|
||||
// parser to have been built first.)
|
||||
if completionFlags() {
|
||||
return
|
||||
}
|
||||
|
||||
err := run()
|
||||
updateNote() // after the output: a footer, not a headline
|
||||
@@ -71,7 +84,7 @@ func run() error {
|
||||
|
||||
var vcname string
|
||||
var yes bool
|
||||
flaggy.String(&vcname, "v", "vcenter", "vCenter to work on (default: 'default' from ~/.gvmrc)")
|
||||
flaggy.String(&vcname, "v", "vcenter", "vCenter to work on, or several separated by commas for the machine list (default: 'default' from ~/.gvmrc)")
|
||||
flaggy.Bool(&yes, "y", "yes", "Answer the confirmation of a destructive command in advance (for cron)")
|
||||
|
||||
var vmList, vmInteractive bool
|
||||
@@ -82,14 +95,18 @@ func run() error {
|
||||
subVM.Bool(&vmInteractive, "i", "interactive", "Browse the machines (the default)")
|
||||
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
|
||||
var vmSort string
|
||||
var vmReverse bool
|
||||
subVM.String(&vmSort, "", "sort", "Order for -l: name, power, cpu%, mem%, size, cpus, vc, host, ip")
|
||||
var vmReverse, vmIssues, vmJSON bool
|
||||
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, snaps, old, why, size, cpus, vc, host, ip")
|
||||
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
|
||||
subVM.Bool(&vmIssues, "", "issues", "Only the machines with something wrong with them (^w in the list)")
|
||||
subVM.Bool(&vmJSON, "", "json", "Print the listing as a JSON document instead of a table")
|
||||
|
||||
// The destructive options deliberately have no short letter: --revert and
|
||||
// --removeall have to be spelled out, so neither can be reached by a slip of
|
||||
// one key next to a harmless one.
|
||||
var snapLs, snapNew_, snapRm, snapRmAll, snapRevertTo, snapName string
|
||||
var snapOld, snapMail bool
|
||||
snapDays := snapOldDays
|
||||
subSnap := flaggy.NewSubcommand("snap")
|
||||
subSnap.Description = "Snapshot commands"
|
||||
subSnap.String(&snapLs, "l", "list", "List the snapshots of <vm>")
|
||||
@@ -98,6 +115,12 @@ func run() error {
|
||||
subSnap.String(&snapRm, "r", "remove", "Remove one snapshot of <vm>, named with -s")
|
||||
subSnap.String(&snapRevertTo, "", "revert", "Revert <vm> to the snapshot named with -s (destroys everything since)")
|
||||
subSnap.String(&snapRmAll, "", "removeall", "Remove all snapshots of <vm>")
|
||||
// The age report reads every server at once, like `vm -l` and unlike the
|
||||
// rest of this subcommand: housekeeping is a question about the estate, not
|
||||
// about one vCenter. -v still narrows it.
|
||||
subSnap.Bool(&snapOld, "", "old", "Report the snapshots older than -d days, on every vCenter")
|
||||
subSnap.Int(&snapDays, "d", "days", "How old is old, for --old")
|
||||
subSnap.Bool(&snapMail, "m", "mail", "Mail that report as well")
|
||||
|
||||
var pwOn, pwShutdown, pwReboot, pwOff, pwReset string
|
||||
subPower := flaggy.NewSubcommand("power")
|
||||
@@ -108,12 +131,31 @@ func run() error {
|
||||
subPower.String(&pwOff, "", "off", "Power off <vm> at the hypervisor — hard, like pulling the plug")
|
||||
subPower.String(&pwReset, "", "reset", "Reset <vm> at the hypervisor — hard, like the reset button")
|
||||
|
||||
// The machine is a flag of its own here, not the value of the action flag as
|
||||
// it is for power and snap: this command takes a machine *and* a number, and
|
||||
// only one of the two can be the value of "--cpus".
|
||||
// szCPUs and szMemory, not sizeCPUs and sizeMemory: those two are the
|
||||
// sizeKind constants, and locals of the same name would shadow them for the
|
||||
// rest of this function — where the next person to write parseSize(sizeCPUs,
|
||||
// ...) would get a type error with no obvious cause.
|
||||
var szVM, szCPUs, szMemory string
|
||||
subSize := flaggy.NewSubcommand("size")
|
||||
subSize.Description = "Show or change a machine's vCPUs and memory"
|
||||
subSize.String(&szVM, "", "vm", "The machine to show or change")
|
||||
subSize.String(&szCPUs, "c", "cpus", "Set its vCPU count")
|
||||
subSize.String(&szMemory, "m", "memory", "Set its memory, in GB (or 512m for MB)")
|
||||
|
||||
var hostCount, hostTelemetry bool
|
||||
subHost := flaggy.NewSubcommand("host")
|
||||
subHost.Description = "Host commands"
|
||||
subHost.Bool(&hostCount, "c", "count", "Only the machine counts per host")
|
||||
subHost.Bool(&hostTelemetry, "t", "telemetry", "Also post the numbers to the monitoring server")
|
||||
|
||||
var dsTelemetry bool
|
||||
subDS := flaggy.NewSubcommand("ds")
|
||||
subDS.Description = "Datastore commands"
|
||||
subDS.Bool(&dsTelemetry, "t", "telemetry", "Also post the numbers to the monitoring server")
|
||||
|
||||
var logShow, logMail bool
|
||||
logMinutes := 60
|
||||
subLog := flaggy.NewSubcommand("log")
|
||||
@@ -130,10 +172,28 @@ func run() error {
|
||||
flaggy.AttachSubcommand(subVM, 1)
|
||||
flaggy.AttachSubcommand(subSnap, 1)
|
||||
flaggy.AttachSubcommand(subPower, 1)
|
||||
flaggy.AttachSubcommand(subSize, 1)
|
||||
flaggy.AttachSubcommand(subHost, 1)
|
||||
flaggy.AttachSubcommand(subDS, 1)
|
||||
flaggy.AttachSubcommand(subLog, 1)
|
||||
flaggy.AttachSubcommand(subConfig, 1)
|
||||
|
||||
// `gvm completion <shell>` is flaggy's own subcommand — it is in the help
|
||||
// because flaggy puts it there, and flaggy would answer it inside Parse.
|
||||
// It is answered here first, one step earlier, so that the script it writes
|
||||
// can carry the machine names as well (complete.go). A shell this does not
|
||||
// know falls through to flaggy, whose message names the ones it can write.
|
||||
//
|
||||
// Before Parse and before loadConfig, deliberately: a Tab key must not read
|
||||
// ~/.gvmrc, because reading it seals any password standing in it in the
|
||||
// clear, and a completion has no business rewriting a file.
|
||||
if shell, ok := completionRequest(os.Args[1:]); ok {
|
||||
if script, ok := completionScript(shell, flaggyCompletion(shell)); ok {
|
||||
PF("%s", script)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
flaggy.Parse()
|
||||
|
||||
cfg := loadConfig()
|
||||
@@ -144,12 +204,26 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if vmList { // -l prints; anything else browses
|
||||
return lsvm(targets, vmMatch, vmSort, vmReverse)
|
||||
// --json and --issues are ways of printing, so they mean -l without
|
||||
// having to be told twice: browsing a machine list as JSON is not a
|
||||
// thing, and a full-screen list has ^w for the other one.
|
||||
if vmList || vmJSON || vmIssues {
|
||||
return lsvm(targets, lsOptions{match: vmMatch, orderBy: vmSort,
|
||||
reverse: vmReverse, issues: vmIssues, json: vmJSON})
|
||||
}
|
||||
return browseVMs(targets, vmMatch)
|
||||
return browseVMs(targets, vmMatch, cfg.SSH)
|
||||
|
||||
case subSnap.Used:
|
||||
if snapOld {
|
||||
targets, err := cfg.targets(vcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapDays < 0 {
|
||||
return errf("-d wants a number of days, not %d", snapDays)
|
||||
}
|
||||
return snapOldReport(cfg, targets, snapDays, snapMail)
|
||||
}
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -206,6 +280,16 @@ func run() error {
|
||||
return errf("one power operation at a time, not %d", len(given))
|
||||
}
|
||||
|
||||
case subSize.Used:
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if szVM == "" {
|
||||
return errf("size needs a machine: gvm size --vm <machine> [-c <vcpus>] [-m <memory>]")
|
||||
}
|
||||
return sizeCLI(vc, szVM, szCPUs, szMemory, yes)
|
||||
|
||||
case subHost.Used:
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
@@ -214,15 +298,23 @@ func run() error {
|
||||
if hostCount {
|
||||
return vmstat(vc)
|
||||
}
|
||||
telemetry := ""
|
||||
if hostTelemetry {
|
||||
if cfg.Telemetry == "" {
|
||||
return errf("no 'telemetry' url in %s", configFile())
|
||||
}
|
||||
telemetry = cfg.Telemetry
|
||||
telemetry, err := cfg.telemetryURL(hostTelemetry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hoststat(vc, telemetry)
|
||||
|
||||
case subDS.Used:
|
||||
vc, err := cfg.pick(vcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
telemetry, err := cfg.telemetryURL(dsTelemetry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dsstat(vc, telemetry)
|
||||
|
||||
case subLog.Used:
|
||||
if !logShow && !logMail {
|
||||
flaggy.ShowHelpAndExit("")
|
||||
@@ -254,7 +346,7 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return browseVMs(targets, "")
|
||||
return browseVMs(targets, "", cfg.SSH)
|
||||
}
|
||||
|
||||
// showConfig prints what gvm made of ~/.gvmrc and the environment. Passwords
|
||||
@@ -285,6 +377,8 @@ func showConfig(cfg Config) error {
|
||||
P()
|
||||
PF("mail %s -> %s via %s:%d\n", orNone(cfg.MailFrom), orNone(cfg.MailTo), orNone(cfg.SMTPHost), cfg.smtpPort())
|
||||
PF("telemetry %s\n", orNone(cfg.Telemetry))
|
||||
PF("ssh %s\n", orNone(strings.Join(sshCommand(cfg.SSH, "<machine>"), " ")))
|
||||
PF("completion %s\n", inventoryAge())
|
||||
PF("version %s\n", version)
|
||||
return nil
|
||||
}
|
||||
|
||||
+17
-1
@@ -26,10 +26,26 @@ func TestHelpMentionsEveryUpdateOptionItAnswers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The completion subcommand is documented too — flaggy lists it as well,
|
||||
// but not that the script it writes carries machine names. The two options
|
||||
// the scripts themselves call are not documented, the same way the
|
||||
// background refresh is not: nobody types --complete-vms.
|
||||
if !strings.Contains(helpTail, "completion") {
|
||||
t.Error("gvm answers the completion subcommand but the help does not mention it")
|
||||
}
|
||||
for _, f := range []string{"--complete-vms", "--complete-vcenters"} {
|
||||
if strings.Contains(helpTail, f) {
|
||||
t.Errorf("%s is called by the completion script and has no business in the help", f)
|
||||
}
|
||||
}
|
||||
|
||||
// And nothing is promised that is not answered.
|
||||
for _, line := range strings.Split(helpTail, "\n") {
|
||||
for _, word := range strings.Fields(line) {
|
||||
if strings.HasPrefix(word, "--") && !isUpdateFlag(word) {
|
||||
if !strings.HasPrefix(word, "--") {
|
||||
continue
|
||||
}
|
||||
if !isUpdateFlag(word) && !isCompletionFlag(word) {
|
||||
t.Errorf("the help offers %s, which nothing answers", word)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -21,8 +21,11 @@
|
||||
# this file *and* a gvm binary can open the value. The 0600 is what keeps other
|
||||
# users out.
|
||||
|
||||
# The vCenter used when -v is not given. `gvm vm -l` ignores it and asks every
|
||||
# configured server; every other command works on exactly one.
|
||||
# The vCenter used when -v is not given. `gvm` and `gvm vm -l` ignore it and ask
|
||||
# every configured server; the rest work on exactly one.
|
||||
#
|
||||
# -v takes a list for the commands that sweep — `-v v308,v108` — and refuses one
|
||||
# for the commands that act on a single machine.
|
||||
default = v308
|
||||
|
||||
# --- one 'vcenter.<name>.*' block per server ---
|
||||
@@ -54,22 +57,34 @@ vcenter.v38.password = <password>
|
||||
vcenter.v38.datacenter = FEL
|
||||
vcenter.v38.insecure = true
|
||||
|
||||
# --- mail, for `gvm log -m` ---
|
||||
# Without these, `gvm log -m` says so before it queries anything.
|
||||
# --- mail, for `gvm log -m` and `gvm snap --old -m` ---
|
||||
# Without these, both say so before they query anything.
|
||||
mailfrom = root@fhi.mpg.de
|
||||
mailto = mw@pstbx.org
|
||||
smtphost = m0.fhi-berlin.mpg.de
|
||||
smtpport = 25
|
||||
|
||||
# --- telemetry, for `gvm host -t` ---
|
||||
# Where the per-host numbers are posted. Unset (or without -t) nothing is sent.
|
||||
# --- telemetry, for `gvm host -t` and `gvm ds -t` ---
|
||||
# Where the per-host and per-datastore numbers are posted. Unset (or without
|
||||
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
|
||||
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
|
||||
|
||||
# --- ssh, for the sheet's 'h' ---
|
||||
# The command that logs in to a machine's guest from its sheet. "%h" is where
|
||||
# the guest's own hostname — or its address, when it reports no name — is put;
|
||||
# it is appended when %h is not written anywhere. Unset means "ssh root@%h",
|
||||
# which is what one logs in to these machines as; a line here replaces it whole,
|
||||
# root and all.
|
||||
#
|
||||
# The target is always one argument and never goes through a shell: it is a name
|
||||
# the guest chose for itself, and gvm does not run it as a command.
|
||||
# ssh = ssh -l someone %h
|
||||
|
||||
# --- the same settings from the environment ---
|
||||
# Every setting above has an environment spelling that wins over the file:
|
||||
#
|
||||
# GVM_DEFAULT, GVM_MAILFROM, GVM_MAILTO, GVM_SMTPHOST, GVM_SMTPPORT,
|
||||
# GVM_TELEMETRY
|
||||
# GVM_TELEMETRY, GVM_SSH
|
||||
# GVM_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,210 @@
|
||||
// issues.go — the machines that want looking at.
|
||||
//
|
||||
// A list of two hundred machines is read by running the eye down it, which is
|
||||
// exactly the wrong way to find the three that are broken: a lost VMware Tools,
|
||||
// a filesystem at 97 %, a snapshot from March, a machine sitting on a question
|
||||
// nobody has answered. Every one of those facts is already in the inventory
|
||||
// sweep and none of them is visible in a table sorted by name.
|
||||
//
|
||||
// So this is not a new question put to the vCenters — it is a filter over the
|
||||
// answer they have already given (^i in the list, `vm -l --issues` on the
|
||||
// command line), and each machine carries the reason it is in the list.
|
||||
//
|
||||
// What counts as an issue is deliberately narrow. A list that cries wolf is one
|
||||
// nobody opens, so a machine that is switched off is not an issue, a machine
|
||||
// without VMware Tools is only worth a word while it is running, and a snapshot
|
||||
// is only old once it has stopped being anybody's afternoon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// When a guest filesystem is worth naming. Ninety per cent is where a disk
|
||||
// stops having room for a surprise; ninety-five is where it stops having room.
|
||||
// Small partitions sit legitimately close to full — /boot on a Debian is a
|
||||
// perennial 92 % — which is why the mount point is always named with the figure
|
||||
// rather than the machine merely being flagged.
|
||||
const (
|
||||
fsWarnPct = 90.0
|
||||
fsBadPct = 95.0
|
||||
)
|
||||
|
||||
// issue is one reason a machine is in the list. bad separates "this is broken"
|
||||
// from "this wants a look" — the colours of the two are the palette's red and
|
||||
// yellow, and the order they are reported in is worst first, because the column
|
||||
// they end up in is the one that gets truncated.
|
||||
type issue struct {
|
||||
text string
|
||||
bad bool
|
||||
}
|
||||
|
||||
// issueList is everything gvm has to say against this machine, worst first.
|
||||
//
|
||||
// Everything here is read off the row as the sweep left it. Nothing in this
|
||||
// function may go to the network: it is called for every machine in the
|
||||
// inventory, for the table, for the filter and for the report.
|
||||
func (r vmRow) issueList() []issue {
|
||||
var bad, warn []issue
|
||||
add := func(isBad bool, format string, a ...any) {
|
||||
i := issue{text: SF(format, a...), bad: isBad}
|
||||
if isBad {
|
||||
bad = append(bad, i)
|
||||
return
|
||||
}
|
||||
warn = append(warn, i)
|
||||
}
|
||||
|
||||
rt := r.vm.Summary.Runtime
|
||||
|
||||
// vCenter cannot see the machine properly. Everything below this line is a
|
||||
// statement about a machine vSphere is in touch with, so this comes first.
|
||||
switch rt.ConnectionState {
|
||||
case types.VirtualMachineConnectionStateConnected, "":
|
||||
default:
|
||||
add(true, "%s", string(rt.ConnectionState))
|
||||
}
|
||||
|
||||
// A machine stopped on a question is stopped until somebody answers it, and
|
||||
// nothing in the ordinary table says so.
|
||||
if rt.Question != nil {
|
||||
add(true, "waiting for an answer in vCenter")
|
||||
}
|
||||
|
||||
// Delta disks left behind by a snapshot removal that did not finish. The
|
||||
// machine runs perfectly well and grows quietly until the datastore is full.
|
||||
if rt.ConsolidationNeeded {
|
||||
add(true, "disks need consolidating")
|
||||
}
|
||||
|
||||
// What vCenter itself is complaining about. Its own alarms are the best
|
||||
// answer to "is something wrong", so they are passed on rather than
|
||||
// second-guessed — by the name a person gave the alarm, never by its number.
|
||||
alarms := 0
|
||||
for _, a := range r.vm.TriggeredAlarmState {
|
||||
if a.Acknowledged != nil && *a.Acknowledged {
|
||||
continue // somebody has seen it and said so
|
||||
}
|
||||
switch a.OverallStatus {
|
||||
case types.ManagedEntityStatusRed:
|
||||
add(true, "alarm: %s", r.alarmLabel(a.Alarm))
|
||||
alarms++
|
||||
case types.ManagedEntityStatusYellow:
|
||||
add(false, "alarm: %s", r.alarmLabel(a.Alarm))
|
||||
alarms++
|
||||
}
|
||||
}
|
||||
// The overall status is the rollup of those alarms. It is only worth a line
|
||||
// of its own when no alarm came with it — otherwise the same fact would be
|
||||
// reported twice, once with a reason and once without.
|
||||
if alarms == 0 {
|
||||
switch r.vm.Summary.OverallStatus {
|
||||
case types.ManagedEntityStatusRed:
|
||||
add(true, "vCenter says red")
|
||||
case types.ManagedEntityStatusYellow:
|
||||
add(false, "vCenter says yellow")
|
||||
}
|
||||
}
|
||||
|
||||
if r.running() {
|
||||
if !r.toolsRunning() {
|
||||
add(false, "no VMware Tools")
|
||||
}
|
||||
for _, d := range r.fullDisks() {
|
||||
add(d.pct >= fsBadPct, "%s %.0f %% full", d.path, d.pct)
|
||||
}
|
||||
}
|
||||
|
||||
// An old snapshot is the one issue here that is nobody's fault and
|
||||
// everybody's job. The table's colours change at a week; the report only
|
||||
// names one once it is a month old, so this list stays worth reading.
|
||||
if e, ok := r.oldest(); ok {
|
||||
if days := e.days(); days >= snapOldDays {
|
||||
add(false, "snapshot %s is %s old", e.name, plural(days, "day"))
|
||||
}
|
||||
}
|
||||
|
||||
return append(bad, warn...)
|
||||
}
|
||||
|
||||
// fullDisk is one guest filesystem that is nearly full.
|
||||
type fullDisk struct {
|
||||
path string
|
||||
pct float64
|
||||
}
|
||||
|
||||
// fullDisks are the guest's filesystems worth naming. The figures come from
|
||||
// VMware Tools, so a machine without it simply has none — which is not the same
|
||||
// as having none that are full, and is why the absence of Tools is its own line.
|
||||
func (r vmRow) fullDisks() []fullDisk {
|
||||
g := r.vm.Guest
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
var out []fullDisk
|
||||
for _, d := range g.Disk {
|
||||
if d.Capacity <= 0 {
|
||||
continue
|
||||
}
|
||||
pct := 100.0 - 100.0/float64(d.Capacity)*float64(d.FreeSpace)
|
||||
if pct >= fsWarnPct {
|
||||
out = append(out, fullDisk{path: d.DiskPath, pct: pct})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// alarmLabel is the alarm's own name, or its reference when the names could not
|
||||
// be read. "alarm-14 is red" is not something anybody can act on, but it is
|
||||
// still better than not saying that something is.
|
||||
func (r vmRow) alarmLabel(ref types.ManagedObjectReference) string {
|
||||
if r.sess != nil {
|
||||
if name := r.sess.alarms[ref]; name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ref.Value
|
||||
}
|
||||
|
||||
// issues is the reasons as plain text, worst first.
|
||||
func (r vmRow) issues() []string {
|
||||
list := r.issueList()
|
||||
out := make([]string, 0, len(list))
|
||||
for _, i := range list {
|
||||
out = append(out, i.text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r vmRow) hasIssues() bool { return len(r.issueList()) > 0 }
|
||||
|
||||
// issueCell is the WHY column: every reason, worst first, in one line for the
|
||||
// column to truncate from the right. Truncation is why the order matters.
|
||||
func (r vmRow) issueCell() string { return strings.Join(r.issues(), " · ") }
|
||||
|
||||
// issueColor paints the row's worst reason: red where something is broken,
|
||||
// yellow where something wants a look.
|
||||
func (r vmRow) issueColor() string {
|
||||
for _, i := range r.issueList() {
|
||||
if i.bad {
|
||||
return colFull
|
||||
}
|
||||
}
|
||||
if len(r.issueList()) > 0 {
|
||||
return colBusy
|
||||
}
|
||||
return colOff
|
||||
}
|
||||
|
||||
// withIssues is the filter itself.
|
||||
func withIssues(rows []vmRow) []vmRow {
|
||||
out := make([]vmRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.hasIssues() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// aged builds a snapshot entry that was taken so many days ago.
|
||||
func aged(name string, days int) snapEntry {
|
||||
when := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
return snapEntry{
|
||||
ref: types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: "snapshot-" + name},
|
||||
name: name,
|
||||
when: when,
|
||||
created: when.Local().Format("02.01.2006 15:04"),
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with nothing wrong with it says nothing. This is the one that
|
||||
// matters: the whole point of the filter is that it is short.
|
||||
func TestAHealthyMachineHasNoIssues(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("a healthy machine reported %v", got)
|
||||
}
|
||||
if r.hasIssues() {
|
||||
t.Error("a healthy machine is in the issues list")
|
||||
}
|
||||
if r.issueColor() != colOff {
|
||||
t.Error("a healthy machine's reason is coloured as though it had one")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that is switched off is not a fault, and the things that are only
|
||||
// true of a running machine are not held against a stopped one.
|
||||
func TestAStoppedMachineIsNotAnIssue(t *testing.T) {
|
||||
r := testRow("web01", false, "10.0.0.5")
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100, FreeSpace: 1}}
|
||||
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("a stopped machine reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesFound(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
bend func(*vmRow)
|
||||
want string
|
||||
bad bool
|
||||
}{
|
||||
{"disconnected", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateDisconnected
|
||||
}, "disconnected", true},
|
||||
{"orphaned", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.ConnectionState = types.VirtualMachineConnectionStateOrphaned
|
||||
}, "orphaned", true},
|
||||
{"a question", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.Question = &types.VirtualMachineQuestionInfo{Id: "1"}
|
||||
}, "waiting for an answer", true},
|
||||
{"consolidation", func(r *vmRow) {
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
}, "consolidating", true},
|
||||
{"a red status", func(r *vmRow) {
|
||||
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
|
||||
}, "vCenter says red", true},
|
||||
{"a yellow status", func(r *vmRow) {
|
||||
r.vm.Summary.OverallStatus = types.ManagedEntityStatusYellow
|
||||
}, "vCenter says yellow", false},
|
||||
{"no tools", func(r *vmRow) {
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning"
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{ToolsRunningStatus: "guestToolsNotRunning"}
|
||||
}, "no VMware Tools", false},
|
||||
{"a full filesystem", func(r *vmRow) {
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/var", Capacity: 100 << 30, FreeSpace: 3 << 30}}
|
||||
}, "/var 97 % full", true},
|
||||
{"a nearly full filesystem", func(r *vmRow) {
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/boot", Capacity: 100 << 30, FreeSpace: 8 << 30}}
|
||||
}, "/boot 92 % full", false},
|
||||
{"an old snapshot", func(r *vmRow) {
|
||||
r.snaps = []snapEntry{aged("before-patch", 63)}
|
||||
}, "before-patch is 63 days old", false},
|
||||
} {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
c.bend(&r)
|
||||
|
||||
list := r.issueList()
|
||||
found := false
|
||||
for _, i := range list {
|
||||
if strings.Contains(i.text, c.want) {
|
||||
found = true
|
||||
if i.bad != c.bad {
|
||||
t.Errorf("%s: bad = %v, want %v (%q)", c.what, i.bad, c.bad, i.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: nothing said %q, only %v", c.what, c.want, r.issues())
|
||||
}
|
||||
if !r.hasIssues() {
|
||||
t.Errorf("%s: the machine is not in the issues list", c.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A filesystem that is merely fairly full is nobody's business.
|
||||
func TestAFilesystemWithRoomIsNotReported(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.Disk = []types.GuestDiskInfo{{DiskPath: "/", Capacity: 100 << 30, FreeSpace: 20 << 30}}
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("a filesystem at 80 %% reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot is only old once it has stopped being somebody's afternoon. The
|
||||
// table colours it yellow after a week; the list of things to answer for waits
|
||||
// for a month, or it fills up with this morning's work.
|
||||
func TestOnlyAMonthOldSnapshotIsAnIssue(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
days int
|
||||
want bool
|
||||
}{{2, false}, {snapStaleDays + 1, false}, {snapOldDays, true}, {90, true}} {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.snaps = []snapEntry{aged("s", c.days)}
|
||||
if got := r.hasIssues(); got != c.want {
|
||||
t.Errorf("a snapshot of %d days: reported = %v, want %v (%v)",
|
||||
c.days, got, c.want, r.issues())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vCenter's own alarms are passed on by the name somebody gave them, and an
|
||||
// alarm that has been acknowledged has been dealt with by a person already.
|
||||
func TestAlarms(t *testing.T) {
|
||||
ref := types.ManagedObjectReference{Type: "Alarm", Value: "alarm-14"}
|
||||
yes := true
|
||||
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.TriggeredAlarmState = []types.AlarmState{
|
||||
{Alarm: ref, OverallStatus: types.ManagedEntityStatusRed},
|
||||
}
|
||||
// Without the names, the reference is still said: it is little use, but it
|
||||
// is not silence.
|
||||
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "alarm-14") {
|
||||
t.Errorf("an alarm with no name resolved reported %q", got)
|
||||
}
|
||||
|
||||
r.sess = &session{alarms: map[types.ManagedObjectReference]string{ref: "Host memory usage"}}
|
||||
if got := strings.Join(r.issues(), " "); !strings.Contains(got, "Host memory usage") {
|
||||
t.Errorf("the alarm's name was not used: %q", got)
|
||||
}
|
||||
|
||||
r.vm.TriggeredAlarmState[0].Acknowledged = &yes
|
||||
if got := r.issues(); len(got) > 0 {
|
||||
t.Errorf("an acknowledged alarm still reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The rolled-up status is not reported next to the alarm it is the rollup of:
|
||||
// the same fact twice, once with a reason and once without.
|
||||
func TestTheStatusIsNotReportedTwice(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.OverallStatus = types.ManagedEntityStatusRed
|
||||
r.vm.TriggeredAlarmState = []types.AlarmState{{
|
||||
Alarm: types.ManagedObjectReference{Type: "Alarm", Value: "alarm-1"},
|
||||
OverallStatus: types.ManagedEntityStatusRed,
|
||||
}}
|
||||
|
||||
if got := r.issues(); len(got) != 1 {
|
||||
t.Errorf("a red machine with one alarm reported %d things: %v", len(got), got)
|
||||
}
|
||||
if got := strings.Join(r.issues(), " "); strings.Contains(got, "says red") {
|
||||
t.Errorf("the rollup was reported beside its own alarm: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Worst first, because the column they end up in is truncated from the right.
|
||||
func TestTheWorstReasonComesFirst(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // a word of warning
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true // broken
|
||||
|
||||
list := r.issueList()
|
||||
if len(list) < 2 {
|
||||
t.Fatalf("expected both reasons, got %v", r.issues())
|
||||
}
|
||||
if !list[0].bad {
|
||||
t.Errorf("the reasons came out warning first: %v", r.issues())
|
||||
}
|
||||
if r.issueColor() != colFull {
|
||||
t.Error("a machine with something broken is not painted as broken")
|
||||
}
|
||||
|
||||
// Only a warning: yellow, not red.
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = false
|
||||
if r.issueColor() != colBusy {
|
||||
t.Error("a machine with only a warning is painted as broken")
|
||||
}
|
||||
if cell := r.issueCell(); !strings.Contains(cell, "no VMware Tools") {
|
||||
t.Errorf("the reason column says %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithIssuesKeepsOnlyTheOnesToAnswerFor(t *testing.T) {
|
||||
good := testRow("web01", true, "10.0.0.5")
|
||||
bad := testRow("db01", true, "10.0.0.6")
|
||||
bad.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
|
||||
got := withIssues([]vmRow{good, bad})
|
||||
if len(got) != 1 || got[0].name != "db01" {
|
||||
t.Errorf("the filter kept %d machines: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
// jsonout.go — the machine listing as a document.
|
||||
//
|
||||
// `gvm vm -l` is meant to be read; this is the same sweep meant to be parsed —
|
||||
// by a monitoring check, a report, a spreadsheet. So the shape here is a
|
||||
// promise, and two decisions follow from that.
|
||||
//
|
||||
// It is one object and not an array of machines, because a listing that leaves
|
||||
// out a vCenter which did not answer is worse than no listing at all: a script
|
||||
// handed a bare array cannot tell an empty cluster from an unreachable one. The
|
||||
// servers that answered and the ones that did not are part of the document.
|
||||
//
|
||||
// And a figure that is not known is null, never zero. A stopped machine has no
|
||||
// processor load, a machine whose guest is silent has no address, and a
|
||||
// spreadsheet that averages a column of zeroes reports a fleet that is idle.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jsonListing is the whole document.
|
||||
type jsonListing struct {
|
||||
Generated string `json:"generated"`
|
||||
Answered []string `json:"answered"`
|
||||
Failed []string `json:"failed"`
|
||||
Count int `json:"count"`
|
||||
Machines []jsonMachine `json:"machines"`
|
||||
}
|
||||
|
||||
// jsonMachine is one machine. The names are the ones the table's headers stand
|
||||
// for, spelled out: a document is read by somebody who cannot see the header.
|
||||
type jsonMachine struct {
|
||||
Name string `json:"name"`
|
||||
VCenter string `json:"vcenter"`
|
||||
Datacenter string `json:"datacenter"`
|
||||
Host string `json:"host"`
|
||||
|
||||
Power string `json:"power"`
|
||||
Connection string `json:"connection,omitempty"`
|
||||
Status string `json:"status,omitempty"` // vCenter's own green/yellow/red
|
||||
Template bool `json:"template"`
|
||||
|
||||
Address string `json:"address,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Guest string `json:"guest,omitempty"`
|
||||
ToolsRunning bool `json:"tools_running"`
|
||||
|
||||
CPUs int32 `json:"cpus"`
|
||||
CPUPercent *float64 `json:"cpu_percent"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
MemoryPercent *float64 `json:"memory_percent"`
|
||||
UptimeSeconds *int32 `json:"uptime_seconds"`
|
||||
|
||||
CommittedBytes *int64 `json:"committed_bytes"`
|
||||
UncommittedBytes *int64 `json:"uncommitted_bytes"`
|
||||
|
||||
Snapshots []jsonSnapshot `json:"snapshots"`
|
||||
OldestSnapshotAt *string `json:"oldest_snapshot_at"`
|
||||
OldestSnapshotDays *int `json:"oldest_snapshot_days"`
|
||||
|
||||
Task *jsonTask `json:"task"`
|
||||
Issues []string `json:"issues"`
|
||||
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Instance string `json:"instance_uuid,omitempty"`
|
||||
Moref string `json:"moref"`
|
||||
}
|
||||
|
||||
type jsonSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"`
|
||||
Days int `json:"days"`
|
||||
Current bool `json:"current"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
type jsonTask struct {
|
||||
What string `json:"what"`
|
||||
Queued bool `json:"queued"`
|
||||
Progress int32 `json:"progress"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// printJSON writes the document. Indented, because the first reader of it is
|
||||
// always a person finding out what the keys are called.
|
||||
func printJSON(found sweep, rows []vmRow) error {
|
||||
doc := jsonListing{
|
||||
Generated: time.Now().Format(time.RFC3339),
|
||||
Answered: found.answered,
|
||||
Failed: found.failed,
|
||||
Count: len(rows),
|
||||
Machines: make([]jsonMachine, 0, len(rows)),
|
||||
}
|
||||
if doc.Answered == nil {
|
||||
doc.Answered = []string{}
|
||||
}
|
||||
if doc.Failed == nil {
|
||||
doc.Failed = []string{}
|
||||
}
|
||||
for _, r := range rows {
|
||||
doc.Machines = append(doc.Machines, jsonOf(r))
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(doc); err != nil {
|
||||
return errf("cannot write the listing: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonOf is one row as a document entry.
|
||||
func jsonOf(r vmRow) jsonMachine {
|
||||
sum := r.vm.Summary
|
||||
cfg := sum.Config
|
||||
rt := sum.Runtime
|
||||
|
||||
m := jsonMachine{
|
||||
Name: r.name,
|
||||
VCenter: r.vc.Name,
|
||||
Datacenter: r.vc.Datacenter,
|
||||
Host: r.host,
|
||||
Power: string(r.power()),
|
||||
Connection: string(rt.ConnectionState),
|
||||
Status: string(sum.OverallStatus),
|
||||
Template: cfg.Template,
|
||||
Guest: r.guestOS(),
|
||||
ToolsRunning: r.toolsRunning(),
|
||||
CPUs: cfg.NumCpu,
|
||||
MemoryMB: cfg.MemorySizeMB,
|
||||
Issues: r.issues(),
|
||||
UUID: cfg.Uuid,
|
||||
Instance: cfg.InstanceUuid,
|
||||
Moref: r.ref.Value,
|
||||
Snapshots: []jsonSnapshot{},
|
||||
}
|
||||
if m.Issues == nil {
|
||||
m.Issues = []string{}
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
m.Address = ip
|
||||
}
|
||||
if g := r.vm.Guest; g != nil {
|
||||
m.Hostname = g.HostName
|
||||
}
|
||||
if pct, ok := r.cpuLoad(); ok {
|
||||
m.CPUPercent = &pct
|
||||
}
|
||||
if pct, ok := r.memLoad(); ok {
|
||||
m.MemoryPercent = &pct
|
||||
}
|
||||
if r.running() && sum.QuickStats.UptimeSeconds > 0 {
|
||||
up := sum.QuickStats.UptimeSeconds
|
||||
m.UptimeSeconds = &up
|
||||
}
|
||||
if st := sum.Storage; st != nil {
|
||||
committed, uncommitted := st.Committed, st.Uncommitted
|
||||
m.CommittedBytes, m.UncommittedBytes = &committed, &uncommitted
|
||||
}
|
||||
for _, e := range r.snaps {
|
||||
m.Snapshots = append(m.Snapshots, jsonSnapshot{
|
||||
Name: e.name, Created: e.when.Format(time.RFC3339),
|
||||
Days: e.days(), Current: e.current, Depth: e.depth,
|
||||
})
|
||||
}
|
||||
if e, ok := r.oldest(); ok {
|
||||
at, days := e.when.Format(time.RFC3339), e.days()
|
||||
m.OldestSnapshotAt, m.OldestSnapshotDays = &at, &days
|
||||
}
|
||||
if t := r.task; t != nil {
|
||||
jt := jsonTask{What: t.what, Queued: t.queued, Progress: t.progress}
|
||||
if !t.since.IsZero() {
|
||||
jt.Since = t.since.Format(time.RFC3339)
|
||||
}
|
||||
m.Task = &jt
|
||||
}
|
||||
return m
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// jsonDoc runs the encoder over a listing and reads it back the way whatever is
|
||||
// on the other end of the pipe would.
|
||||
func jsonDoc(t *testing.T, found sweep, rows []vmRow) map[string]any {
|
||||
t.Helper()
|
||||
out := captureStdout(t, func() {
|
||||
if err := printJSON(found, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("what came out is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// A figure that is not known is null and never nought. A stopped machine has no
|
||||
// processor load, and a spreadsheet that averages a column of zeroes reports a
|
||||
// fleet that is idle.
|
||||
func TestJSONLeavesTheUnknownNull(t *testing.T) {
|
||||
on := testRow("web01", true, "10.0.0.5")
|
||||
off := testRow("db01", false, "")
|
||||
off.ref = types.ManagedObjectReference{Value: "vm-43"}
|
||||
|
||||
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{on, off})
|
||||
machines := doc["machines"].([]any)
|
||||
if len(machines) != 2 {
|
||||
t.Fatalf("the document holds %d machines", len(machines))
|
||||
}
|
||||
|
||||
running := machines[0].(map[string]any)
|
||||
stopped := machines[1].(map[string]any)
|
||||
|
||||
if running["cpu_percent"] == nil {
|
||||
t.Error("a running machine has no processor load in the document")
|
||||
}
|
||||
for _, key := range []string{"cpu_percent", "memory_percent", "uptime_seconds"} {
|
||||
if stopped[key] != nil {
|
||||
t.Errorf("a stopped machine reports %s = %v, want null", key, stopped[key])
|
||||
}
|
||||
}
|
||||
if stopped["address"] != nil {
|
||||
t.Errorf("a machine with no address reports address = %v", stopped["address"])
|
||||
}
|
||||
}
|
||||
|
||||
// The servers that answered and the ones that did not are part of the document.
|
||||
// A script handed a bare list of machines cannot tell an empty cluster from an
|
||||
// unreachable one, which is the difference that matters.
|
||||
func TestJSONNamesTheServersThatDidNotAnswer(t *testing.T) {
|
||||
doc := jsonDoc(t, sweep{
|
||||
answered: []string{"v308"},
|
||||
failed: []string{"v108: login failed"},
|
||||
}, nil)
|
||||
|
||||
if got := doc["answered"].([]any); len(got) != 1 || got[0] != "v308" {
|
||||
t.Errorf("answered = %v", got)
|
||||
}
|
||||
got := doc["failed"].([]any)
|
||||
if len(got) != 1 || !strings.Contains(got[0].(string), "v108") {
|
||||
t.Errorf("failed = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Both lists are always there, empty rather than absent: a reader that has to
|
||||
// tell null from [] is a reader that will get it wrong once.
|
||||
func TestJSONAlwaysHasBothServerLists(t *testing.T) {
|
||||
doc := jsonDoc(t, sweep{}, nil)
|
||||
for _, key := range []string{"answered", "failed", "machines"} {
|
||||
if doc[key] == nil {
|
||||
t.Errorf("%s is null in an empty listing", key)
|
||||
}
|
||||
}
|
||||
if doc["count"] != float64(0) {
|
||||
t.Errorf("count = %v", doc["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// What the table shows in colour and what the document says in words is the
|
||||
// same judgement, made in one place.
|
||||
func TestJSONCarriesTheIssuesAndTheSnapshots(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
r.snaps = []snapEntry{aged("before-patch", 63), aged("hotfix", 2)}
|
||||
r.task = &runningTask{what: "clone", progress: 40}
|
||||
|
||||
doc := jsonDoc(t, sweep{answered: []string{"v308"}}, []vmRow{r})
|
||||
m := doc["machines"].([]any)[0].(map[string]any)
|
||||
|
||||
issues := m["issues"].([]any)
|
||||
if len(issues) == 0 || !strings.Contains(issues[0].(string), "consolidating") {
|
||||
t.Errorf("issues = %v", issues)
|
||||
}
|
||||
if got := m["snapshots"].([]any); len(got) != 2 {
|
||||
t.Errorf("the document holds %d snapshots", len(got))
|
||||
}
|
||||
if got := m["oldest_snapshot_days"]; got != float64(63) {
|
||||
t.Errorf("oldest_snapshot_days = %v", got)
|
||||
}
|
||||
task := m["task"].(map[string]any)
|
||||
if task["what"] != "clone" || task["progress"] != float64(40) {
|
||||
t.Errorf("task = %v", task)
|
||||
}
|
||||
|
||||
// And a machine with none of those says so, rather than leaving the reader
|
||||
// to guess whether the key was simply left out.
|
||||
quiet := testRow("db01", true, "10.0.0.6")
|
||||
doc = jsonDoc(t, sweep{}, []vmRow{quiet})
|
||||
m = doc["machines"].([]any)[0].(map[string]any)
|
||||
if got := m["issues"].([]any); len(got) != 0 {
|
||||
t.Errorf("a healthy machine reports issues = %v", got)
|
||||
}
|
||||
if m["task"] != nil {
|
||||
t.Errorf("an idle machine reports task = %v", m["task"])
|
||||
}
|
||||
if m["oldest_snapshot_at"] != nil {
|
||||
t.Errorf("a machine with no snapshots reports oldest_snapshot_at = %v", m["oldest_snapshot_at"])
|
||||
}
|
||||
}
|
||||
|
||||
// The document is one object, not a bare array: that is what leaves room for
|
||||
// the servers, and it is the promise a script is written against.
|
||||
func TestJSONIsOneDocument(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
if err := printJSON(sweep{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("the document begins %q", strings.SplitN(out, "\n", 2)[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// live.go — the list that keeps itself up to date.
|
||||
//
|
||||
// Everything else in the interactive half happens because somebody pressed a
|
||||
// key. This is the part that happens because time passed: `^l` turns it on and
|
||||
// the list re-reads itself every few seconds, which turns gvm from something one
|
||||
// looks at into something one leaves open on a second screen.
|
||||
//
|
||||
// Three things come with it, and they are here rather than in browse.go because
|
||||
// they only mean anything together:
|
||||
//
|
||||
// 1. The refresh itself, over the connections that are already open. `^r` logs
|
||||
// in again — that is how a session that has died is recovered — and doing
|
||||
// that every ten seconds would be three logins a minute for nothing.
|
||||
// 2. What changed. A table says what is; after a sweep it can say what just
|
||||
// became, which is the one thing no column can hold: "db01 off · web01 +1
|
||||
// snapshot". It is the reason to leave the thing open at all.
|
||||
// 3. The trend column: each machine's recent CPU load as one column of text.
|
||||
// The samples cannot live on the rows, which every sweep throws away, so
|
||||
// the browser keeps them and writes the drawing back onto the rows.
|
||||
//
|
||||
// Nothing here acts on a machine. A refresh that could start or stop something
|
||||
// would be a timer with the power to do it, and the one thing a screen left
|
||||
// open unattended must not have is that.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How often the list re-reads itself. Faster while vCenter is doing something:
|
||||
// a clone's progress that moves once every ten seconds is a figure one waits
|
||||
// for, and one that moves every two is a thing one watches finish.
|
||||
//
|
||||
// Neither is configurable yet. The sweep is a few properties of every machine
|
||||
// over a connection that is already up, which is cheap enough that ten seconds
|
||||
// is not a number worth tuning per site — and a setting nobody needs is a
|
||||
// setting to keep working for ever.
|
||||
const (
|
||||
liveEvery = 10 * time.Second
|
||||
liveBusy = 2 * time.Second
|
||||
)
|
||||
|
||||
// trendLen is how many sweeps the trend column remembers, and therefore how
|
||||
// wide the column is: a history longer than the drawing would be arithmetic
|
||||
// nobody sees.
|
||||
//
|
||||
// Six rather than eight. The difference is two characters of table, and two
|
||||
// characters is what decides whether this column is on the screen at all on a
|
||||
// terminal of 120 with a task column in it — which is the ordinary case it was
|
||||
// built for. Six sweeps is a minute of history at the quiet interval.
|
||||
const trendLen = 6
|
||||
|
||||
// toggleLive turns it on and off, and says which — a mode that changes what the
|
||||
// screen does on its own has to announce itself, or a list that moves under
|
||||
// somebody's hands looks like a fault.
|
||||
//
|
||||
// Turning it on refreshes at once rather than in ten seconds' time. The
|
||||
// keystroke is a request for the current state, not for a subscription that
|
||||
// begins later.
|
||||
func (b *browser) toggleLive() {
|
||||
b.live = !b.live
|
||||
if !b.live {
|
||||
b.setStatus(colDim, "live off — ^r to reload by hand")
|
||||
return
|
||||
}
|
||||
b.liveNext, b.liveGap = time.Now(), b.liveInterval()
|
||||
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
|
||||
}
|
||||
|
||||
// liveReady reports whether a tick may happen now. Only the list and a
|
||||
// machine's sheet are refreshed underneath somebody: the menu decides what it
|
||||
// offers from the state it was drawn with, the picker holds a list of snapshots
|
||||
// that is being chosen from, and both the editor and a question are half-typed
|
||||
// answers. Redrawing any of those from under a hand is worse than being ten
|
||||
// seconds out of date.
|
||||
//
|
||||
// The estate screen is left out for a different reason: it reads the hosts
|
||||
// itself, which is work the machine list does not do, and a timer that did it
|
||||
// every ten seconds would be paying for a screen somebody is reading rather
|
||||
// than watching. ^r reads it again.
|
||||
func (b *browser) liveReady() bool {
|
||||
return b.live && b.menu == nil && b.pick == nil && b.confirm == nil &&
|
||||
b.edit == nil && b.prompt == nil && b.estate == nil
|
||||
}
|
||||
|
||||
// liveIn is how long until the next tick — the remaining time, not the whole
|
||||
// interval, so that somebody arrowing through the list steadily cannot postpone
|
||||
// the refresh for ever.
|
||||
func (b *browser) liveIn() time.Duration {
|
||||
d := time.Until(b.liveNext)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// liveInterval is the gap after this tick: short while anything at all is being
|
||||
// done on the cluster, so the task column moves while one watches it.
|
||||
func (b *browser) liveInterval() time.Duration {
|
||||
for _, r := range b.rows {
|
||||
if r.task != nil {
|
||||
return liveBusy
|
||||
}
|
||||
}
|
||||
return liveEvery
|
||||
}
|
||||
|
||||
// liveTick is one refresh. What it must not do is move the screen: the cursor
|
||||
// stays on the machine it was on, and the viewport stays where it was — a list
|
||||
// that jumps to put the selection on the last visible line every ten seconds is
|
||||
// unreadable, and that is what the ordinary refilter would do, since it is
|
||||
// written for a filter being typed, where going back to the top is right.
|
||||
func (b *browser) liveTick() {
|
||||
was := b.rows
|
||||
scroll := b.scroll
|
||||
|
||||
// The error is information, not a reason to stop: resweep may have replaced
|
||||
// the rows and still have something to report — one server of three did not
|
||||
// answer — and the rows it left are a list, just a partly older one.
|
||||
//
|
||||
// What must not happen is returning here with the rows replaced and the
|
||||
// view not rebuilt. The view holds indexes into the rows, and the drawing
|
||||
// follows it without asking: leaving the two disagreeing showed every row
|
||||
// as a different machine, put the cursor on one the operator was not
|
||||
// looking at, and panicked outright as soon as the new list was shorter.
|
||||
err := b.resweep()
|
||||
|
||||
b.applySort() // which refilters, so the view describes the rows again
|
||||
b.scroll = min(scroll, max(len(b.view)-1, 0))
|
||||
b.sample()
|
||||
|
||||
if b.detail != nil {
|
||||
// The sheet is rebuilt from the machine as it is now, at the line it was
|
||||
// being read at. It closes itself where that machine has gone.
|
||||
keep := b.dscroll
|
||||
b.openDetail()
|
||||
b.dscroll = keep
|
||||
}
|
||||
|
||||
// (4) The next tick is timed from here, not from before the sweep: a sweep
|
||||
// that takes longer than the interval would otherwise leave no idle time at
|
||||
// all, and liveIn would hand nextWithin a zero deadline that races every
|
||||
// keystroke against an already expired timer.
|
||||
b.liveGap = b.liveInterval()
|
||||
b.liveNext = time.Now().Add(b.liveGap)
|
||||
|
||||
if err != nil {
|
||||
b.setStatus(colWarn, "live: "+err.Error())
|
||||
b.saidLive = ""
|
||||
return
|
||||
}
|
||||
|
||||
// The changed line is live mode's own, and a quiet tick clears it — but it
|
||||
// clears nothing else. A message somebody produced by pressing a key is
|
||||
// theirs: "copied its hostname web01.example" or the reason a snapshot was
|
||||
// refused must not vanish because ten seconds passed and nothing happened
|
||||
// on the cluster. Every keystroke already clears the status; a timer has no
|
||||
// business doing it.
|
||||
what := changesBetween(was, b.rows)
|
||||
if what != "" {
|
||||
b.setStatus(colInfo, what)
|
||||
b.saidLive = what
|
||||
return
|
||||
}
|
||||
if b.status == b.saidLive {
|
||||
b.setStatus("", "")
|
||||
}
|
||||
b.saidLive = ""
|
||||
}
|
||||
|
||||
// resweep re-reads every machine over the sessions that are already open, in
|
||||
// parallel across the servers the way the first sweep is.
|
||||
//
|
||||
// A server that stops answering costs its own machines, not the screen: its rows
|
||||
// are kept as they were and it is named in the error. Dropping them would empty
|
||||
// half a list because one of three vCenters was restarting.
|
||||
func (b *browser) resweep() error {
|
||||
type result struct {
|
||||
rows []vmRow
|
||||
err error
|
||||
}
|
||||
res := make([]result, len(b.sessions))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, s := range b.sessions {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, s *session) {
|
||||
defer wg.Done()
|
||||
res[i].rows, res[i].err = sweepOne(s.vc, s)
|
||||
}(i, s)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
previous := b.rows
|
||||
var rows []vmRow
|
||||
var failed, answered []string
|
||||
for i, s := range b.sessions {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if res[i].err != nil {
|
||||
failed = append(failed, s.vc.Name)
|
||||
rows = append(rows, rowsOfVC(previous, s.vc)...)
|
||||
continue
|
||||
}
|
||||
answered = append(answered, s.vc.Name)
|
||||
rows = append(rows, res[i].rows...)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if len(failed) == 0 {
|
||||
return errf("no connection left to re-read the machines over")
|
||||
}
|
||||
return errf("no machine could be re-read (%s)", strings.Join(failed, ", "))
|
||||
}
|
||||
b.rows = rows
|
||||
// The title is made of these two, and a refresh that leaves them alone puts
|
||||
// "412 machines on v308, v309" above a status line saying v309 did not
|
||||
// answer. One of the two is then a lie, and the status line is the one that
|
||||
// the next keystroke clears.
|
||||
b.answered, b.lost = answered, failed
|
||||
|
||||
if len(failed) > 0 {
|
||||
return errf("%s did not answer; showing what was last read of it", strings.Join(failed, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rowsOfVC keeps one server's rows across a sweep it did not survive, so a
|
||||
// vCenter that stops answering for a moment does not empty its half of the list.
|
||||
func rowsOfVC(rows []vmRow, vc VCenter) []vmRow {
|
||||
var out []vmRow
|
||||
for _, r := range rows {
|
||||
if r.vc.Name == vc.Name {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- what changed
|
||||
|
||||
// changesBetween is what moved between two sweeps, as one line. A table shows
|
||||
// what is; this is the only thing on the screen that says what just became, and
|
||||
// it is why the list is worth leaving open.
|
||||
//
|
||||
// Deliberately short: four things and a count of the rest. A line that has to be
|
||||
// read carefully is one nobody reads at all, and the table underneath it holds
|
||||
// the detail of every one of them.
|
||||
func changesBetween(was, now []vmRow) string {
|
||||
// Indexes, not copies. A vmRow carries the machine's whole property
|
||||
// document — summary, guest, snapshot tree — and this runs every two
|
||||
// seconds on a list of hundreds to compare four scalars.
|
||||
before := make(map[string]int, len(was))
|
||||
for i := range was {
|
||||
before[was[i].id()] = i
|
||||
}
|
||||
seen := make(map[string]bool, len(now))
|
||||
name := namer(was, now)
|
||||
|
||||
var said []string
|
||||
for i := range now {
|
||||
r := &now[i]
|
||||
seen[r.id()] = true
|
||||
at, had := before[r.id()]
|
||||
if !had {
|
||||
said = append(said, name(*r)+" is new")
|
||||
continue
|
||||
}
|
||||
said = append(said, changesOf(&was[at], r, name)...)
|
||||
}
|
||||
for i := range was {
|
||||
if !seen[was[i].id()] {
|
||||
said = append(said, name(was[i])+" is gone")
|
||||
}
|
||||
}
|
||||
|
||||
if len(said) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(said) > 4 {
|
||||
return strings.Join(said[:4], " · ") + SF(" · and %d more", len(said)-4)
|
||||
}
|
||||
return strings.Join(said, " · ")
|
||||
}
|
||||
|
||||
// namer says how to call a machine on that line. Its name, ordinarily — but a
|
||||
// name is not what makes a machine that machine, and two vCenters may each hold
|
||||
// a "web01" (see vmRow.id). Where they do, the server goes in front, and only
|
||||
// there: "v309 web01 off" is the truth and "web01 off" is a coin toss, while
|
||||
// putting the server in front of every name would spend the width on the
|
||||
// ordinary case to pay for the rare one.
|
||||
//
|
||||
// The table underneath has a column for this, which is why it is worth so
|
||||
// little width up here and so much certainty.
|
||||
func namer(was, now []vmRow) func(vmRow) string {
|
||||
// Both sweeps, because the lines that say a machine has gone are built out
|
||||
// of the old one: deciding ambiguity from the new rows alone left "web01 is
|
||||
// gone" unqualified in exactly the case where one of two web01s went.
|
||||
//
|
||||
// Counted by identity rather than by server, so two machines of one name on
|
||||
// one vCenter — which vSphere allows, in different folders — are ambiguous
|
||||
// too. The server in front does not separate those two; it does say which
|
||||
// server to go and look on, which is more than the bare name does.
|
||||
ids := make(map[string]map[string]bool)
|
||||
for _, rows := range [][]vmRow{was, now} {
|
||||
for i := range rows {
|
||||
n := rows[i].name
|
||||
if ids[n] == nil {
|
||||
ids[n] = make(map[string]bool, 1)
|
||||
}
|
||||
ids[n][rows[i].id()] = true
|
||||
}
|
||||
}
|
||||
return func(r vmRow) string {
|
||||
if len(ids[r.name]) > 1 {
|
||||
return r.vc.Name + " " + r.name
|
||||
}
|
||||
return r.name
|
||||
}
|
||||
}
|
||||
|
||||
// changesOf is what happened to one machine. Only things somebody would want to
|
||||
// be told: the load moving is what the trend column is for, and a line that
|
||||
// reported it would never say anything else.
|
||||
func changesOf(old, now *vmRow, name func(vmRow) string) []string {
|
||||
var said []string
|
||||
|
||||
who := name(*now)
|
||||
|
||||
if old.power() != now.power() {
|
||||
said = append(said, who+" "+now.powerShort())
|
||||
}
|
||||
if d := len(now.snaps) - len(old.snaps); d != 0 {
|
||||
said = append(said, SF("%s %+d snapshot", who, d))
|
||||
}
|
||||
switch {
|
||||
case old.task == nil && now.task != nil:
|
||||
said = append(said, who+" "+now.task.what)
|
||||
case old.task != nil && now.task == nil:
|
||||
said = append(said, who+" "+old.task.what+" done")
|
||||
}
|
||||
// What vCenter is complaining about, by the count: the reasons themselves
|
||||
// are a column away (^w) and several of them at once would fill this line
|
||||
// on their own.
|
||||
if o, n := old.issues(), now.issues(); len(o) != len(n) {
|
||||
switch {
|
||||
case len(n) == 0:
|
||||
said = append(said, who+" is clear")
|
||||
case len(n) > len(o):
|
||||
said = append(said, who+": "+n[0])
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the trend column
|
||||
|
||||
// sample adds this sweep's load to what is remembered of each machine and draws
|
||||
// it onto the row. Machines that have gone are forgotten here, which is the one
|
||||
// place that can: a map of every machine ever seen would grow all day.
|
||||
func (b *browser) sample() {
|
||||
if b.hist == nil {
|
||||
b.hist = make(map[string][]float64, len(b.rows))
|
||||
}
|
||||
seen := make(map[string]bool, len(b.rows))
|
||||
|
||||
for i := range b.rows {
|
||||
r := &b.rows[i]
|
||||
id := r.id()
|
||||
seen[id] = true
|
||||
|
||||
// A machine that is not running has no load rather than a load of zero
|
||||
// (cpuLoad says which), and a zero sampled off a stopped machine would
|
||||
// draw a floor that never happened.
|
||||
//
|
||||
// What it had before goes with it. Keeping it left a machine that was
|
||||
// switched off ten minutes ago showing a busy history beside a CPU%
|
||||
// of "-": a shape that was true once, next to a figure saying there is
|
||||
// nothing to be true about.
|
||||
if pct, ok := r.cpuLoad(); ok {
|
||||
h := append(b.hist[id], pct)
|
||||
if len(h) > trendLen {
|
||||
h = h[len(h)-trendLen:]
|
||||
}
|
||||
b.hist[id] = h
|
||||
} else {
|
||||
delete(b.hist, id)
|
||||
}
|
||||
r.trend = sparkline(b.hist[id])
|
||||
}
|
||||
|
||||
for id := range b.hist {
|
||||
if !seen[id] {
|
||||
delete(b.hist, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sparkBlocks are eight levels in one character each, which is what makes a
|
||||
// history fit in a column.
|
||||
var sparkBlocks = []rune("▁▂▃▄▅▆▇█")
|
||||
|
||||
// sparkline draws percentages as one piece of text.
|
||||
//
|
||||
// The scale is fixed at 0 to 100 and not fitted to the samples. A line that
|
||||
// scales itself to what it holds makes a machine idling between 1 and 2 per
|
||||
// cent look exactly like one swinging between 40 and 80 — the shape would be
|
||||
// the news and the size would be invisible, which is the opposite of what a
|
||||
// glance down a column is for.
|
||||
//
|
||||
// One sample draws nothing: a single block is not a trend, and a column that
|
||||
// appears full of them the moment gvm starts would be eight characters of width
|
||||
// spent on saying "hello".
|
||||
func sparkline(samples []float64) string {
|
||||
if len(samples) < 2 {
|
||||
return ""
|
||||
}
|
||||
out := make([]rune, 0, len(samples))
|
||||
for _, pct := range samples {
|
||||
i := int(pct / 100 * float64(len(sparkBlocks)))
|
||||
out = append(out, sparkBlocks[min(max(i, 0), len(sparkBlocks)-1)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// trendColumn is the third column that is not always there, for the same reason
|
||||
// the other two are not (see taskColumn): it holds something that is only
|
||||
// sometimes true — a machine has a history once it has been swept twice — and a
|
||||
// column of eight dashes down two hundred rows is width spent on nothing.
|
||||
//
|
||||
// It goes early on a narrow terminal — everything else in the table is a fact
|
||||
// about a machine and this is a shape — but not first: the guest's operating
|
||||
// system is the least read column there is, and eight characters of where a
|
||||
// machine has just been are worth more than "Ubuntu Linux (64-bit)".
|
||||
var trendColumn = browseColumn{header: "CPU~", width: trendLen, expendable: 2,
|
||||
cell: func(r vmRow) string { return r.trend },
|
||||
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }}
|
||||
|
||||
// anyTrend reports whether anything has a history to draw yet.
|
||||
func anyTrend(rows []vmRow) bool {
|
||||
for _, r := range rows {
|
||||
if r.trend != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The trend is drawn on a fixed scale, 0 to 100, and not fitted to what it
|
||||
// holds. A line that scales itself makes a machine idling between 1 and 2 per
|
||||
// cent look exactly like one swinging between 40 and 80 — the shape would be
|
||||
// the news and the size invisible, which is the opposite of what a glance down
|
||||
// a column is for.
|
||||
func TestSparklineKeepsItsScale(t *testing.T) {
|
||||
idle := sparkline([]float64{1, 2, 1, 2})
|
||||
busy := sparkline([]float64{40, 80, 40, 80})
|
||||
if idle == busy {
|
||||
t.Errorf("idling and swinging drew the same line: %q", idle)
|
||||
}
|
||||
if strings.Trim(idle, "▁") != "" {
|
||||
t.Errorf("a machine at 1-2%% is not drawn at the floor: %q", idle)
|
||||
}
|
||||
|
||||
// The ends of the scale, and nothing outside it: a percentage over 100 is a
|
||||
// figure vCenter has been known to hand out, and it must not index past the
|
||||
// blocks.
|
||||
for _, c := range []struct {
|
||||
pct float64
|
||||
want rune
|
||||
}{{0, '▁'}, {50, '▅'}, {100, '█'}, {140, '█'}, {-5, '▁'}} {
|
||||
got := sparkline([]float64{c.pct, c.pct})
|
||||
if []rune(got)[0] != c.want {
|
||||
t.Errorf("%.0f%% drew %q, want %q", c.pct, got, string(c.want))
|
||||
}
|
||||
}
|
||||
|
||||
// One sample is not a trend, and a column full of single blocks the moment
|
||||
// gvm starts would be width spent on saying hello.
|
||||
if got := sparkline([]float64{50}); got != "" {
|
||||
t.Errorf("one sample drew %q", got)
|
||||
}
|
||||
if got := sparkline(nil); got != "" {
|
||||
t.Errorf("no samples drew %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The history is kept per machine, bounded, and forgotten when the machine goes
|
||||
// — a map of every machine ever seen would grow all day.
|
||||
func TestSampleRemembersAndForgets(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
// web01 is the running one testBrowser makes, so it is the one with a load.
|
||||
for i := 0; i < trendLen+5; i++ {
|
||||
b.sample()
|
||||
}
|
||||
var id string
|
||||
for _, r := range b.rows {
|
||||
if r.running() {
|
||||
id = r.id()
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("no running machine to sample")
|
||||
}
|
||||
if got := len(b.hist[id]); got != trendLen {
|
||||
t.Errorf("the history holds %d samples, want it bounded at %d", got, trendLen)
|
||||
}
|
||||
if !anyTrend(b.rows) {
|
||||
t.Error("nothing was drawn after a dozen sweeps")
|
||||
}
|
||||
|
||||
// A machine that is not running has no load rather than a load of zero, so
|
||||
// nothing is remembered of it: a sampled zero would draw a floor that never
|
||||
// happened.
|
||||
for _, r := range b.rows {
|
||||
if !r.running() && len(b.hist[r.id()]) != 0 {
|
||||
t.Errorf("%s is off and has %d samples", r.name, len(b.hist[r.id()]))
|
||||
}
|
||||
}
|
||||
|
||||
// And the machine going takes its history with it.
|
||||
b.rows = b.rows[:0]
|
||||
b.sample()
|
||||
if len(b.hist) != 0 {
|
||||
t.Errorf("%d histories outlived their machines", len(b.hist))
|
||||
}
|
||||
}
|
||||
|
||||
// The trend column is there only once there is something in it, the way the
|
||||
// task and reason columns are: eight dashes down two hundred rows would be
|
||||
// width spent on nothing.
|
||||
func TestTheTrendColumnComesWithTheHistory(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
has := func() bool {
|
||||
for _, c := range b.columns() {
|
||||
if c.header == "CPU~" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if has() {
|
||||
t.Error("the trend column is there before anything was sampled")
|
||||
}
|
||||
b.sample()
|
||||
if has() {
|
||||
t.Error("the trend column is there after one sweep, which is not a trend")
|
||||
}
|
||||
b.sample()
|
||||
if !has() {
|
||||
t.Error("the trend column is missing after two sweeps")
|
||||
}
|
||||
|
||||
// It is the first thing a narrow terminal gives up: everything else in the
|
||||
// table is a fact about a machine, and this is a shape.
|
||||
wide := fitColumnsOf(b.columns(), 200)
|
||||
if wide[len(wide)-1].header == "CPU~" && len(wide) < 2 {
|
||||
t.Fatal("nothing to compare")
|
||||
}
|
||||
narrow := fitColumnsOf(b.columns(), 100)
|
||||
for _, c := range narrow {
|
||||
if c.header == "CPU~" {
|
||||
t.Error("a hundred columns kept the trend and gave up facts for it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What changed between two sweeps is the one thing no column can hold. Each
|
||||
// kind of change has to be named, and the line has to stay short enough to read
|
||||
// at a glance.
|
||||
func TestChangesBetweenSweeps(t *testing.T) {
|
||||
on := func(name string) vmRow {
|
||||
r := testRow(name, true, "10.0.0.5")
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
|
||||
return r
|
||||
}
|
||||
|
||||
// Nothing moved.
|
||||
web := on("web01")
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{web}); got != "" {
|
||||
t.Errorf("a sweep with nothing in it said %q", got)
|
||||
}
|
||||
|
||||
// Powered off behind gvm's back.
|
||||
off := web
|
||||
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{off}); !strings.Contains(got, "web01 off") {
|
||||
t.Errorf("a machine that stopped said %q", got)
|
||||
}
|
||||
|
||||
// A snapshot appearing and one going.
|
||||
snapped := web
|
||||
snapped.snaps = []snapEntry{{name: "s1"}}
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{snapped}); !strings.Contains(got, "+1 snapshot") {
|
||||
t.Errorf("a new snapshot said %q", got)
|
||||
}
|
||||
if got := changesBetween([]vmRow{snapped}, []vmRow{web}); !strings.Contains(got, "-1 snapshot") {
|
||||
t.Errorf("a removed snapshot said %q", got)
|
||||
}
|
||||
|
||||
// A task starting and finishing.
|
||||
busy := web
|
||||
busy.task = &runningTask{what: "clone", progress: 40}
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{busy}); !strings.Contains(got, "web01 clone") {
|
||||
t.Errorf("a task starting said %q", got)
|
||||
}
|
||||
if got := changesBetween([]vmRow{busy}, []vmRow{web}); !strings.Contains(got, "clone done") {
|
||||
t.Errorf("a task finishing said %q", got)
|
||||
}
|
||||
|
||||
// Machines coming and going.
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{web, on("db01")}); !strings.Contains(got, "db01 is new") {
|
||||
t.Errorf("a new machine said %q", got)
|
||||
}
|
||||
if got := changesBetween([]vmRow{web, on("db01")}, []vmRow{web}); !strings.Contains(got, "db01 is gone") {
|
||||
t.Errorf("a machine that went said %q", got)
|
||||
}
|
||||
|
||||
// And the line stays short: four things, then a count.
|
||||
var many []vmRow
|
||||
for _, n := range []string{"a", "b", "c", "d", "e", "f"} {
|
||||
many = append(many, on(n))
|
||||
}
|
||||
got := changesBetween(nil, many)
|
||||
if !strings.Contains(got, "and 2 more") {
|
||||
t.Errorf("six changes said %q, which does not end in a count", got)
|
||||
}
|
||||
if n := strings.Count(got, " · "); n > 4 {
|
||||
t.Errorf("the line runs to %d pieces: %q", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tick may not happen underneath a menu, a picker, a confirmation or
|
||||
// something half-typed: redrawing any of those from under a hand is worse than
|
||||
// being ten seconds out of date.
|
||||
func TestLiveHoldsStillForEveryScreenThatAsksSomething(t *testing.T) {
|
||||
b := testBrowser("web01")
|
||||
b.live = true
|
||||
if !b.liveReady() {
|
||||
t.Fatal("live mode does not tick on the plain list")
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
set func()
|
||||
undo func()
|
||||
}{
|
||||
{"a menu", func() { b.menu = []menuItem{{key: 'n'}} }, func() { b.menu = nil }},
|
||||
{"a picker", func() { b.pick = &picker{} }, func() { b.pick = nil }},
|
||||
{"a confirmation", func() { b.confirm = &confirmation{} }, func() { b.confirm = nil }},
|
||||
{"an editor", func() { b.edit = &editor{} }, func() { b.edit = nil }},
|
||||
{"a question", func() { b.prompt = &prompt{} }, func() { b.prompt = nil }},
|
||||
} {
|
||||
c.set()
|
||||
if b.liveReady() {
|
||||
t.Errorf("live mode ticks with %s on screen", c.what)
|
||||
}
|
||||
c.undo()
|
||||
}
|
||||
|
||||
// The sheet is refreshed, though: watching a machine's memory is a reason
|
||||
// to have it open.
|
||||
b.detail = []sheetLine{{label: "cpu", value: "4 vCPU"}}
|
||||
if !b.liveReady() {
|
||||
t.Error("live mode does not refresh an open sheet")
|
||||
}
|
||||
|
||||
// And off is off.
|
||||
b.live = false
|
||||
if b.liveReady() {
|
||||
t.Error("live mode ticks while it is switched off")
|
||||
}
|
||||
}
|
||||
|
||||
// It looks more often while vCenter is doing something: a clone's progress that
|
||||
// moves every ten seconds is a figure one waits for, and one that moves every
|
||||
// two is a thing one watches finish.
|
||||
func TestLiveLooksFasterWhileSomethingIsRunning(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
if got := b.liveInterval(); got != liveEvery {
|
||||
t.Errorf("a quiet cluster is swept every %s, want %s", got, liveEvery)
|
||||
}
|
||||
b.rows[1].task = &runningTask{what: "clone", progress: 10}
|
||||
if got := b.liveInterval(); got != liveBusy {
|
||||
t.Errorf("a busy cluster is swept every %s, want %s", got, liveBusy)
|
||||
}
|
||||
}
|
||||
|
||||
// Two machines of one name are two machines, and the changed line has to say
|
||||
// which of them stopped. A name is not what makes a machine that machine —
|
||||
// vmRow.id carries the comment — and "web01 off" across three vCenters is a
|
||||
// coin toss. The server goes in front only where the name is ambiguous: paying
|
||||
// the width on every line for the rare case would be the wrong trade, and the
|
||||
// table underneath has a column for it.
|
||||
func TestTheChangedLineSaysWhichServerWhenItHasTo(t *testing.T) {
|
||||
twice := func(vc string) vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vc = VCenter{Name: vc}
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
|
||||
return r
|
||||
}
|
||||
stopped := func(r vmRow) vmRow {
|
||||
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
return r
|
||||
}
|
||||
|
||||
a, c := twice("v308"), twice("v309")
|
||||
got := changesBetween([]vmRow{a, c}, []vmRow{a, stopped(c)})
|
||||
if !strings.Contains(got, "v309 web01 off") {
|
||||
t.Errorf("with a web01 on each of two servers it said %q", got)
|
||||
}
|
||||
|
||||
// And one of that name is not dressed up with a server it does not need.
|
||||
got = changesBetween([]vmRow{a}, []vmRow{stopped(a)})
|
||||
if got != "web01 off" {
|
||||
t.Errorf("an unambiguous machine said %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tick may clear its own line and nothing else. A message somebody produced
|
||||
// by pressing a key is theirs: the address they just copied, or the reason a
|
||||
// change was refused, must not vanish because ten seconds passed with nothing
|
||||
// happening on the cluster.
|
||||
func TestAQuietTickClearsOnlyItsOwnLine(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
// What a tick that found something leaves behind, then a quiet one.
|
||||
b.setStatus(colInfo, "db01 off")
|
||||
b.saidLive = "db01 off"
|
||||
if b.status == b.saidLive {
|
||||
b.setStatus("", "")
|
||||
}
|
||||
if b.status != "" {
|
||||
t.Errorf("a quiet tick kept its own stale line: %q", b.status)
|
||||
}
|
||||
|
||||
// And somebody else's message, which it must leave alone.
|
||||
b.setStatus(colInfo, "copied its hostname web01.example to the clipboard (pbcopy)")
|
||||
b.saidLive = "db01 off"
|
||||
if b.status == b.saidLive {
|
||||
b.setStatus("", "")
|
||||
}
|
||||
if !strings.Contains(b.status, "copied") {
|
||||
t.Errorf("a quiet tick wiped a message it did not write: %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A refresh that half failed still leaves the rows and the view describing the
|
||||
// same list. Returning early with the rows replaced and the view not rebuilt
|
||||
// showed every row as a different machine, put the cursor on one nobody was
|
||||
// looking at, and panicked outright as soon as the new list was shorter — in
|
||||
// renderList, which follows the view without asking.
|
||||
func TestAHalfFailedRefreshLeavesTheScreenConsistent(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "20")
|
||||
|
||||
b := testBrowser("web01", "db01", "app07", "mail02")
|
||||
b.applySort()
|
||||
b.sel = len(b.view) - 1
|
||||
b.live = true
|
||||
|
||||
// No sessions: resweep fails outright, which is the harshest version of the
|
||||
// same path. The rows it could not re-read stay, and the screen still draws.
|
||||
b.liveTick()
|
||||
if len(b.view) != len(b.rows) {
|
||||
t.Errorf("the view describes %d rows of %d", len(b.view), len(b.rows))
|
||||
}
|
||||
for _, i := range b.view {
|
||||
if i < 0 || i >= len(b.rows) {
|
||||
t.Fatalf("the view points at row %d of %d", i, len(b.rows))
|
||||
}
|
||||
}
|
||||
_ = stripEscapes(renderToPipe(t, b, b.renderList)) // panicked before the fix
|
||||
|
||||
// And the tick is scheduled from after the sweep, not from before it: a
|
||||
// sweep slower than the interval would otherwise leave no idle time at all
|
||||
// and race every keystroke against an expired timer.
|
||||
if d := b.liveIn(); d <= 0 {
|
||||
t.Errorf("the next tick is already due (%s) the moment this one finished", d)
|
||||
}
|
||||
if b.liveGap == 0 {
|
||||
t.Error("the title has no interval to show")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that stops stops drawing. Keeping its history left a busy shape
|
||||
// beside a CPU% of "-": true once, and next to a figure saying there is
|
||||
// nothing to be true about.
|
||||
func TestTheTrendGoesWhenTheMachineStops(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
var at int
|
||||
for i, r := range b.rows {
|
||||
if r.running() {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
b.sample()
|
||||
b.sample()
|
||||
if b.rows[at].trend == "" {
|
||||
t.Fatal("a running machine drew nothing after two sweeps")
|
||||
}
|
||||
|
||||
b.rows[at].vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
b.sample()
|
||||
if got := b.rows[at].trend; got != "" {
|
||||
t.Errorf("a machine that was switched off still draws %q", got)
|
||||
}
|
||||
if n := len(b.hist[b.rows[at].id()]); n != 0 {
|
||||
t.Errorf("%d samples outlived the machine being switched off", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The lines that say a machine has gone are built from the old sweep, so
|
||||
// ambiguity has to be judged over both. Deciding it from the new rows alone
|
||||
// left "web01 is gone" unqualified in exactly the case the function exists for.
|
||||
func TestAmbiguityIsJudgedOverBothSweeps(t *testing.T) {
|
||||
at := func(vc string) vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vc = VCenter{Name: vc}
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
|
||||
return r
|
||||
}
|
||||
a, c := at("v308"), at("v309")
|
||||
|
||||
got := changesBetween([]vmRow{a, c}, []vmRow{a})
|
||||
if !strings.Contains(got, "v309 web01 is gone") {
|
||||
t.Errorf("one of two web01s was deleted and it said %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A refresh that reached nothing at all leaves the title alone, and should:
|
||||
// the rows on screen are still the ones that server gave, so a title naming it
|
||||
// is describing them correctly. It is the *partial* failure that must move the
|
||||
// title, and that one needs a server to answer — see TestSimLiveRefresh.
|
||||
func TestARefreshThatReachedNothingKeepsTheRowsAndTheirTitle(t *testing.T) {
|
||||
b := testBrowser("web01")
|
||||
b.applySort()
|
||||
b.answered = []string{"v308"}
|
||||
|
||||
b.liveTick()
|
||||
if len(b.rows) != 1 || len(b.answered) != 1 {
|
||||
t.Errorf("a failed refresh left %d rows titled %v", len(b.rows), b.answered)
|
||||
}
|
||||
if !strings.Contains(b.status, "live:") {
|
||||
t.Errorf("it did not say the refresh failed: %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// The keystroke reader has to be able to stop waiting, without that breaking
|
||||
// the one thing it must never break: a control sequence arrives in one burst,
|
||||
// and a deadline expiring in the middle of "ESC [ A" would turn one arrow key
|
||||
// into an Esc and a stray letter in the filter.
|
||||
func TestNextWithinStopsWaitingButNotMidSequence(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
kr := newKeyReader(r)
|
||||
|
||||
// Nothing to read: it gives up and says so.
|
||||
start := time.Now()
|
||||
if _, ok := kr.nextWithin(50 * time.Millisecond); ok {
|
||||
t.Error("a key was reported with nothing sent")
|
||||
}
|
||||
if waited := time.Since(start); waited < 40*time.Millisecond {
|
||||
t.Errorf("it gave up after %s, before it was asked to", waited)
|
||||
}
|
||||
|
||||
// A key that is there is decoded as usual.
|
||||
w.WriteString("q")
|
||||
k, ok := kr.nextWithin(time.Second)
|
||||
if !ok || k.special != keyRune || k.r != 'q' {
|
||||
t.Errorf("nextWithin gave %+v, %v", k, ok)
|
||||
}
|
||||
|
||||
// An arrow key survives it whole, with what follows still intact.
|
||||
w.WriteString("\x1b[Ax")
|
||||
k, ok = kr.nextWithin(time.Second)
|
||||
if !ok || k.special != keyUp {
|
||||
t.Errorf("the arrow came back as %+v, %v", k, ok)
|
||||
}
|
||||
if next := kr.next(); next.special != keyRune || next.r != 'x' {
|
||||
t.Errorf("what followed the arrow came back as %+v", next)
|
||||
}
|
||||
|
||||
// ^l is the toggle, and nothing else had it.
|
||||
w.WriteString("\x0c")
|
||||
if k, ok = kr.nextWithin(time.Second); !ok || k.special != keyCtrlL {
|
||||
t.Errorf("^l came back as %+v, %v", k, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Turning it on asks at once rather than in ten seconds' time, and both states
|
||||
// say which they are: a list that moves on its own with nothing to explain it
|
||||
// reads as a fault.
|
||||
func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
|
||||
b := testBrowser("web01")
|
||||
|
||||
b.toggleLive()
|
||||
if !b.live {
|
||||
t.Fatal("^l did not turn live mode on")
|
||||
}
|
||||
if !strings.Contains(b.status, "live on") {
|
||||
t.Errorf("turning it on said %q", b.status)
|
||||
}
|
||||
if d := b.liveIn(); d > time.Second {
|
||||
t.Errorf("the first refresh is %s away, want it now", d)
|
||||
}
|
||||
|
||||
b.toggleLive()
|
||||
if b.live {
|
||||
t.Fatal("^l did not turn live mode off")
|
||||
}
|
||||
if !strings.Contains(b.status, "live off") {
|
||||
t.Errorf("turning it off said %q", b.status)
|
||||
}
|
||||
}
|
||||
@@ -105,3 +105,25 @@ func paint(text, col string) string {
|
||||
}
|
||||
return col + text + attrOff
|
||||
}
|
||||
|
||||
// plainRow is one row with nothing in it but the values — the same cells and
|
||||
// the same widths as printRow writes to the screen, without the colour.
|
||||
//
|
||||
// For output that leaves the machine: the mail a report sends is read in a mail
|
||||
// client, where an escape sequence is not a colour but four stray characters,
|
||||
// and fatih/color's answer to "is this a terminal" is about this process's
|
||||
// stdout and says nothing about where a mail is going.
|
||||
func plainRow(cols []printColumn, cells []cell) string {
|
||||
out := make([]string, 0, len(cols))
|
||||
for i, c := range cols {
|
||||
text := c.header
|
||||
if cells != nil {
|
||||
if i >= len(cells) {
|
||||
break
|
||||
}
|
||||
text = cells[i].text
|
||||
}
|
||||
out = append(out, pad(text, c.width, c.right))
|
||||
}
|
||||
return strings.TrimRight(strings.Join(out, " "), " ")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+756
-3
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -30,6 +32,12 @@ func simVCenter(t *testing.T) VCenter {
|
||||
func simVCenterModel(t *testing.T) (VCenter, *simulator.Model) {
|
||||
t.Helper()
|
||||
|
||||
// Every sweep leaves the machine names in the cache directory for the shell
|
||||
// to complete against (complete.go), so the cache is pointed at a temporary
|
||||
// home first: a test run must no more write "DC0_C0_RP0_VM0" into the
|
||||
// completion cache of the person running it than it may touch a real vCenter.
|
||||
cacheHome(t)
|
||||
|
||||
model := simulator.VPX()
|
||||
model.Datacenter = 1
|
||||
model.Host = 2
|
||||
@@ -269,10 +277,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
|
||||
vc := simVCenter(t)
|
||||
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
|
||||
|
||||
if err := lsvm([]VCenter{vc}, "", "", false); err != nil {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{}); err != nil {
|
||||
t.Errorf("lsvm: %v", err)
|
||||
}
|
||||
if err := lsvm([]VCenter{vc}, "DC0", "", false); err != nil {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{match: "DC0"}); err != nil {
|
||||
t.Errorf("lsvm with a pattern: %v", err)
|
||||
}
|
||||
if err := hoststat(vc, ""); err != nil {
|
||||
@@ -286,7 +294,7 @@ func TestSimReadOnlyCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
// A pattern that is not a regexp is a message, not a panic.
|
||||
if err := lsvm([]VCenter{vc}, "web(", "", false); err == nil {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{match: "web("}); err == nil {
|
||||
t.Error("lsvm accepted a broken pattern")
|
||||
}
|
||||
}
|
||||
@@ -962,3 +970,748 @@ func TestSimAllVCentersUnreachable(t *testing.T) {
|
||||
t.Errorf("answered: %v", found.answered)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- what the new commands read
|
||||
|
||||
// The sweep brings the snapshots back with the machines, which is what the
|
||||
// snapshot column, the issues filter and the age report are all made of.
|
||||
func TestSimSweepCarriesTheSnapshots(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
|
||||
if err := snapshotNow(s, r.ref, "sweep-test", "from the tests"); err != nil {
|
||||
t.Fatalf("cannot take a snapshot: %v", err)
|
||||
}
|
||||
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
defer closeSessions(found.sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
|
||||
for _, row := range found.rows {
|
||||
if row.name != "DC0_C0_RP0_VM0" {
|
||||
continue
|
||||
}
|
||||
if row.snapCount() != 1 {
|
||||
t.Fatalf("the sweep found %d snapshots, want 1", row.snapCount())
|
||||
}
|
||||
if row.snapCell() != "1" {
|
||||
t.Errorf("the column shows %q", row.snapCell())
|
||||
}
|
||||
e, ok := row.oldest()
|
||||
if !ok {
|
||||
t.Fatal("the snapshot came back without a date")
|
||||
}
|
||||
if e.name != "sweep-test" {
|
||||
t.Errorf("the oldest snapshot is %q", e.name)
|
||||
}
|
||||
// Taken a moment ago, so it is neither stale nor an issue.
|
||||
if days := e.days(); days != 0 {
|
||||
t.Errorf("a snapshot taken just now is %d days old", days)
|
||||
}
|
||||
if row.hasIssues() && strings.Contains(strings.Join(row.issues(), " "), "snapshot") {
|
||||
t.Errorf("a fresh snapshot is held against the machine: %v", row.issues())
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("the machine was not in the sweep")
|
||||
}
|
||||
|
||||
// The age report, end to end: a snapshot taken now is not old, and the same
|
||||
// report with an age of nothing finds it.
|
||||
func TestSimSnapshotAgeReport(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
|
||||
if err := snapshotNow(s, r.ref, "ancient", "from the tests"); err != nil {
|
||||
t.Fatalf("cannot take a snapshot: %v", err)
|
||||
}
|
||||
|
||||
// Nothing is a month old on a vCenter that was created a second ago — and
|
||||
// where somebody is reading, the report says so rather than printing an
|
||||
// empty table.
|
||||
onATerminal(t)
|
||||
out := captureStdout(t, func() {
|
||||
if err := snapOldReport(Config{}, []VCenter{vc}, snapOldDays, false); err != nil {
|
||||
t.Fatalf("snapOldReport: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stripEscapes(out), "no snapshot") {
|
||||
t.Errorf("the report of old snapshots says:\n%s", out)
|
||||
}
|
||||
|
||||
// Everything is nought days old, so an age of nought finds it.
|
||||
out = captureStdout(t, func() {
|
||||
if err := snapOldReport(Config{}, []VCenter{vc}, 0, false); err != nil {
|
||||
t.Fatalf("snapOldReport: %v", err)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{"ancient", "DC0_C0_RP0_VM1", "0d"} {
|
||||
if !strings.Contains(stripEscapes(out), want) {
|
||||
t.Errorf("the report leaves out %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mailed report with no relay configured is refused before the sweep, not
|
||||
// after it: finding out that the mail cannot be sent is of no use once the
|
||||
// report has been printed.
|
||||
func TestSimSnapshotReportChecksTheMailFirst(t *testing.T) {
|
||||
quiet(t)
|
||||
err := snapOldReport(Config{}, []VCenter{{Name: "nowhere", URL: "https://127.0.0.1:1/",
|
||||
User: "u", Password: "p", Datacenter: "DC0"}}, 30, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot send mail") {
|
||||
t.Errorf("a report with -m and no relay failed with: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimDatastores(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := dsstat(vc, ""); err != nil {
|
||||
t.Fatalf("dsstat: %v", err)
|
||||
}
|
||||
})
|
||||
plain := stripEscapes(out)
|
||||
for _, want := range []string{"DATASTORE", "CAPACITY", "USED%", "LocalDS_0", "datastore"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("the datastore table leaves out %q:\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The printed listing as a document: what a monitoring check would read.
|
||||
func TestSimJSONListing(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{json: true}); err != nil {
|
||||
t.Fatalf("lsvm --json: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var doc struct {
|
||||
Answered []string `json:"answered"`
|
||||
Failed []string `json:"failed"`
|
||||
Count int `json:"count"`
|
||||
Machines []struct {
|
||||
Name string `json:"name"`
|
||||
VCenter string `json:"vcenter"`
|
||||
Moref string `json:"moref"`
|
||||
Power string `json:"power"`
|
||||
} `json:"machines"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("the listing is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if len(doc.Answered) != 1 || doc.Answered[0] != "sim" {
|
||||
t.Errorf("answered = %v", doc.Answered)
|
||||
}
|
||||
if doc.Count != len(doc.Machines) || doc.Count == 0 {
|
||||
t.Errorf("count = %d, machines = %d", doc.Count, len(doc.Machines))
|
||||
}
|
||||
for _, m := range doc.Machines {
|
||||
if m.Name == "" || m.Moref == "" || m.VCenter != "sim" || m.Power == "" {
|
||||
t.Errorf("a machine came out as %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A vCenter that does not answer is in the document rather than printed into
|
||||
// the middle of it, where it would break whatever is reading it.
|
||||
func TestSimJSONKeepsTheProseOut(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
dead := VCenter{Name: "dead", URL: "https://127.0.0.1:1/", User: "u", Password: "p", Datacenter: "DC0"}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := lsvm([]VCenter{vc, dead}, lsOptions{json: true}); err != nil {
|
||||
t.Fatalf("lsvm --json: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var doc struct {
|
||||
Answered []string `json:"answered"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("a failed server made the document unreadable: %v\n%s", err, out)
|
||||
}
|
||||
if len(doc.Failed) != 1 || !strings.Contains(doc.Failed[0], "dead") {
|
||||
t.Errorf("failed = %v", doc.Failed)
|
||||
}
|
||||
if strings.Contains(out, "ERROR") {
|
||||
t.Errorf("the failure was printed as prose as well:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The issues listing runs against a real inventory. The simulated machines have
|
||||
// no VMware Tools, which is exactly the kind of thing it is for.
|
||||
func TestSimIssuesListing(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
|
||||
t.Fatalf("lsvm --issues: %v", err)
|
||||
}
|
||||
})
|
||||
plain := stripEscapes(out)
|
||||
if !strings.Contains(plain, "WHY") {
|
||||
t.Errorf("the issues listing has no reason column:\n%s", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "VMware Tools") {
|
||||
t.Errorf("the running machines without Tools were not reported:\n%s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's own events, which is what 'e' on a machine's sheet fetches.
|
||||
func TestSimEventsOfOneMachine(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
|
||||
// Something to find: a snapshot leaves events behind it.
|
||||
if err := snapshotNow(s, r.ref, "for-the-log", "from the tests"); err != nil {
|
||||
t.Fatalf("cannot take a snapshot: %v", err)
|
||||
}
|
||||
|
||||
lines, err := eventsOf(r)
|
||||
if err != nil {
|
||||
t.Fatalf("eventsOf: %v", err)
|
||||
}
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l.text) == "" {
|
||||
t.Error("an event came back with nothing in it")
|
||||
}
|
||||
if strings.ContainsAny(l.text, "\n\r") {
|
||||
t.Errorf("an event carries a newline, which would break the sheet: %q", l.text)
|
||||
}
|
||||
}
|
||||
|
||||
// And they go on the sheet under one label, in their own colours.
|
||||
sheet := eventSheet(lines)
|
||||
if len(sheet) != len(lines) {
|
||||
t.Errorf("the sheet holds %d of %d events", len(sheet), len(lines))
|
||||
}
|
||||
if len(sheet) > 0 && sheet[0].label != "events" {
|
||||
t.Errorf("the first event line is labelled %q", sheet[0].label)
|
||||
}
|
||||
for _, l := range sheet[1:] {
|
||||
if l.label != "" {
|
||||
t.Errorf("an event line carries a second label: %q", l.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine read over a connection that has gone cannot be asked for anything,
|
||||
// and says so rather than looking empty.
|
||||
func TestSimEventsWithoutAConnection(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if _, err := eventsOf(r); err == nil {
|
||||
t.Error("a row with no session read its events anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// The estate screen against a server that answers: the hosts come back grouped
|
||||
// under their clusters, what they carry is added up from the rows the list
|
||||
// already holds, and Enter narrows the list to the host under the cursor.
|
||||
func TestSimEstateScreen(t *testing.T) {
|
||||
quiet(t)
|
||||
t.Setenv("COLUMNS", "150")
|
||||
t.Setenv("LINES", "20")
|
||||
vc := simVCenter(t)
|
||||
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
defer closeSessions(found.sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
b := &browser{targets: []VCenter{vc}, rows: found.rows, sessions: found.sessions,
|
||||
answered: found.answered}
|
||||
b.applySort()
|
||||
|
||||
b.openEstate()
|
||||
if b.estate == nil {
|
||||
t.Fatalf("the estate screen did not open: %q", b.status)
|
||||
}
|
||||
e := b.estate
|
||||
|
||||
// Every host of the simulated inventory, under a heading each, and the
|
||||
// cursor on a host rather than on a heading.
|
||||
var hosts, headings int
|
||||
for _, r := range e.rows {
|
||||
if r.isHeading() {
|
||||
headings++
|
||||
continue
|
||||
}
|
||||
hosts++
|
||||
}
|
||||
if hosts == 0 || headings == 0 {
|
||||
t.Fatalf("%d hosts under %d headings", hosts, headings)
|
||||
}
|
||||
if e.rows[e.sel].isHeading() {
|
||||
t.Error("the cursor started on a heading")
|
||||
}
|
||||
|
||||
// The machines are charged to the hosts they are on, and the total matches
|
||||
// what the list holds — nothing counted twice, nothing dropped.
|
||||
charged := 0
|
||||
for _, r := range e.rows {
|
||||
if !r.isHeading() {
|
||||
charged += r.vms
|
||||
}
|
||||
}
|
||||
placed := 0
|
||||
for _, r := range b.rows {
|
||||
if r.vm.Summary.Runtime.Host != nil {
|
||||
placed++
|
||||
}
|
||||
}
|
||||
if charged != placed {
|
||||
t.Errorf("%d machines charged to hosts, %d placed on one", charged, placed)
|
||||
}
|
||||
|
||||
// A heading is the sum of the hosts under it.
|
||||
for i, r := range e.rows {
|
||||
if !r.isHeading() {
|
||||
continue
|
||||
}
|
||||
sum := 0
|
||||
for _, h := range e.rows[i+1:] {
|
||||
if h.isHeading() {
|
||||
break
|
||||
}
|
||||
sum += h.vms
|
||||
}
|
||||
if r.vms != sum {
|
||||
t.Errorf("%q says %d machines, its hosts hold %d", r.heading, r.vms, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// It draws, with the figures on it.
|
||||
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
|
||||
for _, want := range []string{"Estate", "CLUSTER / HOST", "MEM ALLOC", "DC0_C0"} {
|
||||
if !strings.Contains(frame, want) {
|
||||
t.Errorf("the screen does not show %q:\n%s", want, frame)
|
||||
}
|
||||
}
|
||||
|
||||
// And Enter is the whole point of arrowing to a host: the machine list,
|
||||
// narrowed to it.
|
||||
//
|
||||
// Arrowed to one that carries something, deliberately: the simulator does
|
||||
// not spread its machines evenly and leaves some hosts empty, so starting
|
||||
// from wherever the cursor happens to open would be a test that passes or
|
||||
// fails by placement rather than by behaviour. (It did: one run in three.)
|
||||
for i, r := range e.rows {
|
||||
if !r.isHeading() && r.vms > 0 {
|
||||
e.sel = i
|
||||
break
|
||||
}
|
||||
}
|
||||
host := e.rows[e.sel].host
|
||||
if e.rows[e.sel].vms == 0 {
|
||||
t.Fatal("no host in the simulated inventory carries a machine")
|
||||
}
|
||||
b.showHost()
|
||||
if b.estate != nil {
|
||||
t.Error("the estate screen stayed open after Enter")
|
||||
}
|
||||
if b.filter != host {
|
||||
t.Errorf("the filter is %q, want the host %q", b.filter, host)
|
||||
}
|
||||
if len(b.view) == 0 {
|
||||
t.Errorf("filtering to %s left no machines, though it carries some", host)
|
||||
}
|
||||
for _, i := range b.view {
|
||||
if b.rows[i].host != host {
|
||||
t.Errorf("%s is on %s, not on %s", b.rows[i].name, b.rows[i].host, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live mode against a server that answers: the refresh goes over the connection
|
||||
// that is already open, it notices what happened behind gvm's back, and it says
|
||||
// what changed.
|
||||
func TestSimLiveRefreshUsesTheOpenSession(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
|
||||
b := &browser{targets: []VCenter{vc}, rows: rows, sessions: sessions,
|
||||
answered: found.answered}
|
||||
b.applySort()
|
||||
b.sample()
|
||||
|
||||
// The refresh reads the same machines back, and does not touch the
|
||||
// sessions: a reload replaces them, this must not.
|
||||
before := len(b.rows)
|
||||
was := b.rows
|
||||
if err := b.resweep(); err != nil {
|
||||
t.Fatalf("resweep: %v", err)
|
||||
}
|
||||
if len(b.rows) != before {
|
||||
t.Errorf("the refresh came back with %d of %d machines", len(b.rows), before)
|
||||
}
|
||||
if len(b.sessions) != len(sessions) || b.sessions[0] != sessions[0] {
|
||||
t.Error("the refresh replaced the session it was supposed to reuse")
|
||||
}
|
||||
if got := changesBetween(was, b.rows); got != "" {
|
||||
t.Errorf("a refresh with nothing happening in between said %q", got)
|
||||
}
|
||||
|
||||
// Something happens that gvm did not do. A refresh has to notice, and say
|
||||
// so — this is the whole reason to leave the list open.
|
||||
var victim vmRow
|
||||
for _, r := range b.rows {
|
||||
if r.running() {
|
||||
victim = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if victim.name == "" {
|
||||
t.Fatal("no running machine to stop")
|
||||
}
|
||||
if _, err := runPower(victim.sess, victim, opPowerOff); err != nil {
|
||||
t.Fatalf("cannot stop %s: %v", victim.name, err)
|
||||
}
|
||||
|
||||
was = b.rows
|
||||
if err := b.resweep(); err != nil {
|
||||
t.Fatalf("resweep after the change: %v", err)
|
||||
}
|
||||
said := changesBetween(was, b.rows)
|
||||
if !strings.Contains(said, victim.name+" off") {
|
||||
t.Errorf("the refresh did not report the machine stopping: %q", said)
|
||||
}
|
||||
for _, r := range b.rows {
|
||||
if r.name == victim.name && r.running() {
|
||||
t.Error("the refreshed row still says the machine is running")
|
||||
}
|
||||
}
|
||||
|
||||
// One server of two failing moves the title as well as the status line: the
|
||||
// rows of the one that did not answer are kept, and it is named as lost
|
||||
// rather than left in the list of servers holding machines. A title reading
|
||||
// "on v308, v309" above a status line saying v309 did not answer is two
|
||||
// lines contradicting each other, and the next keystroke clears the true
|
||||
// one.
|
||||
dead, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
b.sessions = append(b.sessions, &session{vc: VCenter{Name: "v309"}, ctx: dead,
|
||||
client: sessions[0].client, cancel: stop})
|
||||
b.answered, b.lost = []string{vc.Name, "v309"}, nil
|
||||
|
||||
if err := b.resweep(); err == nil {
|
||||
t.Error("a refresh over a dead session reported no trouble")
|
||||
}
|
||||
if contains(b.answered, "v309") {
|
||||
t.Errorf("v309 did not answer and is still named as holding machines: %v", b.answered)
|
||||
}
|
||||
if !contains(b.lost, "v309") {
|
||||
t.Errorf("v309 is not named as lost: %v", b.lost)
|
||||
}
|
||||
if !contains(b.answered, vc.Name) {
|
||||
t.Errorf("%s answered and is not named: %v", vc.Name, b.answered)
|
||||
}
|
||||
b.sessions = b.sessions[:len(b.sessions)-1]
|
||||
|
||||
// And it really is the open session it reads over: with that closed, the
|
||||
// refresh fails instead of quietly logging in again. Three logins a minute
|
||||
// is what live mode exists not to do.
|
||||
closeSessions(sessions)
|
||||
if err := b.resweep(); err == nil {
|
||||
t.Error("the refresh worked with every session closed, so it logged in again")
|
||||
}
|
||||
// The rows of a server that stopped answering are kept rather than dropped:
|
||||
// an empty list because a vCenter is restarting would be worse than one
|
||||
// that is a minute old.
|
||||
if len(b.rows) != before {
|
||||
t.Errorf("a failed refresh left %d of %d machines on screen", len(b.rows), before)
|
||||
}
|
||||
}
|
||||
|
||||
// Changing what a machine has, against a server that answers: the refusal on a
|
||||
// running machine, and the change itself once it is off. The numbers are read
|
||||
// back from the vCenter afterwards rather than assumed — a reconfigure that is
|
||||
// accepted and does nothing would otherwise look exactly like one that worked.
|
||||
func TestSimResizeAMachine(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
|
||||
sz, err := sizingOf(s, r.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("sizingOf: %v", err)
|
||||
}
|
||||
if !sz.known {
|
||||
t.Fatal("the machine's configuration came back empty")
|
||||
}
|
||||
|
||||
// The simulator's machines run, and are built without hot-plug — which is
|
||||
// the case the objection exists for.
|
||||
if !r.running() {
|
||||
t.Fatal("the simulated machine is not running, so there is nothing to refuse")
|
||||
}
|
||||
if err := checkSize(r, sz, sizeCPUs, sz.cpus+2); err == nil {
|
||||
t.Error("more vCPUs were allowed on a running machine with no hot-add")
|
||||
}
|
||||
if err := checkSize(r, sz, sizeMemory, sz.memoryMB*2); err == nil {
|
||||
t.Error("more memory was allowed on a running machine with no hot-add")
|
||||
}
|
||||
|
||||
// Off it goes, and with it the objection.
|
||||
if _, err := runPower(s, r, opPowerOff); err != nil {
|
||||
t.Fatalf("cannot power the machine off: %v", err)
|
||||
}
|
||||
if err := refreshOne(s, &r); err != nil {
|
||||
t.Fatalf("cannot re-read the machine: %v", err)
|
||||
}
|
||||
|
||||
wantCPUs, wantMemory := sz.cpus+2, sz.memoryMB+1024
|
||||
for _, c := range []struct {
|
||||
kind sizeKind
|
||||
want int32
|
||||
}{{sizeCPUs, wantCPUs}, {sizeMemory, wantMemory}} {
|
||||
// Read again between the two, so the second change is checked and
|
||||
// described against what the first one left behind rather than against
|
||||
// what the machine looked like before either.
|
||||
sz, err = sizingOf(s, r.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("sizingOf: %v", err)
|
||||
}
|
||||
msg, err := runResize(s, r, sz, c.kind, c.want)
|
||||
if err != nil {
|
||||
t.Fatalf("runResize(%v): %v", c.kind.what(), err)
|
||||
}
|
||||
if !strings.Contains(msg, "→") {
|
||||
t.Errorf("the message does not say what changed: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
after, err := sizingOf(s, r.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("sizingOf after the change: %v", err)
|
||||
}
|
||||
if after.cpus != wantCPUs {
|
||||
t.Errorf("the machine has %d vCPUs, want %d", after.cpus, wantCPUs)
|
||||
}
|
||||
if after.memoryMB != wantMemory {
|
||||
t.Errorf("the machine has %d MB, want %d", after.memoryMB, wantMemory)
|
||||
}
|
||||
|
||||
// And the change that would take memory away from it while it runs is still
|
||||
// refused after it has been given some.
|
||||
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOn
|
||||
if err := checkSize(r, after, sizeMemory, after.memoryMB-1024); err == nil {
|
||||
t.Error("memory was taken away from a running machine")
|
||||
}
|
||||
}
|
||||
|
||||
// A command line that asks for two changes does neither until both are known to
|
||||
// be possible: setting the vCPUs and then refusing the memory would leave half
|
||||
// of what somebody asked for, which is the outcome nobody wanted.
|
||||
func TestSimResizeRefusesBothOrNeither(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
s, r := oneRow(t, vc, "DC0_C0_RP0_VM1")
|
||||
if _, err := runPower(s, r, opPowerOff); err != nil {
|
||||
t.Fatalf("cannot power the machine off: %v", err)
|
||||
}
|
||||
before, err := sizingOf(s, r.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("sizingOf: %v", err)
|
||||
}
|
||||
|
||||
// The vCPU count is good and the memory is not a multiple of 4 MB. -y so
|
||||
// that a confirmation cannot be what stops it: the refusal has to come from
|
||||
// the check, before anything is sent.
|
||||
err = sizeCLI(vc, "DC0_C0_RP0_VM1", Itoa(int(before.cpus+2)), "1026m", true)
|
||||
if err == nil {
|
||||
t.Fatal("a half-impossible command line was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "multiples of 4 MB") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
|
||||
after, err := sizingOf(s, r.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("sizingOf after the refusal: %v", err)
|
||||
}
|
||||
if after.cpus != before.cpus {
|
||||
t.Errorf("the vCPUs were changed anyway: %d, was %d", after.cpus, before.cpus)
|
||||
}
|
||||
if after.memoryMB != before.memoryMB {
|
||||
t.Errorf("the memory was changed anyway: %d, was %d", after.memoryMB, before.memoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
// refreshOne re-reads the properties the sweep reads, for a row a test has just
|
||||
// changed something about.
|
||||
func refreshOne(s *session, r *vmRow) error {
|
||||
var fresh mo.VirtualMachine
|
||||
vm := object.NewVirtualMachine(s.client.Client, r.ref)
|
||||
if err := vm.Properties(s.ctx, r.ref, sweepProps, &fresh); err != nil {
|
||||
return err
|
||||
}
|
||||
r.vm = fresh
|
||||
return nil
|
||||
}
|
||||
|
||||
// The instance UUID is what the vSphere client's links are made of, and the one
|
||||
// thing gvm cannot work out from the configuration.
|
||||
func TestSimVsphereLinkFromASession(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
_, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
|
||||
url := vsphereURL(r)
|
||||
if url == "" {
|
||||
t.Fatal("no link was built from a live connection")
|
||||
}
|
||||
if !strings.Contains(url, "urn:vmomi:VirtualMachine:"+r.ref.Value+":") {
|
||||
t.Errorf("the link does not name the machine: %s", url)
|
||||
}
|
||||
if !strings.HasSuffix(url, "/summary") {
|
||||
t.Errorf("the link does not end at the machine's page: %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// -v takes several servers for the sweeps, and the same server twice is still
|
||||
// one login.
|
||||
func TestMultipleVCentersInOneSweep(t *testing.T) {
|
||||
cfg := Config{VCenters: []VCenter{
|
||||
{Name: "v308", URL: "https://a/", User: "u", Password: "p", Datacenter: "DC"},
|
||||
{Name: "v108", URL: "https://b/", User: "u", Password: "p", Datacenter: "DC"},
|
||||
{Name: "v38", URL: "https://c/", User: "u", Password: "p", Datacenter: "DC"},
|
||||
}}
|
||||
|
||||
got, err := cfg.targets("v308,v38")
|
||||
if err != nil {
|
||||
t.Fatalf("targets: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "v308" || got[1].Name != "v38" {
|
||||
t.Errorf("-v v308,v38 gave %v", vcNames(got))
|
||||
}
|
||||
|
||||
// The order given is the order used, and a name given twice is one server.
|
||||
got, _ = cfg.targets("v38, v308 ,v38")
|
||||
if len(got) != 2 || got[0].Name != "v38" || got[1].Name != "v308" {
|
||||
t.Errorf("-v with a repeat gave %v", vcNames(got))
|
||||
}
|
||||
|
||||
// An unknown name among them is an error, not a shorter list: a sweep that
|
||||
// quietly left a server out would report a cluster that is not there.
|
||||
if _, err := cfg.targets("v308,nowhere"); err == nil {
|
||||
t.Error("an unknown server in the list was skipped")
|
||||
}
|
||||
|
||||
// And the commands that act on one machine refuse a list outright.
|
||||
if _, err := cfg.pick("v308,v38"); err == nil {
|
||||
t.Error("a single-server command took a list of servers")
|
||||
} else if !strings.Contains(err.Error(), "one vCenter at a time") {
|
||||
t.Errorf("it refused with: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The task path itself: that the references off a machine can be read back as
|
||||
// tasks at all. runningTasks swallows a failure here on purpose — a table is
|
||||
// worth having without the column — so a broken read would otherwise look
|
||||
// exactly like a quiet cluster, on every vCenter, for ever.
|
||||
func TestSimTasksAreReadable(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
s, r := oneRow(t, vc, "DC0_C0_RP0_VM0")
|
||||
if err := snapshotNow(s, r.ref, "leaves-a-task", "from the tests"); err != nil {
|
||||
t.Fatalf("cannot take a snapshot: %v", err)
|
||||
}
|
||||
|
||||
var fresh mo.VirtualMachine
|
||||
if err := object.NewVirtualMachine(s.client.Client, r.ref).
|
||||
Properties(s.ctx, r.ref, []string{"recentTask"}, &fresh); err != nil {
|
||||
t.Fatalf("cannot read recentTask: %v", err)
|
||||
}
|
||||
// A re-read machine has to know which machine it is: runningTasks keys what
|
||||
// it finds by the machine's own reference, and refreshRow looks its answer
|
||||
// up by the reference of the row. A property read that did not fill that in
|
||||
// would leave the task column empty on exactly the row that was just acted
|
||||
// on, and nowhere else.
|
||||
if fresh.Reference() != r.ref {
|
||||
t.Fatalf("a re-read machine came back as %v, not %v", fresh.Reference(), r.ref)
|
||||
}
|
||||
if len(fresh.RecentTask) == 0 {
|
||||
t.Skip("this vCenter keeps no recent tasks on the machine")
|
||||
}
|
||||
|
||||
tasks, err := s.tasks(fresh.RecentTask)
|
||||
if err != nil {
|
||||
t.Fatalf("the task references could not be read: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("no task came back for a machine that has just had one")
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.Info.DescriptionId == "" {
|
||||
t.Error("a task came back with nothing to call it")
|
||||
}
|
||||
}
|
||||
|
||||
// And a task that has finished is not something being done to the machine.
|
||||
if busy := runningTasks(s, []mo.VirtualMachine{fresh}); len(busy) != 0 {
|
||||
t.Errorf("a finished snapshot is still reported as going on: %v", busy)
|
||||
}
|
||||
}
|
||||
|
||||
// The issues listing is the one that belongs in cron, so with nothing to report
|
||||
// it prints nothing at all — cron mails whatever a command prints, and a daily
|
||||
// "nothing wrong" is a daily mail nobody reads. On a terminal it says so.
|
||||
func TestSimIssuesListingIsQuietUnderCron(t *testing.T) {
|
||||
quiet(t)
|
||||
vc, model := simVCenterModel(t)
|
||||
|
||||
// A healthy inventory: the simulated machines are only in the issues list
|
||||
// because they run without VMware Tools, so switching Tools on for all of
|
||||
// them leaves nothing to report.
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
for _, r := range found.rows {
|
||||
startTools(t, model, r.ref)
|
||||
}
|
||||
closeSessions(found.sessions)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
|
||||
t.Fatalf("lsvm --issues: %v", err)
|
||||
}
|
||||
})
|
||||
if out != "" {
|
||||
t.Errorf("a clean estate printed this into a pipe:\n%s", out)
|
||||
}
|
||||
|
||||
onATerminal(t)
|
||||
out = captureStdout(t, func() {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{issues: true}); err != nil {
|
||||
t.Fatalf("lsvm --issues: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stripEscapes(out), "nothing to report") {
|
||||
t.Errorf("on a terminal a clean estate printed %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
// snapold.go — the snapshots nobody has come back for.
|
||||
//
|
||||
// This is the one recurring job in a vSphere estate that nothing in vCenter
|
||||
// does for you: somebody takes a snapshot before an upgrade, the upgrade goes
|
||||
// well, and the snapshot stays. Six weeks later its delta disk is bigger than
|
||||
// the machine and the datastore is the thing that pages you.
|
||||
//
|
||||
// So the report reads every vCenter at once and prints one line per snapshot
|
||||
// older than the age asked for, oldest first, with what it costs — and with -m
|
||||
// it goes out by mail, which is the form it is actually useful in: this is a
|
||||
// cron job, not something anyone remembers to run.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/vmware/govmomi/units"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
var snapOldColumns = []printColumn{
|
||||
{header: "MACHINE", width: 24},
|
||||
{header: "VC", width: 4},
|
||||
{header: "SNAPSHOT", width: 22},
|
||||
{header: "AGE", width: 6, right: true},
|
||||
{header: "TAKEN", width: 16},
|
||||
{header: "SIZE", width: 9, right: true},
|
||||
}
|
||||
|
||||
// oldSnap is one snapshot in the report, with the machine it belongs to.
|
||||
type oldSnap struct {
|
||||
row vmRow
|
||||
snap snapEntry
|
||||
bytes int64 // what it owns on the datastore, 0 when that could not be read
|
||||
}
|
||||
|
||||
// snapOldReport prints the report and, when asked, mails it.
|
||||
func snapOldReport(cfg Config, targets []VCenter, days int, mail bool) error {
|
||||
if mail {
|
||||
// Asked before the sweep, not after it: finding out that the mail cannot
|
||||
// be sent is of no use once the report has scrolled past.
|
||||
if err := cfg.mailReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
found, err := gatherVMs(targets)
|
||||
defer closeSessions(found.sessions)
|
||||
for _, why := range found.failed {
|
||||
PE(why) // said before the report, where it will not be scrolled past
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Which machines have snapshots at all decides what the sizes are asked
|
||||
// for, so it is worked out before anything else is read.
|
||||
var carrying []vmRow
|
||||
for _, r := range found.rows {
|
||||
if len(r.snaps) > 0 {
|
||||
carrying = append(carrying, r)
|
||||
}
|
||||
}
|
||||
sizes := snapshotSizes(carrying)
|
||||
|
||||
var old []oldSnap
|
||||
for _, r := range carrying {
|
||||
for _, e := range r.snaps {
|
||||
if e.days() < days {
|
||||
continue
|
||||
}
|
||||
old = append(old, oldSnap{row: r, snap: e, bytes: sizes[snapKey(r, e)]})
|
||||
}
|
||||
}
|
||||
// Oldest first: that is the order the work is done in, and the first line of
|
||||
// a mail is the one that gets read.
|
||||
sort.SliceStable(old, func(i, j int) bool {
|
||||
return old[i].snap.when.Before(old[j].snap.when)
|
||||
})
|
||||
|
||||
body := snapOldTable(old, days, len(found.rows))
|
||||
if !mail || len(old) == 0 {
|
||||
return nil
|
||||
}
|
||||
return sendmail(cfg, SF("old snapshots (%s)", vcNames(targets)), "<pre>"+body+"</pre>")
|
||||
}
|
||||
|
||||
// snapOldTable prints the report and returns the same report as text.
|
||||
//
|
||||
// Both come out of one set of cells: the screen gets them painted and fitted,
|
||||
// the mail gets them plain. A report that was formatted twice would eventually
|
||||
// say two different things, and the mail is the copy nobody checks.
|
||||
func snapOldTable(old []oldSnap, days, machines int) string {
|
||||
if len(old) == 0 {
|
||||
line := SF("no snapshot on any of the %d machines is %s old", machines, plural(days, "day"))
|
||||
// Only where somebody is reading. This report runs weekly out of cron,
|
||||
// and cron mails whatever is printed: a weekly "nothing to clean up"
|
||||
// teaches everyone to filter the report away.
|
||||
if !color.NoColor {
|
||||
P(Cgb(line))
|
||||
}
|
||||
return line + "\n"
|
||||
}
|
||||
|
||||
cells := make([][]cell, 0, len(old))
|
||||
total := int64(0)
|
||||
for _, o := range old {
|
||||
total += o.bytes
|
||||
cells = append(cells, []cell{
|
||||
{o.row.name, colName},
|
||||
{o.row.vc.Name, colWhere},
|
||||
{o.snap.name, colChosen},
|
||||
{SF("%dd", o.snap.days()), ageColor(o.snap.days())},
|
||||
{o.snap.created, colAddress},
|
||||
sizeCell(o.bytes),
|
||||
})
|
||||
}
|
||||
cols := append([]printColumn(nil), snapOldColumns...)
|
||||
widen(cols, cells)
|
||||
|
||||
head := SF("%d snapshots older than %s, on %s:", len(old), plural(days, "day"),
|
||||
plural(countMachines(old), "machine"))
|
||||
tail := SF("%s in %s, on %s", units.ByteSize(total),
|
||||
plural(len(old), "snapshot"), plural(countMachines(old), "machine"))
|
||||
|
||||
P(Cwb(head))
|
||||
P()
|
||||
printRow(cols, "", nil)
|
||||
for _, c := range cells {
|
||||
printRow(cols, "", c)
|
||||
}
|
||||
P()
|
||||
P(Cob(tail))
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(head + "\n\n")
|
||||
sb.WriteString(plainRow(cols, nil) + "\n")
|
||||
for _, c := range cells {
|
||||
sb.WriteString(plainRow(cols, c) + "\n")
|
||||
}
|
||||
sb.WriteString("\n" + tail + "\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// countMachines counts the machines rather than the snapshots: three snapshots
|
||||
// of one machine is one machine's worth of work.
|
||||
func countMachines(old []oldSnap) int {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range old {
|
||||
seen[o.row.id()] = true
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// sizeCell is what the snapshot owns, or a dash where the file layout could not
|
||||
// be read. Nought bytes and "not known" are different answers and a report that
|
||||
// prints 0 B for the second invites somebody to remove the wrong snapshot.
|
||||
func sizeCell(b int64) cell {
|
||||
if b <= 0 {
|
||||
return cell{"-", colOff}
|
||||
}
|
||||
return cell{units.ByteSize(b).String(), colSize}
|
||||
}
|
||||
|
||||
// ageColor takes the table's thresholds: yellow once a snapshot has stopped
|
||||
// being this week's, red once it has stopped being this month's.
|
||||
func ageColor(days int) string {
|
||||
switch {
|
||||
case days >= snapOldDays:
|
||||
return colFull
|
||||
case days >= snapStaleDays:
|
||||
return colBusy
|
||||
}
|
||||
return colSize
|
||||
}
|
||||
|
||||
// snapKey names one snapshot of one machine across every server: two vCenters
|
||||
// hand out the same references, and a machine may hold two snapshots of one
|
||||
// name.
|
||||
func snapKey(r vmRow, e snapEntry) string { return r.id() + "/" + e.ref.Value }
|
||||
|
||||
// snapshotSizes is what each snapshot owns on the datastore.
|
||||
//
|
||||
// The file layout is asked for only for the machines that have snapshots, and
|
||||
// for all of them at once per server: it lists every file of every machine and
|
||||
// is far too much to carry through the ordinary sweep.
|
||||
func snapshotSizes(rows []vmRow) map[string]int64 {
|
||||
bySession := map[*session][]types.ManagedObjectReference{}
|
||||
rowOf := map[string]vmRow{}
|
||||
for _, r := range rows {
|
||||
if r.sess == nil {
|
||||
continue
|
||||
}
|
||||
bySession[r.sess] = append(bySession[r.sess], r.ref)
|
||||
rowOf[r.sess.vc.Name+"/"+r.ref.Value] = r
|
||||
}
|
||||
|
||||
out := map[string]int64{}
|
||||
for s, refs := range bySession {
|
||||
var vms []mo.VirtualMachine
|
||||
if err := s.objects(refs, []string{"layoutEx"}, &vms); err != nil {
|
||||
continue // the report is worth having without the sizes
|
||||
}
|
||||
for _, vm := range vms {
|
||||
r, ok := rowOf[s.vc.Name+"/"+vm.Reference().Value]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for ref, size := range snapshotBytes(vm.LayoutEx) {
|
||||
out[r.id()+"/"+ref.Value] = size
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshotBytes is the size of each snapshot in one machine's file layout.
|
||||
//
|
||||
// A snapshot owns its state file — the .vmsn, with the memory in it — and the
|
||||
// *last* link of each of its disk chains. The links in front of that one are
|
||||
// the disks its ancestors froze, and the delta the machine is writing to right
|
||||
// now belongs to no snapshot at all: it is in the machine's own chain, not in
|
||||
// any snapshot's. Counting only the last link is therefore both the whole of
|
||||
// what removing this snapshot would give back and free of double counting,
|
||||
// which summing whole chains is not.
|
||||
func snapshotBytes(layout *types.VirtualMachineFileLayoutEx) map[types.ManagedObjectReference]int64 {
|
||||
if layout == nil {
|
||||
return nil
|
||||
}
|
||||
size := make(map[int32]int64, len(layout.File))
|
||||
for _, f := range layout.File {
|
||||
size[f.Key] = f.Size
|
||||
}
|
||||
|
||||
out := make(map[types.ManagedObjectReference]int64, len(layout.Snapshot))
|
||||
for _, sl := range layout.Snapshot {
|
||||
total := size[sl.DataKey]
|
||||
if sl.MemoryKey >= 0 && sl.MemoryKey != sl.DataKey {
|
||||
total += size[sl.MemoryKey]
|
||||
}
|
||||
for _, d := range sl.Disk {
|
||||
if len(d.Chain) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, key := range d.Chain[len(d.Chain)-1].FileKey {
|
||||
total += size[key]
|
||||
}
|
||||
}
|
||||
out[sl.Key] = total
|
||||
}
|
||||
return out
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// snapRef is a snapshot's reference, as the file layout keys its entries by.
|
||||
func snapRef(v string) types.ManagedObjectReference {
|
||||
return types.ManagedObjectReference{Type: "VirtualMachineSnapshot", Value: v}
|
||||
}
|
||||
|
||||
// The size of a snapshot is what removing it would give back: its own state
|
||||
// file and the last link of each of its disk chains. The links in front of that
|
||||
// belong to its ancestors, and counting whole chains — which is the obvious
|
||||
// thing to do — reports the same delta once per descendant.
|
||||
func TestSnapshotBytesCountsEachDeltaOnce(t *testing.T) {
|
||||
// Two snapshots in a line. base froze file 10; after-patch froze file 11.
|
||||
// Each has a state file of its own (1 and 2), and the machine is writing to
|
||||
// file 12, which belongs to neither.
|
||||
layout := &types.VirtualMachineFileLayoutEx{
|
||||
File: []types.VirtualMachineFileLayoutExFileInfo{
|
||||
{Key: 1, Type: "snapshotData", Size: 100},
|
||||
{Key: 2, Type: "snapshotData", Size: 200},
|
||||
{Key: 10, Type: "diskExtent", Size: 1000},
|
||||
{Key: 11, Type: "diskExtent", Size: 2000},
|
||||
{Key: 12, Type: "diskExtent", Size: 4000}, // the running delta
|
||||
},
|
||||
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
|
||||
{
|
||||
Key: snapRef("snapshot-1"), DataKey: 1, MemoryKey: -1,
|
||||
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
|
||||
{FileKey: []int32{10}},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
Key: snapRef("snapshot-2"), DataKey: 2, MemoryKey: -1,
|
||||
Disk: []types.VirtualMachineFileLayoutExDiskLayout{{Chain: []types.VirtualMachineFileLayoutExDiskUnit{
|
||||
{FileKey: []int32{10}}, {FileKey: []int32{11}},
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := snapshotBytes(layout)
|
||||
if got[snapRef("snapshot-1")] != 1100 {
|
||||
t.Errorf("the first snapshot is %d bytes, want 1100", got[snapRef("snapshot-1")])
|
||||
}
|
||||
if got[snapRef("snapshot-2")] != 2200 {
|
||||
t.Errorf("the second snapshot is %d bytes, want 2200 — its parent's delta was counted again",
|
||||
got[snapRef("snapshot-2")])
|
||||
}
|
||||
}
|
||||
|
||||
// A separate memory file is part of the snapshot; a memoryKey of -1 means there
|
||||
// is not one, and the key that says so must not be looked up as a file.
|
||||
func TestSnapshotBytesTakesTheMemoryFile(t *testing.T) {
|
||||
layout := &types.VirtualMachineFileLayoutEx{
|
||||
File: []types.VirtualMachineFileLayoutExFileInfo{
|
||||
{Key: 1, Size: 100}, {Key: 3, Size: 8000},
|
||||
},
|
||||
Snapshot: []types.VirtualMachineFileLayoutExSnapshotLayout{
|
||||
{Key: snapRef("s"), DataKey: 1, MemoryKey: 3},
|
||||
},
|
||||
}
|
||||
if got := snapshotBytes(layout)[snapRef("s")]; got != 8100 {
|
||||
t.Errorf("with a memory file the snapshot is %d bytes, want 8100", got)
|
||||
}
|
||||
|
||||
layout.Snapshot[0].MemoryKey = -1
|
||||
if got := snapshotBytes(layout)[snapRef("s")]; got != 100 {
|
||||
t.Errorf("without one it is %d bytes, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotBytesOfNothing(t *testing.T) {
|
||||
if got := snapshotBytes(nil); got != nil {
|
||||
t.Errorf("a machine with no file layout reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nought bytes and "the layout could not be read" are different answers, and
|
||||
// the second must not look like a snapshot that costs nothing.
|
||||
func TestSizeCellSaysWhenItDoesNotKnow(t *testing.T) {
|
||||
if got := sizeCell(0); got.text != "-" || got.col != colOff {
|
||||
t.Errorf("an unknown size is shown as %q", got.text)
|
||||
}
|
||||
if got := sizeCell(1 << 30); !strings.Contains(got.text, "GB") {
|
||||
t.Errorf("a gigabyte is shown as %q", got.text)
|
||||
}
|
||||
}
|
||||
|
||||
// The report's ages take the table's colours, so a red count in the list and a
|
||||
// red line in the mail mean the same thing.
|
||||
func TestAgeColorMatchesTheTable(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
days int
|
||||
want string
|
||||
}{
|
||||
{0, colSize}, {snapStaleDays - 1, colSize},
|
||||
{snapStaleDays, colBusy}, {snapOldDays - 1, colBusy},
|
||||
{snapOldDays, colFull}, {365, colFull},
|
||||
} {
|
||||
if got := ageColor(c.days); got != c.want {
|
||||
t.Errorf("a snapshot of %d days is coloured wrongly", c.days)
|
||||
}
|
||||
}
|
||||
|
||||
// And the same thresholds the column uses.
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.snaps = []snapEntry{aged("s", snapOldDays+1)}
|
||||
if r.snapColor() != colFull {
|
||||
t.Error("the column and the report disagree about an old snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// A report with nothing in it says so in the same breath as saying what it
|
||||
// looked for: "no old snapshots" without the age is not an answer.
|
||||
//
|
||||
// And it says it only where somebody is reading. The report runs weekly out of
|
||||
// cron, and cron mails whatever is printed: a weekly "nothing to clean up"
|
||||
// teaches everybody to filter the report away, and then the week it has
|
||||
// something to say is filtered away with it.
|
||||
func TestAnEmptyReportSaysWhatItLookedForOnlyOnATerminal(t *testing.T) {
|
||||
var body string
|
||||
out := captureStdout(t, func() { body = snapOldTable(nil, 30, 212) })
|
||||
if out != "" {
|
||||
t.Errorf("an empty report printed %q into a pipe", out)
|
||||
}
|
||||
if !strings.Contains(body, "212") || !strings.Contains(body, "30 days") {
|
||||
t.Errorf("the empty report reads %q", body)
|
||||
}
|
||||
|
||||
onATerminal(t)
|
||||
out = captureStdout(t, func() { snapOldTable(nil, 30, 212) })
|
||||
if !strings.Contains(stripEscapes(out), "no snapshot") {
|
||||
t.Errorf("on a terminal the empty report printed %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// onATerminal makes the colour library — which is also gvm's answer to "is
|
||||
// anybody reading this" — say yes for the length of one test.
|
||||
func onATerminal(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := color.NoColor
|
||||
color.NoColor = false
|
||||
t.Cleanup(func() { color.NoColor = saved })
|
||||
}
|
||||
|
||||
// The report counts machines, not snapshots: three snapshots of one machine is
|
||||
// one machine's worth of work.
|
||||
func TestCountMachines(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
other := testRow("db01", true, "10.0.0.6")
|
||||
other.ref = types.ManagedObjectReference{Value: "vm-43"}
|
||||
|
||||
old := []oldSnap{
|
||||
{row: r, snap: aged("a", 40)},
|
||||
{row: r, snap: aged("b", 50)},
|
||||
{row: other, snap: aged("c", 60)},
|
||||
}
|
||||
if got := countMachines(old); got != 2 {
|
||||
t.Errorf("three snapshots on two machines counted as %d machines", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mail carries the same table as the screen, and carries no escape
|
||||
// sequences: a mail client shows those as four stray characters per colour.
|
||||
func TestTheMailedReportHasNoColours(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
old := []oldSnap{{row: r, snap: aged("before-patch", 63), bytes: 3 << 30}}
|
||||
|
||||
var body string
|
||||
out := captureStdout(t, func() { body = snapOldTable(old, 30, 1) })
|
||||
|
||||
for _, want := range []string{"web01", "before-patch", "63d"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the mail leaves out %q:\n%s", want, body)
|
||||
}
|
||||
if !strings.Contains(stripEscapes(out), want) {
|
||||
t.Errorf("the screen leaves out %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune(body, 0x1b) {
|
||||
t.Errorf("the mail carries escape sequences:\n%q", body)
|
||||
}
|
||||
}
|
||||
+44
-8
@@ -11,6 +11,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/vim25/methods"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
@@ -26,12 +28,32 @@ type snapEntry struct {
|
||||
ref types.ManagedObjectReference
|
||||
name string
|
||||
desc string
|
||||
created string
|
||||
created string // when it was taken, as it is shown
|
||||
when time.Time // and as it is compared: an age is not a string
|
||||
depth int
|
||||
prefix string // the branch drawn in front of the name
|
||||
current bool // the state the machine is running from
|
||||
}
|
||||
|
||||
// age is how long ago the snapshot was taken. A snapshot whose date did not
|
||||
// come back has no age rather than an age of nothing: zero would read as
|
||||
// "taken just now", which is the opposite of what an absent date means.
|
||||
func (e snapEntry) age() (time.Duration, bool) {
|
||||
if e.when.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
return time.Since(e.when), true
|
||||
}
|
||||
|
||||
// days is the age in whole days, for the reports that count in them.
|
||||
func (e snapEntry) days() int {
|
||||
d, ok := e.age()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(d.Hours() / 24)
|
||||
}
|
||||
|
||||
// line is the entry as it is shown: its branch, its name, when it was taken, and
|
||||
// a mark when it is the one the machine is running from.
|
||||
func (e snapEntry) line() string {
|
||||
@@ -75,6 +97,7 @@ func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.Ma
|
||||
name: n.Name,
|
||||
desc: n.Description,
|
||||
created: n.CreateTime.Local().Format("02.01.2006 15:04"),
|
||||
when: n.CreateTime,
|
||||
depth: depth,
|
||||
prefix: prefix + branch,
|
||||
current: n.Snapshot == current,
|
||||
@@ -88,6 +111,12 @@ func flattenSnapshots(roots []types.VirtualMachineSnapshotTree, current types.Ma
|
||||
|
||||
// snapshotsOf reads the machine's snapshots as a flat list, parents before their
|
||||
// children. Empty when it has none.
|
||||
//
|
||||
// The sweep brings the same tree back for every machine at once (browse.go), and
|
||||
// this asks for one machine's again. That is deliberate: everything that acts on
|
||||
// a snapshot addresses it by reference, and a reference out of a sweep that ran
|
||||
// minutes ago may name a snapshot somebody has since removed. The table may be a
|
||||
// few minutes old; the list one is about to revert to may not be.
|
||||
func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, error) {
|
||||
vm := object.NewVirtualMachine(s.client.Client, ref)
|
||||
|
||||
@@ -95,15 +124,22 @@ func snapshotsOf(s *session, ref types.ManagedObjectReference) ([]snapEntry, err
|
||||
if err := vm.Properties(s.ctx, ref, []string{"snapshot"}, &mvm); err != nil {
|
||||
return nil, errf("%s: cannot read the snapshots: %w", s.vc.Name, err)
|
||||
}
|
||||
if mvm.Snapshot == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return snapshotsIn(mvm.Snapshot), nil
|
||||
}
|
||||
|
||||
current := types.ManagedObjectReference{}
|
||||
if mvm.Snapshot.CurrentSnapshot != nil {
|
||||
current = *mvm.Snapshot.CurrentSnapshot
|
||||
// snapshotsIn is the tree as it comes out of the property collector, flattened.
|
||||
// One function for both ways of getting there — the sheet asking for one machine
|
||||
// and the sweep bringing back every machine — so the two cannot disagree about
|
||||
// what a machine's snapshots are.
|
||||
func snapshotsIn(info *types.VirtualMachineSnapshotInfo) []snapEntry {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return flattenSnapshots(mvm.Snapshot.RootSnapshotList, current), nil
|
||||
current := types.ManagedObjectReference{}
|
||||
if info.CurrentSnapshot != nil {
|
||||
current = *info.CurrentSnapshot
|
||||
}
|
||||
return flattenSnapshots(info.RootSnapshotList, current)
|
||||
}
|
||||
|
||||
// revertToSnapshot puts the machine back to the exact snapshot given. Everything
|
||||
|
||||
@@ -21,10 +21,17 @@ type sortOrder struct {
|
||||
name string // what it is called, in the title and the legend
|
||||
natural bool // its own direction: true means largest or busiest first
|
||||
cmp func(a, b vmRow) int
|
||||
|
||||
// legendBreak starts a new line of the legend at this entry. Thirteen
|
||||
// orders do not fit across eighty columns, and a legend that ran off the
|
||||
// edge would hide the very choices it exists to offer — so it is two lines,
|
||||
// broken where the meaning breaks rather than wherever the width runs out.
|
||||
legendBreak bool
|
||||
}
|
||||
|
||||
// sortOrders in the order the legend lists them: the two that identify a machine
|
||||
// first, then what it is doing, then what it is made of, then where it lives.
|
||||
// sortOrders in the order the legend lists them, which is two groups: first
|
||||
// what a machine is doing and what it wants doing to it, then what it is made
|
||||
// of and where it lives. The legend breaks between the two.
|
||||
var sortOrders = []sortOrder{
|
||||
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
|
||||
{key: 'p', name: "power", natural: true,
|
||||
@@ -33,7 +40,29 @@ var sortOrders = []sortOrder{
|
||||
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
|
||||
{key: 'm', name: "memory in use", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
|
||||
{key: 's', name: "memory size", natural: true,
|
||||
// How many rollback points the machine is carrying, most first. Nought is a
|
||||
// figure here and not a missing one — nothing to clean up is a fact about
|
||||
// the machine — so a machine with none sorts where nought belongs, at the
|
||||
// bottom going down and at the top coming back up.
|
||||
//
|
||||
// The key carries no mnemonic — every letter that does was taken — so it is
|
||||
// simply one that is free and easy to reach. The name is what the command
|
||||
// line takes: `--sort snapshots`, or `--sort snaps`.
|
||||
{key: 'z', name: "snapshots", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
|
||||
// By how long the machine has been dragging its oldest snapshot along, the
|
||||
// oldest first — which is the order the housekeeping is done in. A machine
|
||||
// with no snapshots has no age, and sorts to the bottom either way round.
|
||||
{key: 'o', name: "snapshot age", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
|
||||
// By what is wrong with the machine, worst first: broken above wants-a-look
|
||||
// above nothing to report, and within each the machine with the most to
|
||||
// answer for first. Sorting the reasons as text would put "alarm" above
|
||||
// "disks need consolidating" and mean nothing at all.
|
||||
{key: 'w', name: "issues", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpIssues(a, b) }},
|
||||
|
||||
{key: 's', name: "memory size", natural: true, legendBreak: true,
|
||||
cmp: func(a, b vmRow) int {
|
||||
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
|
||||
}},
|
||||
@@ -105,6 +134,30 @@ func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// issueRank is how bad the machine's worst reason is: two for something broken,
|
||||
// one for something that wants a look, nought for nothing to report.
|
||||
func issueRank(r vmRow) int {
|
||||
rank := 0
|
||||
for _, i := range r.issueList() {
|
||||
if i.bad {
|
||||
return 2
|
||||
}
|
||||
rank = 1
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// cmpIssues orders by that, and within it by how many reasons there are: a
|
||||
// machine with a full disk *and* no Tools is worse off than one with only the
|
||||
// disk. Nothing to report is nought and sorts where nought belongs, so the
|
||||
// order run the other way up is the machines that are fine, by name.
|
||||
func cmpIssues(a, b vmRow) int {
|
||||
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
|
||||
return n
|
||||
}
|
||||
return cmpInt(len(a.issueList()), len(b.issueList()))
|
||||
}
|
||||
|
||||
// cmpAddress orders by address, unknown highest — which puts it last under the
|
||||
// a-to-z direction this order is asked for with.
|
||||
func cmpAddress(a, b vmRow) int {
|
||||
@@ -180,22 +233,55 @@ func (b *browser) sortLabel() string {
|
||||
return arrow + " " + b.order().name
|
||||
}
|
||||
|
||||
// sortLegend is the one line offering the choices. Short by necessity — it shares
|
||||
// the status line — and the title says what the order is anyway, so nobody who
|
||||
// misses it is lost.
|
||||
func sortLegend() string {
|
||||
parts := make([]string, 0, len(sortOrders)+1)
|
||||
// sortLegend is the choices, laid out for a terminal of this width: one line
|
||||
// where they fit on one, and otherwise the two groups they fall into — what the
|
||||
// machine is doing and wants doing to it, then what it is made of and where it
|
||||
// lives.
|
||||
//
|
||||
// One line is the better answer and the usual one; two is what a narrow
|
||||
// terminal gets instead of a legend that runs off the right-hand edge, hiding
|
||||
// the very choices it exists to offer. Decided here, at render time, so a
|
||||
// window that is dragged wider gets the one line back — the same way the table
|
||||
// itself is fitted (fitColumns) and the sheet is wrapped.
|
||||
//
|
||||
// Terse either way: it shares the bottom of the screen with nothing but itself,
|
||||
// and the title says what the order is anyway, so nobody who misses it is lost.
|
||||
func sortLegend(cols int) []string {
|
||||
const label = "sort: "
|
||||
|
||||
entries := make([]string, 0, len(sortOrders)+1)
|
||||
for _, o := range sortOrders {
|
||||
parts = append(parts, string(o.key)+"·"+shortName(o.name))
|
||||
entries = append(entries, string(o.key)+"·"+shortName(o.name))
|
||||
}
|
||||
// Reverse is not an order of its own and goes at the end.
|
||||
entries = append(entries, string(sortReverse)+"·reverse")
|
||||
|
||||
if one := label + strings.Join(entries, " "); len([]rune(one)) <= cols {
|
||||
return []string{one}
|
||||
}
|
||||
|
||||
// Two, broken where the meaning breaks. The second line is indented under
|
||||
// the first one's entries rather than under its label, so the two read as
|
||||
// one list and not as a sentence continued.
|
||||
at := len(sortOrders)
|
||||
for i, o := range sortOrders {
|
||||
if o.legendBreak {
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
label + strings.Join(entries[:at], " "),
|
||||
SR(" ", len(label)) + strings.Join(entries[at:], " "),
|
||||
}
|
||||
parts = append(parts, string(sortReverse)+"·reverse")
|
||||
return "sort: " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// shortName is the legend's spelling: the title has room for the whole name, one
|
||||
// line shared with the status does not.
|
||||
func shortName(name string) string {
|
||||
switch name {
|
||||
case "power":
|
||||
return "pwr" // as the column is headed, and it keeps the legend inside 80
|
||||
case "cpu load":
|
||||
return "cpu%"
|
||||
case "memory in use":
|
||||
@@ -208,6 +294,12 @@ func shortName(name string) string {
|
||||
return "vc"
|
||||
case "address":
|
||||
return "ip"
|
||||
case "snapshot age":
|
||||
return "old"
|
||||
case "snapshots":
|
||||
return "snaps"
|
||||
case "issues":
|
||||
return "why" // as the column is headed
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -216,7 +308,17 @@ func shortName(name string) string {
|
||||
// choice leaves the order alone: this is the one prompt in the list that is
|
||||
// reached by accident, and doing nothing is the right answer to a stray key.
|
||||
func (b *browser) sortPrompt() {
|
||||
b.prompt = &prompt{text: sortLegend(), col: colValue} // a menu, not a warning
|
||||
// The colour every question at the foot of the screen has (colPrompt), and
|
||||
// no yes/no hint: this is a menu and not a question answerable with y, but
|
||||
// it is still gvm waiting for a key, and that is one thing wearing one
|
||||
// colour. Where it takes two lines the second goes in place of the help
|
||||
// line, which says nothing that applies while a menu is up.
|
||||
cols, _ := termSize()
|
||||
lines := sortLegend(cols)
|
||||
b.prompt = &prompt{text: lines[0], col: colPrompt}
|
||||
if len(lines) > 1 {
|
||||
b.prompt.more = strings.Join(lines[1:], " ")
|
||||
}
|
||||
b.render()
|
||||
k := b.keys.next()
|
||||
b.prompt = nil
|
||||
@@ -263,6 +365,10 @@ func (b *browser) sortedColumn(header string) bool {
|
||||
return header == "HOST"
|
||||
case "address":
|
||||
return header == "IP"
|
||||
case "snapshot age", "snapshots":
|
||||
return header == "SNAP"
|
||||
case "issues":
|
||||
return header == "WHY"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+149
-6
@@ -204,8 +204,11 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
|
||||
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
|
||||
}
|
||||
|
||||
// In the table the order belongs to: sorting by what is wrong with a
|
||||
// machine lights the reason column, which only the issues listing has.
|
||||
table := listColumns(b.rows, o.name == "issues")
|
||||
lit := 0
|
||||
for _, c := range browseColumns {
|
||||
for _, c := range table {
|
||||
if b.sortedColumn(c.header) {
|
||||
lit++
|
||||
}
|
||||
@@ -218,17 +221,59 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
|
||||
|
||||
// Every order is offered, and the legend fits a terminal of eighty.
|
||||
func TestSortLegend(t *testing.T) {
|
||||
legend := sortLegend()
|
||||
lines := sortLegend(80)
|
||||
legend := strings.Join(lines, "\n")
|
||||
for _, o := range sortOrders {
|
||||
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
|
||||
t.Errorf("the legend does not offer %q for %s: %s", string(o.key), o.name, legend)
|
||||
t.Errorf("the legend does not offer %q for %s:\n%s", string(o.key), o.name, legend)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
|
||||
t.Errorf("the legend does not offer the reverse: %s", legend)
|
||||
t.Errorf("the legend does not offer the reverse:\n%s", legend)
|
||||
}
|
||||
if n := len([]rune(legend)); n > 78 { // a terminal of eighty, less the gutter
|
||||
t.Errorf("the legend is %d columns wide: %s", n, legend)
|
||||
|
||||
// Every line of it fits a terminal of eighty. The legend has the bottom two
|
||||
// rows to itself, and they begin at the left edge rather than behind the
|
||||
// pointer's gutter, so the budget is eighty whole — but a line over it would
|
||||
// be truncated, and the choices it hid would be unreachable in the only
|
||||
// place they are offered.
|
||||
for i, line := range lines {
|
||||
if n := len([]rune(line)); n > 80 {
|
||||
t.Errorf("legend line %d is %d columns wide: %s", i+1, n, line)
|
||||
}
|
||||
}
|
||||
// Two rows, and not three: there are only two to spare.
|
||||
if len(lines) > 2 {
|
||||
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
|
||||
}
|
||||
|
||||
// One line wherever one line will do — which is every terminal wide enough
|
||||
// for it, and the usual case. A legend on two lines is what a narrow
|
||||
// terminal gets instead of one that runs off the edge.
|
||||
wide := sortLegend(200)
|
||||
if len(wide) != 1 {
|
||||
t.Errorf("a wide terminal gets the legend on %d lines:\n%s", len(wide), strings.Join(wide, "\n"))
|
||||
}
|
||||
if n := len([]rune(wide[0])); n > 200 {
|
||||
t.Errorf("the one-line legend is %d columns wide", n)
|
||||
}
|
||||
// And every choice is on it, so nothing is reachable only when the terminal
|
||||
// happens to be narrow.
|
||||
for _, o := range sortOrders {
|
||||
if !strings.Contains(wide[0], string(o.key)+"·"+shortName(o.name)) {
|
||||
t.Errorf("the one-line legend does not offer %q for %s: %s", string(o.key), o.name, wide[0])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(wide[0], string(sortReverse)+"·reverse") {
|
||||
t.Errorf("the one-line legend does not offer the reverse: %s", wide[0])
|
||||
}
|
||||
// The width at which it gives up on one line is the width of the legend
|
||||
// itself, and not a number written down somewhere.
|
||||
if got := sortLegend(len([]rune(wide[0]))); len(got) != 1 {
|
||||
t.Error("the legend broke in two at exactly its own width")
|
||||
}
|
||||
if got := sortLegend(len([]rune(wide[0])) - 1); len(got) != 2 {
|
||||
t.Error("the legend stayed on one line one column too narrow for it")
|
||||
}
|
||||
|
||||
// Distinct letters, or one of them would be unreachable.
|
||||
@@ -285,3 +330,101 @@ func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// By how many snapshots a machine is carrying. Nought is a figure here and not
|
||||
// a missing one — nothing to clean up is a fact about the machine — so it sorts
|
||||
// where nought belongs: at the bottom going down, at the top coming back up.
|
||||
func TestSortBySnapshotCount(t *testing.T) {
|
||||
rows := []vmRow{
|
||||
sortRow("none", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
|
||||
sortRow("three", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
|
||||
sortRow("one", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
|
||||
}
|
||||
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
|
||||
rows[2].snaps = []snapEntry{aged("a", 1)}
|
||||
|
||||
if got := orderOf(t, rows, 'z', true); got != "three one none" {
|
||||
t.Errorf("most snapshots first gave %q", got)
|
||||
}
|
||||
if got := orderOf(t, rows, 'z', false); got != "none one three" {
|
||||
t.Errorf("fewest first gave %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// By what is wrong with the machine: broken above wants-a-look above nothing to
|
||||
// report, and within each the machine with the most to answer for first.
|
||||
func TestSortByIssues(t *testing.T) {
|
||||
rows := []vmRow{
|
||||
sortRow("fine", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
|
||||
sortRow("warned", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
|
||||
sortRow("broken", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
|
||||
sortRow("worse", "v308", "esx1", "10.0.0.4", 1, 1024, 0, 0, true),
|
||||
}
|
||||
// sortRow builds machines with no guest information at all, which reports
|
||||
// nothing: the issues that are only true of a running machine need a guest
|
||||
// to be true of. So each is given exactly what it is named for.
|
||||
for i := range rows {
|
||||
rows[i].vm.Guest = &types.GuestInfo{
|
||||
ToolsRunningStatus: "guestToolsRunning",
|
||||
IpAddress: rows[i].ip(),
|
||||
}
|
||||
}
|
||||
rows[1].vm.Summary.OverallStatus = types.ManagedEntityStatusYellow // one warning
|
||||
rows[2].vm.Summary.Runtime.ConsolidationNeeded = true // one breakage
|
||||
rows[3].vm.Summary.Runtime.ConsolidationNeeded = true // and the same
|
||||
rows[3].vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // plus a warning
|
||||
|
||||
if got := orderOf(t, rows, 'w', true); got != "worse broken warned fine" {
|
||||
t.Errorf("worst first gave %q", got)
|
||||
}
|
||||
// And the other way up, the machines with nothing wrong come first, which
|
||||
// is a listing worth having too.
|
||||
if got := orderOf(t, rows, 'w', false); got != "fine warned broken worse" {
|
||||
t.Errorf("nothing to report first gave %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// orderOf sorts the rows given by one order and returns the names in order.
|
||||
//
|
||||
// A key that is not an order at all is fatal here rather than left to sort by
|
||||
// name: the browser's default order is index nought, so a test naming a letter
|
||||
// that has been renamed would go on passing while checking the name order.
|
||||
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
|
||||
t.Helper()
|
||||
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
|
||||
found := false
|
||||
for i, o := range sortOrders {
|
||||
if o.key == key {
|
||||
b.sortBy, found = i, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%q is not one of the sort orders", string(key))
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
var names []string
|
||||
for _, r := range b.rows {
|
||||
names = append(names, r.name)
|
||||
}
|
||||
return strings.Join(names, " ")
|
||||
}
|
||||
|
||||
// The two snapshot orders are different questions: how many, and how old. A
|
||||
// machine with one snapshot from March needs attention before one with six
|
||||
// from this morning.
|
||||
func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
|
||||
rows := []vmRow{
|
||||
sortRow("many-new", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
|
||||
sortRow("one-ancient", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
|
||||
}
|
||||
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
|
||||
rows[1].snaps = []snapEntry{aged("march", 200)}
|
||||
|
||||
if got := orderOf(t, rows, 'z', true); got != "many-new one-ancient" {
|
||||
t.Errorf("by count: %q", got)
|
||||
}
|
||||
if got := orderOf(t, rows, 'o', true); got != "one-ancient many-new" {
|
||||
t.Errorf("by age: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// tasks.go — what a machine is in the middle of.
|
||||
//
|
||||
// A machine being cloned, migrated or consolidated looks in the table exactly
|
||||
// like one that is idle, and that is the one moment when the table is wrong
|
||||
// about the most important thing on the line: why the machine is slow, why its
|
||||
// disk is growing, why it must be left alone. vCenter keeps the answer on the
|
||||
// machine itself, in recentTask, so the sweep picks it up along the way.
|
||||
//
|
||||
// "Recent" is vCenter's word, not gvm's: a task stays on that list for minutes
|
||||
// after it has finished. Only the ones that are still going on are shown — a
|
||||
// finished task is history, and the event log is where history belongs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// runningTask is one thing vCenter is doing to a machine right now.
|
||||
type runningTask struct {
|
||||
what string // the operation, in one word
|
||||
queued bool // accepted but not started yet
|
||||
progress int32
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// taskVerbs are the operations worth naming in the eight characters the column
|
||||
// has left once a percentage is beside them — "pwr off" for the same reason the
|
||||
// sort legend says pwr. The key is
|
||||
// vSphere's descriptionId, which is the same word on every vCenter — the
|
||||
// task's own Description is localised, so a German vCenter would put German
|
||||
// into an English table.
|
||||
//
|
||||
// Anything not listed keeps its method name, which is still the truth and still
|
||||
// tells an operator to leave the machine alone.
|
||||
var taskVerbs = map[string]string{
|
||||
"createSnapshot": "snapshot",
|
||||
"removeSnapshot": "rm snap",
|
||||
"removeAllSnapshots": "rm snaps",
|
||||
"revertToSnapshot": "revert",
|
||||
"consolidateDisks": "consolid",
|
||||
"promoteDisks": "consolid",
|
||||
"clone": "clone",
|
||||
"relocate": "migrate",
|
||||
"migrate": "migrate",
|
||||
"reconfigure": "reconfig",
|
||||
"powerOn": "pwr on",
|
||||
"powerOff": "pwr off",
|
||||
"suspend": "suspend",
|
||||
"reset": "reset",
|
||||
"shutdownGuest": "shutdown",
|
||||
"rebootGuest": "reboot",
|
||||
"destroy": "delete",
|
||||
"customize": "custom",
|
||||
"createDisk": "disk",
|
||||
"extendDisk": "disk",
|
||||
"upgradeTools": "tools",
|
||||
"upgradeVirtualHardware": "hardware",
|
||||
}
|
||||
|
||||
// taskVerb is the operation in one word. A descriptionId reads
|
||||
// "VirtualMachine.createSnapshot"; the kind in front of the dot is already the
|
||||
// row the task is on, so only what follows it says anything.
|
||||
func taskVerb(descriptionID string) string {
|
||||
method := descriptionID
|
||||
if i := strings.LastIndexByte(method, '.'); i >= 0 {
|
||||
method = method[i+1:]
|
||||
}
|
||||
if v, ok := taskVerbs[method]; ok {
|
||||
return v
|
||||
}
|
||||
if method == "" {
|
||||
return "busy"
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
// cell is the task as the table shows it: what it is and how far it has got.
|
||||
// A percentage is only shown once there is one — vCenter reports 0 both for
|
||||
// "just started" and for "no idea", and a task sitting at 0 % looks stuck when
|
||||
// it is merely young.
|
||||
func (t runningTask) cell() string {
|
||||
if t.queued {
|
||||
return t.what + " q"
|
||||
}
|
||||
if t.progress > 0 {
|
||||
return SF("%s %d%%", t.what, t.progress)
|
||||
}
|
||||
return t.what
|
||||
}
|
||||
|
||||
// line is the task on the machine's sheet, where there is room to say when it
|
||||
// started and that queued means nothing has happened yet.
|
||||
func (t runningTask) line() string {
|
||||
parts := []string{t.what}
|
||||
switch {
|
||||
case t.queued:
|
||||
parts = append(parts, "queued, not started")
|
||||
case t.progress > 0:
|
||||
parts = append(parts, SF("%d %%", t.progress))
|
||||
}
|
||||
if !t.since.IsZero() {
|
||||
parts = append(parts, "since "+t.since.Local().Format("15:04:05"))
|
||||
}
|
||||
return join(parts)
|
||||
}
|
||||
|
||||
// runningTasks maps the machines of one sweep to what is being done to them.
|
||||
// The task references come off the machines themselves and are read in one
|
||||
// call for the whole inventory, so this costs one round trip per vCenter no
|
||||
// matter how much is going on.
|
||||
//
|
||||
// A machine with two tasks at once keeps the one that has started; of two
|
||||
// running ones, the first. There is one column, and "something is going on" is
|
||||
// what it has to say.
|
||||
func runningTasks(s *session, vms []mo.VirtualMachine) map[types.ManagedObjectReference]runningTask {
|
||||
var refs []types.ManagedObjectReference
|
||||
for _, vm := range vms {
|
||||
refs = append(refs, vm.RecentTask...)
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks, err := s.tasks(refs)
|
||||
if err != nil {
|
||||
return nil // the table is worth having without it
|
||||
}
|
||||
byRef := make(map[types.ManagedObjectReference]runningTask, len(tasks))
|
||||
for _, t := range tasks {
|
||||
rt, ok := taskOf(t.Info)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
byRef[t.Reference()] = rt
|
||||
}
|
||||
|
||||
out := make(map[types.ManagedObjectReference]runningTask, len(vms))
|
||||
for _, vm := range vms {
|
||||
for _, ref := range vm.RecentTask {
|
||||
rt, ok := byRef[ref]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cur, seen := out[vm.Reference()]; seen && (!cur.queued || rt.queued) {
|
||||
continue
|
||||
}
|
||||
out[vm.Reference()] = rt
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// taskOf is one task, when it is still going on.
|
||||
func taskOf(info types.TaskInfo) (runningTask, bool) {
|
||||
switch info.State {
|
||||
case types.TaskInfoStateRunning:
|
||||
since := info.QueueTime
|
||||
if info.StartTime != nil {
|
||||
since = *info.StartTime
|
||||
}
|
||||
return runningTask{what: taskVerb(info.DescriptionId), progress: info.Progress, since: since}, true
|
||||
case types.TaskInfoStateQueued:
|
||||
return runningTask{what: taskVerb(info.DescriptionId), queued: true, since: info.QueueTime}, true
|
||||
}
|
||||
return runningTask{}, false
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The operation is named from vSphere's descriptionId and not from the task's
|
||||
// own Description, which vCenter localises: a German vCenter would otherwise
|
||||
// put German words in an English table.
|
||||
func TestTaskVerb(t *testing.T) {
|
||||
for _, c := range []struct{ id, want string }{
|
||||
{"VirtualMachine.createSnapshot", "snapshot"},
|
||||
{"VirtualMachine.removeAllSnapshots", "rm snaps"},
|
||||
{"VirtualMachine.relocate", "migrate"},
|
||||
{"VirtualMachine.reconfigure", "reconfig"},
|
||||
{"Datacenter.somethingNobodyHasHeardOf", "somethingNobodyHasHeardOf"},
|
||||
{"noDotAtAll", "noDotAtAll"},
|
||||
{"", "busy"},
|
||||
} {
|
||||
if got := taskVerb(c.id); got != c.want {
|
||||
t.Errorf("taskVerb(%q) = %q, want %q", c.id, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every verb fits the column, or the cell it is put in would be truncated with
|
||||
// the percentage — the part that says whether anything is happening — cut off.
|
||||
func TestEveryTaskVerbFitsItsColumn(t *testing.T) {
|
||||
room := taskColumn.width - len(" 100%")
|
||||
for id, verb := range taskVerbs {
|
||||
if len(verb) > room {
|
||||
t.Errorf("%s is called %q, which is %d characters of the %d there are",
|
||||
id, verb, len(verb), room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A task at nought per cent has not reported any progress, which is not the
|
||||
// same as having made none: printing 0 % makes a task that has just started
|
||||
// look stuck.
|
||||
func TestTaskCell(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
task runningTask
|
||||
want string
|
||||
}{
|
||||
{runningTask{what: "clone", progress: 40}, "clone 40%"},
|
||||
{runningTask{what: "clone"}, "clone"},
|
||||
{runningTask{what: "clone", queued: true}, "clone q"},
|
||||
{runningTask{what: "clone", queued: true, progress: 10}, "clone q"},
|
||||
} {
|
||||
if got := c.task.cell(); got != c.want {
|
||||
t.Errorf("cell() = %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskLineSpellsQueuedOut(t *testing.T) {
|
||||
since := time.Date(2026, 9, 8, 11, 42, 0, 0, time.Local)
|
||||
got := runningTask{what: "consolid", queued: true, since: since}.line()
|
||||
if got != "consolid · queued, not started · since 11:42:00" {
|
||||
t.Errorf("the sheet line reads %q", got)
|
||||
}
|
||||
got = runningTask{what: "clone", progress: 40, since: since}.line()
|
||||
if got != "clone · 40 % · since 11:42:00" {
|
||||
t.Errorf("the sheet line reads %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only what is still going on is a task. A task that has finished stays on
|
||||
// vCenter's recentTask list for minutes afterwards, and a table that showed it
|
||||
// would report a snapshot being taken long after it was taken.
|
||||
func TestOnlyRunningTasksCount(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
state types.TaskInfoState
|
||||
want bool
|
||||
}{
|
||||
{types.TaskInfoStateRunning, true},
|
||||
{types.TaskInfoStateQueued, true},
|
||||
{types.TaskInfoStateSuccess, false},
|
||||
{types.TaskInfoStateError, false},
|
||||
} {
|
||||
_, ok := taskOf(types.TaskInfo{State: c.state, DescriptionId: "VirtualMachine.clone"})
|
||||
if ok != c.want {
|
||||
t.Errorf("a %s task counts = %v, want %v", c.state, ok, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A running task is timed from when it started, a queued one from when it was
|
||||
// accepted — there is nothing else to time it from.
|
||||
func TestTaskTakesItsTimeFromTheRightEnd(t *testing.T) {
|
||||
queued := time.Date(2026, 9, 8, 11, 0, 0, 0, time.UTC)
|
||||
started := time.Date(2026, 9, 8, 11, 5, 0, 0, time.UTC)
|
||||
|
||||
got, _ := taskOf(types.TaskInfo{
|
||||
State: types.TaskInfoStateRunning, QueueTime: queued, StartTime: &started,
|
||||
DescriptionId: "VirtualMachine.clone",
|
||||
})
|
||||
if !got.since.Equal(started) {
|
||||
t.Errorf("a running task is timed from %v", got.since)
|
||||
}
|
||||
|
||||
got, _ = taskOf(types.TaskInfo{
|
||||
State: types.TaskInfoStateQueued, QueueTime: queued,
|
||||
DescriptionId: "VirtualMachine.clone",
|
||||
})
|
||||
if !got.since.Equal(queued) {
|
||||
t.Errorf("a queued task is timed from %v", got.since)
|
||||
}
|
||||
}
|
||||
@@ -83,9 +83,12 @@ const (
|
||||
keyShiftTab
|
||||
keyEnter
|
||||
keyCtrlA
|
||||
keyCtrlE
|
||||
keyCtrlL
|
||||
keyCtrlO
|
||||
keyCtrlR
|
||||
keyCtrlS
|
||||
keyCtrlW
|
||||
keyCtrlC
|
||||
keyEsc
|
||||
)
|
||||
@@ -132,17 +135,53 @@ func (kr *keyReader) next() key {
|
||||
if !ok {
|
||||
return key{special: keyCtrlC} // input closed - treat like cancel
|
||||
}
|
||||
return kr.decode(b)
|
||||
}
|
||||
|
||||
// nextWithin is next() with a limit on how long it waits — for live mode, which
|
||||
// has to be able to stop waiting and re-read the list.
|
||||
//
|
||||
// The limit is on the *first* byte only, which is why it is here and not around
|
||||
// next() as a whole: a control sequence arrives in one burst, and a deadline
|
||||
// that could expire in the middle of "ESC [ A" would turn one arrow key into an
|
||||
// Esc and a stray letter in the filter.
|
||||
func (kr *keyReader) nextWithin(d time.Duration) (key, bool) {
|
||||
select {
|
||||
case b, ok := <-kr.ch:
|
||||
if !ok {
|
||||
return key{special: keyCtrlC}, true
|
||||
}
|
||||
return kr.decode(b), true
|
||||
case <-time.After(d):
|
||||
return key{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// decode turns one byte, and whatever else belongs with it, into a key.
|
||||
func (kr *keyReader) decode(b byte) key {
|
||||
switch b {
|
||||
case 0x03:
|
||||
return key{special: keyCtrlC}
|
||||
case 0x01:
|
||||
return key{special: keyCtrlA}
|
||||
case 0x05:
|
||||
return key{special: keyCtrlE}
|
||||
// ^l, which in a shell redraws the screen. Nothing is lost by taking it:
|
||||
// gvm redraws the whole screen on every keystroke anyway, so there is
|
||||
// nothing here for a redraw key to fix.
|
||||
case 0x0c:
|
||||
return key{special: keyCtrlL}
|
||||
case 0x0f:
|
||||
return key{special: keyCtrlO}
|
||||
case 0x12:
|
||||
return key{special: keyCtrlR}
|
||||
case 0x13:
|
||||
return key{special: keyCtrlS}
|
||||
// ^w, and not the ^i the mnemonic wants: Ctrl-I *is* Tab (0x09), which the
|
||||
// list already moves down with, so an issues filter bound to it would have
|
||||
// scrolled the table instead.
|
||||
case 0x17:
|
||||
return key{special: keyCtrlW}
|
||||
case 0x1b:
|
||||
return kr.readEscape()
|
||||
case '\r', '\n':
|
||||
|
||||
+64
-1
@@ -15,8 +15,10 @@ import (
|
||||
"github.com/vmware/govmomi"
|
||||
"github.com/vmware/govmomi/find"
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/property"
|
||||
"github.com/vmware/govmomi/view"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// dialTimeout bounds a login. Without one a vCenter that accepts the connection
|
||||
@@ -30,6 +32,12 @@ type session struct {
|
||||
ctx context.Context
|
||||
client *govmomi.Client
|
||||
cancel context.CancelFunc
|
||||
|
||||
// The names of the alarm definitions this server has triggered, filled in by
|
||||
// the sweep when anything is actually alarming (browse.go). It belongs to
|
||||
// the connection rather than to a machine: one alarm stands against many
|
||||
// machines, and its name is worth reading once per server, not once per row.
|
||||
alarms map[types.ManagedObjectReference]string
|
||||
}
|
||||
|
||||
// connect logs in to one server. The caller closes what comes back — a session
|
||||
@@ -122,7 +130,8 @@ func (s *session) retrieve(kind string, props []string, dst any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vms and hosts are the two inventory sweeps gvm makes.
|
||||
// vms, hosts and datastores are the inventory sweeps gvm makes: one call each,
|
||||
// for everything of that kind in the whole inventory.
|
||||
func (s *session) vms(props ...string) ([]mo.VirtualMachine, error) {
|
||||
var out []mo.VirtualMachine
|
||||
return out, s.retrieve("VirtualMachine", props, &out)
|
||||
@@ -132,3 +141,57 @@ func (s *session) hosts(props ...string) ([]mo.HostSystem, error) {
|
||||
var out []mo.HostSystem
|
||||
return out, s.retrieve("HostSystem", props, &out)
|
||||
}
|
||||
|
||||
func (s *session) datastores(props ...string) ([]mo.Datastore, error) {
|
||||
var out []mo.Datastore
|
||||
return out, s.retrieve("Datastore", props, &out)
|
||||
}
|
||||
|
||||
// instanceUUID identifies this vCenter to itself: it is the serverGuid the
|
||||
// vSphere client puts in the URLs of the objects it shows, which is the one
|
||||
// thing gvm cannot work out from the configuration alone (see vsphereURL).
|
||||
func (s *session) instanceUUID() string {
|
||||
if s.client == nil {
|
||||
return ""
|
||||
}
|
||||
return s.client.ServiceContent.About.InstanceUuid
|
||||
}
|
||||
|
||||
// objects fills dst with the named properties of exactly the objects given,
|
||||
// rather than of everything of a kind. Alarm definitions and tasks are not in
|
||||
// the inventory container view — they hang off their managers — so the only way
|
||||
// to read them is by reference, and by all of them in one call: one round trip
|
||||
// for a screenful, not one per line.
|
||||
func (s *session) objects(refs []types.ManagedObjectReference, props []string, dst any) error {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := property.DefaultCollector(s.client.Client).
|
||||
Retrieve(s.ctx, refs, props, dst); err != nil {
|
||||
return fmt.Errorf("%s: cannot read %d objects: %w", s.vc.Name, len(refs), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tasks reads what those task references are doing. A task that has finished is
|
||||
// still on a machine's recentTask list for a while afterwards, so the caller
|
||||
// decides what counts as going on; this only reports.
|
||||
func (s *session) tasks(refs []types.ManagedObjectReference) ([]mo.Task, error) {
|
||||
var out []mo.Task
|
||||
return out, s.objects(refs, []string{"info"}, &out)
|
||||
}
|
||||
|
||||
// alarmNames resolves alarm definitions to the names a person gave them. A
|
||||
// triggered alarm carries only the reference of its definition, and "alarm-3 is
|
||||
// red" is not something anyone can act on.
|
||||
func (s *session) alarmNames(refs []types.ManagedObjectReference) map[types.ManagedObjectReference]string {
|
||||
var alarms []mo.Alarm
|
||||
if err := s.objects(refs, []string{"info.name"}, &alarms); err != nil {
|
||||
return nil // the names are a courtesy; the references still say which
|
||||
}
|
||||
out := make(map[types.ManagedObjectReference]string, len(alarms))
|
||||
for _, a := range alarms {
|
||||
out[a.Reference()] = a.Info.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.21
|
||||
1.2.0
|
||||
|
||||
@@ -14,22 +14,40 @@ import (
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// lsOptions is what one printed listing was asked for. A struct rather than six
|
||||
// arguments in a row: three of them are booleans, and a call site reading
|
||||
// (rows, "", false, true, false) says nothing about which is which.
|
||||
type lsOptions struct {
|
||||
match string // regexp on the machine's name
|
||||
orderBy string // one of sortOrders, by letter or by name
|
||||
reverse bool
|
||||
issues bool // only the machines with something wrong with them
|
||||
json bool // as a document instead of a table
|
||||
}
|
||||
|
||||
// lsvm prints the machines of every server it is given. One unreachable vCenter
|
||||
// is a line of complaint, not the end of the listing.
|
||||
func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
|
||||
re, err := regexp.Compile("(?i)" + match)
|
||||
func lsvm(targets []VCenter, opt lsOptions) error {
|
||||
re, err := regexp.Compile("(?i)" + opt.match)
|
||||
if err != nil {
|
||||
return errf("bad pattern %q: %w", match, err)
|
||||
return errf("bad pattern %q: %w", opt.match, err)
|
||||
}
|
||||
by, err := findOrder(orderBy)
|
||||
by, err := findOrder(opt.orderBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found, err := gatherVMs(targets)
|
||||
defer closeSessions(found.sessions)
|
||||
for _, why := range found.failed {
|
||||
PE(why) // said before the table, where it will not be scrolled past
|
||||
// In a table the failures are said before it, where they will not be
|
||||
// scrolled past. In a document they belong *in* it: a line of prose in the
|
||||
// middle of the JSON would break whatever is reading it, and a script that
|
||||
// cannot tell "no machines" from "the server did not answer" is a script
|
||||
// that reports an empty cluster.
|
||||
if !opt.json {
|
||||
for _, why := range found.failed {
|
||||
PE(why)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -41,9 +59,26 @@ func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
|
||||
rows = append(rows, r)
|
||||
}
|
||||
}
|
||||
sortRows(rows, by, reverse != sortOrders[by].natural)
|
||||
if opt.issues {
|
||||
rows = withIssues(rows)
|
||||
}
|
||||
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
|
||||
|
||||
printList(rows)
|
||||
if opt.json {
|
||||
return printJSON(found, rows)
|
||||
}
|
||||
// Nothing to report, and nobody watching: say nothing at all. This is the
|
||||
// listing that belongs in cron, and cron mails whatever a command prints —
|
||||
// so a daily "nothing wrong" would be a daily mail nobody reads, and the
|
||||
// one morning it did not arrive would mean nothing either. On a terminal
|
||||
// somebody is waiting for an answer, so there it is said.
|
||||
if opt.issues && len(rows) == 0 {
|
||||
if !color.NoColor {
|
||||
PF("%s\n", Cgb(SF("nothing to report on any of the %d machines", len(found.rows))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
printList(rows, listColumns(rows, opt.issues))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -54,8 +89,8 @@ func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
|
||||
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
|
||||
// machine names off at twenty-two characters — silently, with an ellipsis, into
|
||||
// output whose whole purpose is to be read by something else.
|
||||
func printList(rows []vmRow) {
|
||||
cols := fitColumns(printWidth())
|
||||
func printList(rows []vmRow, table []browseColumn) {
|
||||
cols := fitColumnsOf(table, printWidth(table))
|
||||
|
||||
pcs := make([]printColumn, len(cols))
|
||||
for i, c := range cols {
|
||||
@@ -87,9 +122,9 @@ func printList(rows []vmRow) {
|
||||
// printWidth is what the table is laid out for. color.NoColor is the answer to
|
||||
// "is this a terminal" that the colours already go by, so the two cannot disagree
|
||||
// about where the output is headed.
|
||||
func printWidth() int {
|
||||
func printWidth(table []browseColumn) int {
|
||||
if color.NoColor {
|
||||
return tableWidth(browseColumns)
|
||||
return tableWidth(table)
|
||||
}
|
||||
cols, _ := termSize()
|
||||
return max(cols, 20)
|
||||
|
||||
Reference in New Issue
Block a user