Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
137a799399 | ||
|
|
b902059402 | ||
|
|
dda9dc1e74 | ||
|
|
d3fa1790a1 | ||
|
|
9b0d218174 | ||
|
|
1c14902d7c | ||
|
|
321c69ebaa |
@@ -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,23 +36,59 @@ 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.
|
||||
|
||||
The file holds passwords, so gvm creates it mode 0600 and complains when it
|
||||
finds it readable by others.
|
||||
|
||||
### Passwords in the file
|
||||
|
||||
A password written into `~/.gvmrc` in the clear is sealed on the next run of gvm
|
||||
and replaced in place by a `gvmenc1:...` word:
|
||||
|
||||
vcenter.v308.password = gvmenc1:otspj7CLz1/8vEUFHpfrKH/zKiVLqlOvVwQ…
|
||||
|
||||
Nothing else about the file changes — the keys, the spacing, the order, the blank
|
||||
lines and the comments beside a setting are all left exactly as they were — and
|
||||
gvm says which password it sealed. Only the value is sealed, never the file, so
|
||||
`~/.gvmrc` stays readable and editable by hand.
|
||||
|
||||
`gvm config -p v308` asks for a password instead of taking it from the file and
|
||||
writes it sealed straight away. That is the way to set one: a password typed into
|
||||
the file stands there in the clear until the next run of gvm, and by then it has
|
||||
been through the editor's swap file and whatever backs the home directory up.
|
||||
|
||||
`gvm config` says whether each password is sealed, still in the clear, or sealed
|
||||
but no longer openable — it opens each one and throws it away, because "it is
|
||||
sealed" is worth nothing if it does not open.
|
||||
|
||||
**What this is, and is not.** The key is compiled into gvm and is the same in
|
||||
every copy of it, so whoever holds `~/.gvmrc` *and* a gvm binary can open the
|
||||
value; prising the key out is an afternoon's work, not a cluster's. This is not a
|
||||
vault. What it buys is that the password no longer stands in the clear in a
|
||||
backup, in a home directory that syncs somewhere, in an editor's swap file, or on
|
||||
a screen someone else is looking at. The 0600 is what keeps other local users
|
||||
out. A value given in `GVM_VCENTER_*_PASSWORD` is taken as it stands, sealed or
|
||||
not.
|
||||
|
||||
## Commands
|
||||
|
||||
| command | what it does |
|
||||
| --- | --- |
|
||||
| *(nothing)* | browse the machines interactively (see below) |
|
||||
| `vm` | the same, spelled out |
|
||||
| `vm -l [-m <re>]` | print them instead; all vCenters unless `-v` names one |
|
||||
| `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 |
|
||||
@@ -58,8 +99,11 @@ finds it readable by others.
|
||||
| `power --reset <vm>` | reset at the hypervisor — hard |
|
||||
| `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
|
||||
|
||||
@@ -72,9 +116,10 @@ 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
|
||||
|
||||
The sheet is one line per thing worth knowing, values that belong together
|
||||
@@ -84,10 +129,153 @@ are the only part anyone scrolls for. A value too long for the width is carried
|
||||
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)
|
||||
^r ask the servers again
|
||||
esc clear the filter, or leave when there is none
|
||||
^c leave
|
||||
|
||||
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 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.
|
||||
|
||||
### Sorting
|
||||
|
||||
`^o` puts a legend on the status line and the next key picks the order, so the
|
||||
list stays on screen while it rearranges itself:
|
||||
|
||||
sort: n·name p·pwr c·cpu% m·mem% #·snaps o·old w·why
|
||||
s·size u·cpus v·vc h·host a·ip r·reverse
|
||||
|
||||
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
|
||||
whatever is current. Anything that is not a choice — Esc, a stray letter — leaves
|
||||
the table as it was.
|
||||
|
||||
The title says which order the table is in (`↓ cpu load`) and the heading of that
|
||||
column is lit in the same colour, so the state is visible without asking. A
|
||||
missing value is never a small one: a stopped machine has no load and a machine
|
||||
whose guest is silent has no address, and both sort to the bottom whichever
|
||||
direction the order runs. Machines that compare equal stay in name order, so
|
||||
flipping the direction on a screen full of identical figures does not reshuffle
|
||||
them.
|
||||
|
||||
Two lines, because thirteen choices do not fit across eighty columns and a
|
||||
legend that ran off the edge would hide the very choices it exists to offer.
|
||||
They are grouped where the meaning breaks: what the machine is doing and what it
|
||||
wants doing to it above, what it is made of and where it lives below. The second
|
||||
line takes the help line's row, which describes keys that do nothing while a
|
||||
menu is waiting for one.
|
||||
|
||||
Three of them are about the two columns that are new:
|
||||
|
||||
* `#` is by how many snapshots the machine is carrying, most first. Nought is a
|
||||
figure here and not a missing one — nothing to clean up is a fact about the
|
||||
machine — so a machine with none sorts where nought belongs: at the bottom
|
||||
going down, at the top coming back up. The key is not a letter because every
|
||||
letter with a mnemonic in it was taken, and a hash is what a count is written
|
||||
with anyway; `--sort snapshots` needs no shell quoting.
|
||||
* `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.
|
||||
|
||||
A vCenter that does not answer is named in the title in red — `on v308, v108
|
||||
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
|
||||
@@ -116,8 +304,10 @@ section so the new one is there to see.
|
||||
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
|
||||
differently. On a terminal too short for both, those lines go one at a time,
|
||||
least useful first: the state stays longest because every choice depends on it,
|
||||
then the address and the hostname, because two of the choices are about them.
|
||||
Everything that changes a
|
||||
machine lives there and nowhere else — the list is arrowed through and its filter
|
||||
swallows every ordinary letter, so a hotkey that powered a machine off would sit
|
||||
one fumbled control key away from an outage, and the sheet has to be opened first
|
||||
@@ -128,11 +318,37 @@ anyway.
|
||||
d remove a snapshot ... b reboot the guest
|
||||
D remove ALL snapshots S power off (hard)
|
||||
B reset (hard)
|
||||
────────────────────────────────────────────────────────────
|
||||
e recent events h ssh to the guest
|
||||
y copy the address w open in the vSphere client
|
||||
|
||||
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.
|
||||
reason next to it — "no VMware Tools", "already running", "no address" — rather
|
||||
than left out, and picking it anyway spells the reason out instead of running it.
|
||||
|
||||
The group under the rule changes nothing, on the machine or on the vCenter. It
|
||||
is here rather than on four more control keys of its own because this is where
|
||||
one already looks for "what can I do with this machine", and because the sheet's
|
||||
help line is not the place to learn them:
|
||||
|
||||
* **`e`** puts the machine's own recent events at the foot of its sheet and
|
||||
scrolls down to them — why is this thing off, who rebooted it, what happened
|
||||
at four this morning. `gvm log` is the whole vCenter over the last hour, which
|
||||
is the right shape for a mail and the wrong one for that question. They are
|
||||
fetched when they are asked for: opening a machine stays one call.
|
||||
* **`h`** logs in to the guest, by its own hostname where it reports one and by
|
||||
its address otherwise. The terminal goes back to what it was for as long as
|
||||
that lasts. `ssh = ssh -l root %h` in `~/.gvmrc` says how; the target is
|
||||
always one argument and never goes through a shell, because it is a name the
|
||||
guest chose for itself.
|
||||
* **`y`** copies that address to the clipboard — the terminal's own, asked for
|
||||
with an escape sequence rather than through `pbcopy`, so it works over ssh and
|
||||
lands where the person actually is.
|
||||
* **`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.
|
||||
|
||||
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
|
||||
@@ -174,9 +390,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`.
|
||||
@@ -206,6 +426,155 @@ 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.
|
||||
|
||||
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
|
||||
@@ -219,6 +588,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:
|
||||
|
||||
| | |
|
||||
@@ -229,7 +606,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
|
||||
@@ -291,3 +670,29 @@ 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 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.
|
||||
|
||||
+163
-7
@@ -51,8 +51,51 @@ 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 because two of the choices are *about* them: ssh
|
||||
// and copy-the-address. The guest's operating system decides nothing here.
|
||||
var factOrder = []string{"state", "address", "hostname", "guest"}
|
||||
|
||||
// keepFacts is as many of the facts as fit, still in sheet order.
|
||||
//
|
||||
// Giving them up one at a time rather than all at once is what a terminal of
|
||||
// twenty-four rows gets out of this: the menu grew a group of its own, all four
|
||||
// facts no longer fit above it there, and none is a worse answer than three.
|
||||
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
|
||||
@@ -146,9 +189,44 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
|
||||
separator(),
|
||||
pwr('S', "power off (hard, at the hypervisor)", opPowerOff),
|
||||
pwr('B', "reset (hard, at the hypervisor)", opReset),
|
||||
separator(),
|
||||
// The last group changes nothing, on the machine or on the vCenter: it
|
||||
// reads its history, copies its address, opens it somewhere else. It is
|
||||
// here rather than on keys of its own because the sheet's help line is
|
||||
// not the place to learn four more control keys, and because this is
|
||||
// where one already looks for "what can I do with this machine".
|
||||
{key: 'e', label: "recent events", run: func(b *browser, r vmRow) { b.showEvents(r) }},
|
||||
guestItem('h', "ssh to the guest", r, func(b *browser, r vmRow) { b.sshTo(r) }),
|
||||
guestItem('y', "copy the address to the clipboard", r,
|
||||
func(b *browser, r vmRow) { b.copyAddress(r) }),
|
||||
vsphereItem('w', "open in the vSphere client", r),
|
||||
}
|
||||
}
|
||||
|
||||
// guestItem is one of the entries that needs somewhere to connect to. A machine
|
||||
// whose guest is not talking has no address, and saying so where the entry is
|
||||
// beats a keystroke that does nothing.
|
||||
func guestItem(key rune, label string, r vmRow, run func(*browser, vmRow)) menuItem {
|
||||
if r.sshTarget() == "" {
|
||||
return menuItem{key: key, label: label, hint: "no address",
|
||||
why: SF("%s has no address or hostname — its guest is not reporting one", r.name),
|
||||
run: run}
|
||||
}
|
||||
return menuItem{key: key, label: label, run: run}
|
||||
}
|
||||
|
||||
// vsphereItem needs the same, plus a way to open a browser: on a machine with
|
||||
// no desktop the URL is still worth having, so it is offered and printed rather
|
||||
// than left out.
|
||||
func vsphereItem(key rune, label string, r vmRow) menuItem {
|
||||
if vsphereURL(r) == "" {
|
||||
return menuItem{key: key, label: label, hint: "no connection",
|
||||
why: "the vSphere client link needs the connection this machine was read over",
|
||||
run: func(b *browser, r vmRow) { b.openVSphere(r) }}
|
||||
}
|
||||
return menuItem{key: key, label: label, run: func(b *browser, r vmRow) { b.openVSphere(r) }}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -429,12 +507,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 +578,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 +636,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,
|
||||
"lowercase asks the guest, uppercase the hypervisor · the last group only reads"})
|
||||
}
|
||||
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
|
||||
b.write(sb.String())
|
||||
@@ -702,3 +792,69 @@ func wrap(s string, width int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the harmless half of the menu
|
||||
|
||||
// showEvents puts the machine's recent history at the foot of its sheet and
|
||||
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
|
||||
// machine is one call, and this is another.
|
||||
func (b *browser) showEvents(r vmRow) {
|
||||
b.closeMenu()
|
||||
b.working(SF("reading the events of %s ...", r.name))
|
||||
|
||||
lines, err := eventsOf(r)
|
||||
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")
|
||||
}
|
||||
|
||||
// sshTo logs in to the guest. The terminal goes back to what it was for as long
|
||||
// as that lasts (guest.go), and the list is redrawn afterwards.
|
||||
func (b *browser) sshTo(r vmRow) {
|
||||
b.closeMenu()
|
||||
target := r.sshTarget()
|
||||
argv := sshCommand(b.ssh, target)
|
||||
|
||||
if err := b.runInTerminal(argv); err != nil {
|
||||
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
|
||||
return
|
||||
}
|
||||
b.setStatus(colDim, "back from "+target)
|
||||
}
|
||||
|
||||
// copyAddress puts the address where the next paste will find it, by asking the
|
||||
// terminal rather than the operating system — see osc52.
|
||||
func (b *browser) copyAddress(r vmRow) {
|
||||
b.closeMenu()
|
||||
target := r.sshTarget()
|
||||
b.write(osc52(target))
|
||||
b.setStatus(colInfo, "copied "+target)
|
||||
}
|
||||
|
||||
// openVSphere opens the machine's page in the vSphere client, and says the URL
|
||||
// either way: a workstation with no browser to hand off to still gets the one
|
||||
// thing that was wanted, and so does anyone running gvm over ssh.
|
||||
func (b *browser) openVSphere(r vmRow) {
|
||||
b.closeMenu()
|
||||
url := vsphereURL(r)
|
||||
if url == "" {
|
||||
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
|
||||
return
|
||||
}
|
||||
if err := openURL(url); err != nil {
|
||||
b.write(osc52(url))
|
||||
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
|
||||
return
|
||||
}
|
||||
b.setStatus(colInfo, "opened "+url)
|
||||
}
|
||||
|
||||
+120
-3
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -597,13 +598,17 @@ 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
|
||||
{"24", true}, // room for three of them, the hostname among them
|
||||
{"23", false}, // room for two: the state and the address
|
||||
{"18", false}, // no room at all, so the choices have the screen
|
||||
} {
|
||||
t.Setenv("LINES", c.rows)
|
||||
|
||||
@@ -644,3 +649,115 @@ func renderToPipe(t *testing.T, b *browser, draw func()) string {
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// A control sequence has to be swallowed whole. Reading a fixed number of bytes
|
||||
// left the rest of a longer one in the stream, where the next read took it for
|
||||
// typing: Ctrl-Up put "5A" into the filter and F5 put a tilde in it, having first
|
||||
// jumped to the top of the list.
|
||||
func TestEscapeSequencesAreConsumedWhole(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
send string
|
||||
want specialKey
|
||||
note string
|
||||
}{
|
||||
{"\x1b[A", keyUp, "up"},
|
||||
{"\x1b[B", keyDown, "down"},
|
||||
{"\x1b[C", keyRight, "right"},
|
||||
{"\x1b[D", keyLeft, "left"},
|
||||
{"\x1b[H", keyHome, "home"},
|
||||
{"\x1b[F", keyEnd, "end"},
|
||||
{"\x1b[Z", keyShiftTab, "shift-tab"},
|
||||
{"\x1b[3~", keyDelete, "delete"},
|
||||
{"\x1b[5~", keyPgUp, "page up"},
|
||||
{"\x1b[6~", keyPgDn, "page down"},
|
||||
|
||||
// Modified arrows and function keys: not answered, but not leaked either.
|
||||
{"\x1b[1;5A", keyNone, "ctrl-up"},
|
||||
{"\x1b[1;2D", keyNone, "shift-left"},
|
||||
{"\x1b[15~", keyNone, "F5"},
|
||||
{"\x1b[200~", keyNone, "a bracketed paste opening"},
|
||||
{"\x1b[<0;10;20M", keyNone, "a mouse report"},
|
||||
} {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kr := newKeyReader(r)
|
||||
w.WriteString(c.send + "x") // an "x" behind it, to see what was left over
|
||||
|
||||
if got := kr.next(); got.special != c.want {
|
||||
t.Errorf("%s (%q): decoded as %v, want %v", c.note, c.send, got.special, c.want)
|
||||
}
|
||||
// Whatever the sequence was, the very next key must be the x — nothing of
|
||||
// the sequence may arrive as text.
|
||||
next := kr.next()
|
||||
if next.special != keyRune || next.r != 'x' {
|
||||
t.Errorf("%s (%q): the next key is %v/%q, want the x — part of the sequence leaked",
|
||||
c.note, c.send, next.special, next.r)
|
||||
}
|
||||
w.Close()
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// A lone Esc is still a lone Esc: it is how every dangerous question is abandoned.
|
||||
func TestLoneEscapeIsStillEscape(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
|
||||
kr := newKeyReader(r)
|
||||
w.WriteString("\x1b")
|
||||
if got := kr.next(); got.special != keyEsc {
|
||||
t.Errorf("a lone Esc decoded as %v", got.special)
|
||||
}
|
||||
}
|
||||
|
||||
// Every vCenter task gvm waits for has to be waited for with a bound. This is a
|
||||
// property of the source rather than of anything a test can provoke — a task that
|
||||
// hangs is exactly what no simulator will do — so the source is what is checked.
|
||||
//
|
||||
// Taking a snapshot was the one that got away: it waited on the session's own
|
||||
// context, which has no deadline, and would have frozen the interactive list
|
||||
// mid-draw with no key being read.
|
||||
func TestEveryTaskWaitIsBounded(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, name := range files {
|
||||
if strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
code := strings.TrimSpace(line)
|
||||
if !strings.Contains(code, ".Wait(") || strings.HasPrefix(code, "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(code, "wg.Wait()") {
|
||||
continue // a WaitGroup, not a vCenter task
|
||||
}
|
||||
if strings.Contains(code, "cmd.Wait()") {
|
||||
continue // an exec.Cmd — a browser being handed a URL (guest.go)
|
||||
}
|
||||
found++
|
||||
// The one place a task may be waited on is inside waitTask, which
|
||||
// gives it a deadline of its own.
|
||||
if name != "power.go" || !strings.Contains(code, "task.Wait(wctx)") {
|
||||
t.Errorf("%s:%d waits on a task outside waitTask: %s", name, i+1, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Error("no task wait found at all — this check has stopped checking anything")
|
||||
}
|
||||
}
|
||||
|
||||
+716
-6
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
@@ -57,7 +61,12 @@ func testRow(name string, on bool, ip string) vmRow {
|
||||
func testBrowser(names ...string) *browser {
|
||||
b := &browser{targets: []VCenter{{Name: "v308"}}}
|
||||
for i, n := range names {
|
||||
b.rows = append(b.rows, testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1)))
|
||||
r := testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1))
|
||||
// A reference of its own for each: testRow hands out one fixed vm-42,
|
||||
// which is fine for a machine on its own and useless for a list, where
|
||||
// the selection follows a machine by its reference.
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + Itoa(i)}
|
||||
b.rows = append(b.rows, r)
|
||||
}
|
||||
b.refilter()
|
||||
return b
|
||||
@@ -384,16 +393,28 @@ func TestHighlight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// stripEscapes removes every control sequence, not only the colours. It used to
|
||||
// stop at the first "m", which ends a colour and nothing else — so on a whole
|
||||
// frame it ran past the "\x1b[K" that ends each line, swallowed the line break
|
||||
// with it, and handed back one very long line.
|
||||
//
|
||||
// A sequence is ESC [ then parameters then one final byte in 0x40..0x7e, which is
|
||||
// what "m" (colour), "K" (clear to end of line) and "H" (move the cursor) are.
|
||||
func stripEscapes(s string) string {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == 0x1b {
|
||||
for i < len(s) && s[i] != 'm' {
|
||||
i++
|
||||
}
|
||||
if s[i] != 0x1b {
|
||||
out.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
out.WriteByte(s[i])
|
||||
i++
|
||||
if i < len(s) && s[i] == '[' {
|
||||
i++ // the bracket is itself in the final-byte range, so step over it
|
||||
}
|
||||
for i < len(s) && (s[i] < 0x40 || s[i] > 0x7e) {
|
||||
i++
|
||||
}
|
||||
// i is on the final byte; the loop's own i++ moves past it
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -746,3 +767,692 @@ func TestSheetValuesAreColouredAndWrapKeepsIt(t *testing.T) {
|
||||
t.Fatal("the sheet has no path line")
|
||||
}
|
||||
}
|
||||
|
||||
// What a narrow terminal gives up, and in what order. The guest's operating
|
||||
// system goes first; the name and the power state never go at all.
|
||||
func TestNarrowTerminalGivesUpTheRightColumns(t *testing.T) {
|
||||
headers := func(cs []browseColumn) []string {
|
||||
out := make([]string, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.header
|
||||
}
|
||||
return out
|
||||
}
|
||||
has := func(cs []browseColumn, header string) bool {
|
||||
return slices.Contains(headers(cs), header)
|
||||
}
|
||||
|
||||
// Everything fits somewhere wide.
|
||||
wide := fitColumns(200)
|
||||
if len(wide) != len(browseColumns) {
|
||||
t.Fatalf("a wide terminal shows %v, not all of them", headers(wide))
|
||||
}
|
||||
|
||||
// The first thing to go is the guest's operating system, before any figure.
|
||||
oneLess := fitColumns(tableWidth(browseColumns) - 1)
|
||||
if has(oneLess, "GUEST OS") {
|
||||
t.Errorf("the first column given up was not GUEST OS: %v", headers(oneLess))
|
||||
}
|
||||
if len(oneLess) != len(browseColumns)-1 {
|
||||
t.Errorf("more than one column was given up: %v", headers(oneLess))
|
||||
}
|
||||
for _, keep := range []string{"CPU%", "MEM%", "MEM", "CPU", "IP", "HOST"} {
|
||||
if !has(oneLess, keep) {
|
||||
t.Errorf("%s was given up before GUEST OS: %v", keep, headers(oneLess))
|
||||
}
|
||||
}
|
||||
|
||||
// The set only ever shrinks as the terminal narrows, and it shrinks in the
|
||||
// order declared.
|
||||
prev := len(browseColumns)
|
||||
for width := 200; width >= 10; width-- {
|
||||
cs := fitColumns(width)
|
||||
if len(cs) > prev {
|
||||
t.Fatalf("%d columns wide shows %d columns, wider showed %d", width, len(cs), prev)
|
||||
}
|
||||
prev = len(cs)
|
||||
|
||||
if tableWidth(cs) > width {
|
||||
t.Errorf("%d columns wide needs %d", width, tableWidth(cs))
|
||||
}
|
||||
if !has(cs, "NAME") {
|
||||
t.Errorf("%d columns wide has no NAME: %v", width, headers(cs))
|
||||
}
|
||||
// Below the point where even the mandatory columns fit, the name alone
|
||||
// stands; above it the power state is always there.
|
||||
if len(cs) > 1 && !has(cs, "PWR") {
|
||||
t.Errorf("%d columns wide gave up PWR: %v", width, headers(cs))
|
||||
}
|
||||
// A percentage is never shown without the header that says it is one.
|
||||
for _, c := range cs {
|
||||
if c.header == "CPU%" || c.header == "MEM%" {
|
||||
if c.width < 4 {
|
||||
t.Errorf("%d columns wide: %s is only %d wide, too narrow for 100", width, c.header, c.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The load figures themselves: a running machine has one, a stopped machine has
|
||||
// none, and none is not zero.
|
||||
func TestLoadCells(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
// 420 of 8400 MHz = 5 %, and 4096 of 8192 MB = 50 %.
|
||||
if got := loadCell(r.cpuLoad()); got != "5" {
|
||||
t.Errorf("cpu load reads %q, want 5", got)
|
||||
}
|
||||
if got := loadCell(r.memLoad()); got != "50" {
|
||||
t.Errorf("memory load reads %q, want 50", got)
|
||||
}
|
||||
|
||||
off := r
|
||||
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
for _, got := range []string{loadCell(off.cpuLoad()), loadCell(off.memLoad())} {
|
||||
if got != "-" {
|
||||
t.Errorf("a stopped machine reports a load of %q, want a dash", got)
|
||||
}
|
||||
}
|
||||
if loadColor(off.cpuLoad()) != colOff {
|
||||
t.Error("the dash is not in the disabled colour")
|
||||
}
|
||||
|
||||
// No denominator is also no load, however the machine claims to be running.
|
||||
noLimit := r
|
||||
noLimit.vm.Summary.Runtime.MaxCpuUsage = 0
|
||||
if got := loadCell(noLimit.cpuLoad()); got != "-" {
|
||||
t.Errorf("with no cpu limit the load reads %q, want a dash", got)
|
||||
}
|
||||
noMem := r
|
||||
noMem.vm.Summary.Config.MemorySizeMB = 0
|
||||
if got := loadCell(noMem.memLoad()); got != "-" {
|
||||
t.Errorf("with no configured memory the load reads %q, want a dash", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine under pressure has to be visible without reading the number.
|
||||
func TestLoadColoursRiseWithTheLoad(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pct float64
|
||||
want string
|
||||
note string
|
||||
}{
|
||||
{0, colSize, "idle"},
|
||||
{74, colSize, "busy but not worth a colour"},
|
||||
{75, colBusy, "filling up"},
|
||||
{89, colBusy, "still filling up"},
|
||||
{90, colFull, "full"},
|
||||
{100, colFull, "quite full"},
|
||||
{143, colFull, "over its share"},
|
||||
} {
|
||||
if got := loadColor(c.pct, true); got != c.want {
|
||||
t.Errorf("%.0f %% (%s) is coloured %q, want %q", c.pct, c.note, got, c.want)
|
||||
}
|
||||
}
|
||||
// Both thresholds are palette colours, and neither is the ordinary one.
|
||||
if colBusy == colSize || colFull == colSize || colBusy == colFull {
|
||||
t.Error("the three load colours are not three different colours")
|
||||
}
|
||||
}
|
||||
|
||||
// The ladder of layouts, written out. Dragging a terminal narrower may only ever
|
||||
// take columns away — a column that appears as the window shrinks is worse than
|
||||
// one that is simply gone — and this is where that is pinned down, together with
|
||||
// the width at which each one goes.
|
||||
func TestColumnLadder(t *testing.T) {
|
||||
at := func(width int) string {
|
||||
var h []string
|
||||
for _, c := range fitColumns(width) {
|
||||
h = append(h, c.header)
|
||||
}
|
||||
return strings.Join(h, " ")
|
||||
}
|
||||
|
||||
// The widths are what fitColumns is handed, which is the terminal less the two
|
||||
// columns the pointer occupies: 78 here is a terminal of eighty. Both ends of
|
||||
// each rung are given, so a width change moves the boundary and not only the
|
||||
// example.
|
||||
const (
|
||||
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%"
|
||||
five = "NAME VC PWR CPU% MEM%"
|
||||
four = "NAME VC PWR CPU%"
|
||||
three = "NAME VC PWR"
|
||||
two = "NAME PWR"
|
||||
one = "NAME"
|
||||
)
|
||||
for _, c := range []struct {
|
||||
width int
|
||||
want string
|
||||
}{
|
||||
{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},
|
||||
{45, four}, {40, four},
|
||||
{39, three}, {34, three},
|
||||
{33, two}, {28, two},
|
||||
{27, one}, {10, one},
|
||||
} {
|
||||
if got := at(c.width); got != c.want {
|
||||
t.Errorf("%d columns wide shows\n %s\nwant\n %s", c.width, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And the sets form a chain: each one is contained in the next wider one.
|
||||
var wider []string
|
||||
for width := 10; width <= 200; width++ {
|
||||
var here []string
|
||||
for _, c := range fitColumns(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
|
||||
}
|
||||
}
|
||||
|
||||
// The printed table and the interactive one come from one definition, so a value
|
||||
// cannot be spelled two ways. It used to show a bare 32 where the list showed
|
||||
// 32.0MB, and had neither the power state, the host, nor the load figures.
|
||||
func TestPrintedListMatchesTheInteractiveOne(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
|
||||
for _, c := range fitColumns(200) {
|
||||
want := c.cell(r)
|
||||
// The same cell function feeds both, so this is really a check that the
|
||||
// printed table takes its cells from here rather than formatting its own.
|
||||
if want == "" && c.header != "IP" {
|
||||
t.Errorf("the %s column has nothing to print", c.header)
|
||||
}
|
||||
}
|
||||
|
||||
// The things the printed listing used to be missing.
|
||||
var headers []string
|
||||
for _, c := range fitColumns(200) {
|
||||
headers = append(headers, c.header)
|
||||
}
|
||||
for _, want := range []string{"PWR", "HOST", "CPU%", "MEM%", "MEM"} {
|
||||
if !slices.Contains(headers, want) {
|
||||
t.Errorf("the shared table has no %s column", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes are spelled as sizes in both.
|
||||
var mem browseColumn
|
||||
for _, c := range browseColumns {
|
||||
if c.header == "MEM" {
|
||||
mem = c
|
||||
}
|
||||
}
|
||||
if got := mem.cell(r); got != "8.0GB" {
|
||||
t.Errorf("the memory column reads %q, want 8.0GB and not a bare number", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Padding happens before colouring, or the escapes are counted as columns — which
|
||||
// is what made `gvm host` shift a line three columns for a longer hostname.
|
||||
func TestPrintedCellsArePaddedNotColoured(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
text string
|
||||
width int
|
||||
right bool
|
||||
want string
|
||||
}{
|
||||
{"esx1", 8, false, "esx1 "},
|
||||
{"esx1", 8, true, " esx1"},
|
||||
{"1.46", 6, true, " 1.46"},
|
||||
{"DC0_C0_H0", 6, false, "DC0_C…"}, // too long: cut, never overflowing
|
||||
{"", 4, false, " "},
|
||||
} {
|
||||
got := pad(c.text, c.width, c.right)
|
||||
if got != c.want {
|
||||
t.Errorf("pad(%q, %d, %v) = %q, want %q", c.text, c.width, c.right, got, c.want)
|
||||
}
|
||||
if len([]rune(got)) != c.width {
|
||||
t.Errorf("pad(%q, %d) gave %d columns", c.text, c.width, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// A coloured value still occupies exactly its column: the escapes go on after
|
||||
// the padding, never before it.
|
||||
painted := paint(pad("esx1", 8, false), cWhite.fg())
|
||||
if len([]rune(stripEscapes(painted))) != 8 {
|
||||
t.Errorf("a coloured cell is %d columns wide", len([]rune(stripEscapes(painted))))
|
||||
}
|
||||
}
|
||||
|
||||
// Into a pipe nothing may be cut. Fitting the printed table to the interactive
|
||||
// minimum width truncated machine names at twenty-two characters, silently and
|
||||
// with an ellipsis, in output whose whole purpose is to be read by something
|
||||
// else.
|
||||
func TestPipedListingCutsNothing(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1") // read by fatih/color at init, so also forced below
|
||||
noColor := color.NoColor
|
||||
color.NoColor = true
|
||||
defer func() { color.NoColor = noColor }()
|
||||
|
||||
long := "a-machine-with-a-name-of-forty-three-chars"
|
||||
rows := []vmRow{
|
||||
testRow(long, true, "10.0.0.5"),
|
||||
testRow("web01", true, "10.0.0.6"),
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { printList(rows, listColumns(rows, false)) })
|
||||
|
||||
if !strings.Contains(out, long) {
|
||||
t.Errorf("the long name was cut:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "…") {
|
||||
t.Errorf("something was truncated:\n%s", out)
|
||||
}
|
||||
// Every column still lines up: the widths grew, they did not go away.
|
||||
var widths []int
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
widths = append(widths, len([]rune(line)))
|
||||
}
|
||||
for i := 1; i < len(widths); i++ {
|
||||
if widths[i] > widths[0]+2 && widths[0] < len(long) {
|
||||
t.Errorf("line %d is %d wide, the heading %d — the columns do not line up",
|
||||
i, widths[i], widths[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout runs f with stdout on a pipe and hands back what it wrote.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := os.Stdout
|
||||
os.Stdout = w
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
out, _ := io.ReadAll(r)
|
||||
done <- string(out)
|
||||
}()
|
||||
|
||||
f()
|
||||
w.Close()
|
||||
os.Stdout = saved
|
||||
return <-done
|
||||
}
|
||||
|
||||
// The selection follows the machine, not its name: two vCenters may each hold a
|
||||
// "web01", and following the name alone moved the cursor to the other server's
|
||||
// machine whenever sorting brought that one up first.
|
||||
func TestSelectionFollowsTheMachineNotTheName(t *testing.T) {
|
||||
same := func(vc, moref string, on bool) vmRow {
|
||||
r := testRow("web01", on, "10.0.0.5")
|
||||
r.vc = VCenter{Name: vc}
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: moref}
|
||||
return r
|
||||
}
|
||||
b := &browser{rows: []vmRow{same("v308", "vm-1", true), same("v108", "vm-9", false)}}
|
||||
b.refilter()
|
||||
|
||||
b.move(1) // the second one: same name, other vCenter
|
||||
cur := b.current()
|
||||
if cur.vc.Name != "v108" {
|
||||
t.Fatalf("the cursor is on %s, expected v108", cur.vc.Name)
|
||||
}
|
||||
if cur.id() == b.rows[0].id() {
|
||||
t.Fatal("two machines on two servers share an identity")
|
||||
}
|
||||
|
||||
// Sorting by power puts the running one first; the cursor must stay on the
|
||||
// stopped machine it was on, not jump to the namesake now under it.
|
||||
for i, o := range sortOrders {
|
||||
if o.key == 'p' {
|
||||
b.sortBy, b.sortDesc = i, o.natural
|
||||
}
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
if got := b.current(); got.vc.Name != "v108" {
|
||||
t.Errorf("after sorting the cursor sits on %s/%s, want the v108 machine",
|
||||
got.vc.Name, got.name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- the conditional columns
|
||||
|
||||
// The task column is there while anything is being done on the cluster and not
|
||||
// otherwise. Every other column holds a property and can say "-"; this one
|
||||
// holds an exception, and thirteen characters of blank down two hundred rows is
|
||||
// thirteen characters spent on nothing.
|
||||
func TestTheTaskColumnIsThereWhenThereIsATask(t *testing.T) {
|
||||
quiet := []vmRow{testRow("web01", true, "10.0.0.5")}
|
||||
if headers(listColumns(quiet, false)) != headers(browseColumns) {
|
||||
t.Errorf("a quiet cluster shows %s", headers(listColumns(quiet, false)))
|
||||
}
|
||||
|
||||
busy := []vmRow{testRow("web01", true, "10.0.0.5"), testRow("db01", true, "10.0.0.6")}
|
||||
busy[1].task = &runningTask{what: "clone", progress: 40}
|
||||
|
||||
got := headers(listColumns(busy, false))
|
||||
if !strings.Contains(got, "TASK") {
|
||||
t.Fatalf("a busy cluster shows %s", got)
|
||||
}
|
||||
// Next to the power state: both answer "what is this machine doing".
|
||||
if !strings.Contains(got, "PWR TASK") {
|
||||
t.Errorf("the task column is not beside the power state: %s", got)
|
||||
}
|
||||
// And the machine that is not busy has an empty cell rather than a dash,
|
||||
// which would read as a value of its own.
|
||||
if cell := busy[0].taskCell(); cell != "" {
|
||||
t.Errorf("an idle machine's task cell is %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
// In the listing that is made of reasons, the reason takes the place of the
|
||||
// guest's operating system: the least read column, and the only one wide enough
|
||||
// to hold a sentence.
|
||||
func TestTheReasonColumnReplacesTheGuestOS(t *testing.T) {
|
||||
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
|
||||
got := headers(listColumns(rows, true))
|
||||
|
||||
if strings.Contains(got, "GUEST OS") {
|
||||
t.Errorf("the issues listing still shows the guest os: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "WHY") {
|
||||
t.Errorf("the issues listing has no reason column: %s", got)
|
||||
}
|
||||
|
||||
// And the four figures go with it: how hard a machine's processors happen
|
||||
// to be working says nothing about what is wrong with it, and they are four
|
||||
// columns between its name and the answer.
|
||||
for _, gone := range issueHides {
|
||||
if strings.Contains(got, gone) {
|
||||
t.Errorf("the issues listing still shows %s: %s", gone, got)
|
||||
}
|
||||
}
|
||||
// What is left is what identifies the machine, plus the reason.
|
||||
if want := "NAME VC PWR SNAP IP HOST WHY"; got != want {
|
||||
t.Errorf("the issues listing shows\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The ladder still only ever takes columns away, with the task column in the
|
||||
// table as well: a column that comes back as the terminal is dragged narrower
|
||||
// is worse than a column that is simply gone.
|
||||
func TestTheLadderStillOnlyDropsWithATaskColumn(t *testing.T) {
|
||||
rows := []vmRow{testRow("web01", true, "10.0.0.5")}
|
||||
rows[0].task = &runningTask{what: "clone"}
|
||||
table := listColumns(rows, false)
|
||||
|
||||
var wider []string
|
||||
for width := 10; width <= 220; width++ {
|
||||
var here []string
|
||||
for _, c := range fitColumnsOf(table, width) {
|
||||
here = append(here, c.header)
|
||||
}
|
||||
for _, h := range wider {
|
||||
if !slices.Contains(here, h) && h != "NAME" {
|
||||
t.Fatalf("%d columns wide lost %s, which a narrower terminal showed: %v", width, h, here)
|
||||
}
|
||||
}
|
||||
wider = here
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal of eighty keeps the address, which is what the name column's
|
||||
// minimum was tuned for. The snapshot count is what it gives up instead.
|
||||
func TestATerminalOfEightyStillKeepsTheAddress(t *testing.T) {
|
||||
got := headers(fitColumns(78))
|
||||
if !strings.Contains(got, "IP") {
|
||||
t.Errorf("a terminal of eighty shows %s", got)
|
||||
}
|
||||
if strings.Contains(got, "SNAP") {
|
||||
t.Errorf("a terminal of eighty shows the snapshot count as well: %s", got)
|
||||
}
|
||||
if !strings.Contains(headers(fitColumns(83)), "SNAP") {
|
||||
t.Errorf("a wider terminal does not show it either: %s", headers(fitColumns(83)))
|
||||
}
|
||||
}
|
||||
|
||||
func headers(cs []browseColumn) string {
|
||||
var h []string
|
||||
for _, c := range cs {
|
||||
h = append(h, c.header)
|
||||
}
|
||||
return strings.Join(h, " ")
|
||||
}
|
||||
|
||||
// The count is what the column says and its age is the colour it is said in.
|
||||
// A colour cannot be read in a pipe, which is why the sheet spells the date out.
|
||||
func TestTheSnapshotColumn(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
if r.snapCell() != "-" || r.snapColor() != colOff {
|
||||
t.Errorf("a machine with no snapshots shows %q", r.snapCell())
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
days int
|
||||
want string
|
||||
}{
|
||||
{1, colSize}, {snapStaleDays, colBusy}, {snapOldDays, colFull},
|
||||
} {
|
||||
r.snaps = []snapEntry{aged("s", c.days)}
|
||||
if r.snapColor() != c.want {
|
||||
t.Errorf("one snapshot of %d days is coloured wrongly", c.days)
|
||||
}
|
||||
if r.snapCell() != "1" {
|
||||
t.Errorf("one snapshot shows as %q", r.snapCell())
|
||||
}
|
||||
}
|
||||
|
||||
// The oldest is what decides, not the newest or the first in the tree.
|
||||
r.snaps = []snapEntry{aged("new", 1), aged("old", 200), aged("middling", 20)}
|
||||
if r.snapCell() != "3" {
|
||||
t.Errorf("three snapshots show as %q", r.snapCell())
|
||||
}
|
||||
if r.snapColor() != colFull {
|
||||
t.Error("a machine whose oldest snapshot is 200 days old is not painted red")
|
||||
}
|
||||
if e, ok := r.oldest(); !ok || e.name != "old" {
|
||||
t.Errorf("the oldest snapshot came back as %v", e.name)
|
||||
}
|
||||
if days, ok := r.snapAge(); !ok || int(days) != 200 {
|
||||
t.Errorf("the age came back as %v", days)
|
||||
}
|
||||
|
||||
// Snapshots with no date at all: there is something to clean up, but
|
||||
// nothing to judge it by, so it is neither yellow nor red.
|
||||
r.snaps = []snapEntry{{name: "undated"}}
|
||||
if _, ok := r.snapAge(); ok {
|
||||
t.Error("an undated snapshot reported an age")
|
||||
}
|
||||
if r.snapColor() != colChosen {
|
||||
t.Error("an undated snapshot is aged as though it had a date")
|
||||
}
|
||||
}
|
||||
|
||||
// The sheet says what the table paints: what is wrong with the machine and what
|
||||
// is being done to it, above everything that has been true since it was built.
|
||||
func TestTheSheetLeadsWithWhatIsWrong(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
r.task = &runningTask{what: "consolid", progress: 12}
|
||||
|
||||
var labels []string
|
||||
var issues, task string
|
||||
for _, l := range vmDetail(r, nil, "") {
|
||||
if l.label != "" {
|
||||
labels = append(labels, l.label)
|
||||
}
|
||||
if l.label == "issues" {
|
||||
issues = l.value
|
||||
}
|
||||
if l.label == "task" {
|
||||
task = l.value
|
||||
}
|
||||
}
|
||||
if !strings.Contains(issues, "consolidating") {
|
||||
t.Errorf("the sheet's issues line reads %q", issues)
|
||||
}
|
||||
if !strings.Contains(task, "consolid") || !strings.Contains(task, "12") {
|
||||
t.Errorf("the sheet's task line reads %q", task)
|
||||
}
|
||||
|
||||
order := strings.Join(labels, " ")
|
||||
if !strings.HasPrefix(order, "state issues task guest") {
|
||||
t.Errorf("the sheet begins %q", order)
|
||||
}
|
||||
}
|
||||
|
||||
// Each reason keeps its own colour: a filesystem at 97 % and a missing VMware
|
||||
// Tools are not the same news.
|
||||
func TestTheSheetColoursEachReasonForItself(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Summary.Runtime.ConsolidationNeeded = true // red
|
||||
r.vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // yellow
|
||||
|
||||
var cols []string
|
||||
for _, l := range vmDetail(r, nil, "") {
|
||||
if l.label == "issues" || (len(cols) > 0 && l.label == "") {
|
||||
if l.col == colFull || l.col == colBusy {
|
||||
cols = append(cols, l.col)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(cols) != 2 || cols[0] != colFull || cols[1] != colBusy {
|
||||
t.Errorf("the reasons came out coloured %v", cols)
|
||||
}
|
||||
}
|
||||
|
||||
// Every screen, drawn with a machine that has something to say on all of them:
|
||||
// what is wrong with it, what is being done to it, snapshots, and a history
|
||||
// that has been fetched. It is the check that each screen actually shows what
|
||||
// it was given — the reason column was once first in the queue to be given up,
|
||||
// which left a listing of nothing but machines with the reasons cut off.
|
||||
func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "120")
|
||||
t.Setenv("LINES", "30")
|
||||
|
||||
b := testBrowser("web01", "db01")
|
||||
b.answered = []string{"v308"}
|
||||
b.rows[1].task = &runningTask{what: "clone", progress: 40}
|
||||
b.rows[1].snaps = []snapEntry{aged("base", 63), aged("hotfix", 3)}
|
||||
b.rows[1].vm.Summary.Runtime.ConsolidationNeeded = true
|
||||
b.applySort()
|
||||
|
||||
list := stripEscapes(renderToPipe(t, b, b.renderList))
|
||||
for _, want := range []string{"TASK", "clone 40%", "SNAP", "db01"} {
|
||||
if !strings.Contains(list, want) {
|
||||
t.Errorf("the list does not show %q:\n%s", want, list)
|
||||
}
|
||||
}
|
||||
|
||||
b.issuesOnly = true
|
||||
b.refilter()
|
||||
issues := stripEscapes(renderToPipe(t, b, b.renderList))
|
||||
for _, want := range []string{"WHY", "consolidating", "issues only"} {
|
||||
if !strings.Contains(issues, want) {
|
||||
t.Errorf("the issues listing does not show %q:\n%s", want, issues)
|
||||
}
|
||||
}
|
||||
if strings.Contains(issues, "web01") {
|
||||
t.Errorf("the issues listing kept a healthy machine:\n%s", issues)
|
||||
}
|
||||
|
||||
b.issuesOnly = false
|
||||
b.refilter()
|
||||
for i, r := range b.rows {
|
||||
if r.name == "db01" {
|
||||
b.sel = i
|
||||
}
|
||||
}
|
||||
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
|
||||
b.eventsOf = b.current().id()
|
||||
b.openDetail()
|
||||
|
||||
sheet := stripEscapes(renderToPipe(t, b, b.renderDetail))
|
||||
for _, want := range []string{"issues", "consolidating", "task", "clone", "events", "esx03"} {
|
||||
if !strings.Contains(sheet, want) {
|
||||
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
b.menu = b.buildMenu(*b.current(), b.current().snaps)
|
||||
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
|
||||
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
|
||||
for _, want := range []string{"take a snapshot", "power off", "recent events",
|
||||
"ssh to the guest", "copy the address", "vSphere client"} {
|
||||
if !strings.Contains(menu, want) {
|
||||
t.Errorf("the menu does not show %q:\n%s", want, 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() {
|
||||
lines := sortLegend()
|
||||
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"}
|
||||
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 action menu's `h` runs; %h is the machine
|
||||
}
|
||||
|
||||
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
|
||||
@@ -66,6 +67,18 @@ func (v VCenter) missing() []string {
|
||||
return miss
|
||||
}
|
||||
|
||||
// password is the password to log in with, opened if it was sealed. Asked for
|
||||
// where it is used rather than when the file is read, so nothing is opened that
|
||||
// is not needed and a value that will not open is reported against the vCenter it
|
||||
// belongs to.
|
||||
func (v VCenter) password() (string, error) {
|
||||
secret, err := unseal(v.Password)
|
||||
if err != nil {
|
||||
return "", errf("%s: %w", v.Name, err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// skipVerify reports whether this server's certificate is to be taken on
|
||||
// trust. It defaults to off, which is the one behaviour change of the rewrite:
|
||||
// the old code passed insecure=true to every single connection, so a vCenter
|
||||
@@ -118,6 +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
|
||||
}
|
||||
@@ -145,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 {
|
||||
@@ -164,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, "; "))
|
||||
@@ -180,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
|
||||
@@ -221,11 +283,81 @@ func loadConfig() Config {
|
||||
m := parseConfig(string(data))
|
||||
applyConfig(&c, m)
|
||||
warnConfigPerms(path, m)
|
||||
sealPasswords(path, string(data))
|
||||
}
|
||||
applyEnv(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// sealPasswords rewrites any password still standing in the clear in the file,
|
||||
// and says which. Nothing else about the file changes: the key, the spacing, the
|
||||
// comments, the order and the blank lines are all left exactly as they were, and
|
||||
// a line that is already sealed or commented out is not touched.
|
||||
//
|
||||
// The rewrite goes through a file alongside and a rename, so that a gvm
|
||||
// interrupted here leaves the configuration whole rather than half of it.
|
||||
func sealPasswords(path, data string) {
|
||||
lines := strings.Split(data, "\n")
|
||||
var done []string
|
||||
|
||||
for i, ln := range lines {
|
||||
trimmed := strings.TrimLeft(ln, " \t")
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
sep := strings.IndexAny(trimmed, "=:")
|
||||
if sep < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimRight(trimmed[:sep], " \t"))
|
||||
f := vcenterFieldRe.FindStringSubmatch(key)
|
||||
if f == nil || f[2] != "password" {
|
||||
continue
|
||||
}
|
||||
|
||||
// The line is taken apart so that everything but the value can be put
|
||||
// back: what stood in front of it, and any comment behind it. A note
|
||||
// somebody wrote next to their password is theirs, not gvm's to delete.
|
||||
raw := trimmed[sep+1:]
|
||||
lead := len(raw) - len(strings.TrimLeft(raw, " \t"))
|
||||
body := raw[lead:]
|
||||
value := stripInlineComment(strings.TrimRight(body, " \t"))
|
||||
tail := body[len(value):]
|
||||
|
||||
if q := strings.Trim(value, "\"'"); q != value {
|
||||
value = q // a quoted password; the sealed word needs no quotes
|
||||
}
|
||||
if value == "" || sealed(value) {
|
||||
continue
|
||||
}
|
||||
word, err := seal(value)
|
||||
if err != nil {
|
||||
PE("could not seal the password of "+f[1], err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
indent := ln[:len(ln)-len(trimmed)]
|
||||
gap := trimmed[len(key):sep] // whatever alignment was there
|
||||
lines[i] = indent + trimmed[:len(key)] + gap + string(trimmed[sep]) + raw[:lead] + word + tail
|
||||
done = append(done, f[1])
|
||||
}
|
||||
if len(done) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), configMode); err != nil {
|
||||
PE("could not seal the passwords in "+path, err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
PE("could not seal the passwords in "+path, err.Error())
|
||||
return
|
||||
}
|
||||
PO(SF("password of %s sealed in %s", strings.Join(done, ", "), path))
|
||||
}
|
||||
|
||||
// parseConfig reads `key = value` (or `key: value`) lines, ignoring blank ones
|
||||
// and '#' comments. Keys are lower-cased, values unquoted.
|
||||
func parseConfig(s string) map[string]string {
|
||||
@@ -279,6 +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)
|
||||
}
|
||||
|
||||
@@ -344,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)
|
||||
}
|
||||
|
||||
@@ -421,6 +555,12 @@ func writeConfigTemplate(path string) {
|
||||
b.WriteString("# Format: 'key = value' (or 'key: value'); '#' starts a comment.\n")
|
||||
b.WriteString("# GVM_* environment variables override these settings.\n")
|
||||
b.WriteString("#\n")
|
||||
b.WriteString("# A password written here in the clear is sealed on the next run and\n")
|
||||
b.WriteString("# replaced by a 'gvmenc1:...' word, so it does not stand in this file\n")
|
||||
b.WriteString("# where a backup or a glance over your shoulder would pick it up.\n")
|
||||
b.WriteString("# 'gvm config -p <vcenter>' asks for one instead, and then it never\n")
|
||||
b.WriteString("# touches the disk unsealed at all.\n")
|
||||
b.WriteString("#\n")
|
||||
b.WriteString("# One 'vcenter.<name>.*' block per server. <name> is what -v selects.\n")
|
||||
b.WriteString("# 'insecure = true' skips certificate verification — needed for a vCenter\n")
|
||||
b.WriteString("# with a self-signed certificate, and the reason it is written down here\n")
|
||||
@@ -440,13 +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 action menu's 'h' logs in to a guest ---\n")
|
||||
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
|
||||
b.WriteString("# not written anywhere. Unset means plain 'ssh <machine>'.\n")
|
||||
b.WriteString("# ssh = ssh -l root %h\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
|
||||
PE("could not create "+path, err.Error())
|
||||
@@ -477,3 +621,85 @@ func contains(list []string, s string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setPassword asks for a vCenter's password and writes it into ~/.gvmrc sealed.
|
||||
//
|
||||
// The point of doing it here rather than in an editor: a password typed into the
|
||||
// file stands there in the clear until the next run of gvm seals it, and by then
|
||||
// it has been through the editor's swap file and whatever backs the home
|
||||
// directory up. Typed here it never touches the disk unsealed.
|
||||
func setPassword(cfg Config, name string) error {
|
||||
var target VCenter
|
||||
for _, v := range cfg.VCenters {
|
||||
if strings.EqualFold(v.Name, name) {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
if target.Name == "" {
|
||||
return errf("no vCenter called %q in %s", name, configFile())
|
||||
}
|
||||
if err := haveTerminal(); err != nil {
|
||||
return errf("a password has to be typed, and there is no terminal to type it on (%v)", err)
|
||||
}
|
||||
|
||||
secret := Inputpw(SF("password for %s (%s)", target.Name, target.User))
|
||||
if secret == "" {
|
||||
P("nothing done")
|
||||
return nil
|
||||
}
|
||||
if again := Inputpw("again"); again != secret {
|
||||
return errf("the two did not match — nothing written")
|
||||
}
|
||||
|
||||
word, err := seal(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSetting(configFile(), "vcenter."+target.Name+".password", word); err != nil {
|
||||
return err
|
||||
}
|
||||
PO(SF("password of %s sealed in %s", target.Name, configFile()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeSetting replaces one setting in the file and leaves everything else as it
|
||||
// was, appending it when it is not there yet. The same care as sealPasswords: a
|
||||
// file alongside and a rename, so an interrupted write leaves the configuration
|
||||
// whole.
|
||||
func writeSetting(path, key, value string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return errf("cannot read %s: %w", path, err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
written := false
|
||||
for i, ln := range lines {
|
||||
trimmed := strings.TrimLeft(ln, " \t")
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
sep := strings.IndexAny(trimmed, "=:")
|
||||
if sep < 0 || !strings.EqualFold(strings.TrimRight(trimmed[:sep], " \t"), key) {
|
||||
continue
|
||||
}
|
||||
indent := ln[:len(ln)-len(trimmed)]
|
||||
gap := trimmed[len(strings.TrimRight(trimmed[:sep], " \t")):sep]
|
||||
lines[i] = indent + trimmed[:sep-len(gap)] + gap + string(trimmed[sep]) + " " + value
|
||||
written = true
|
||||
break
|
||||
}
|
||||
if !written {
|
||||
lines = append(lines, key+" = "+value)
|
||||
}
|
||||
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")), configMode); err != nil {
|
||||
return errf("cannot write %s: %w", path, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return errf("cannot write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+192
@@ -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,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
|
||||
}
|
||||
@@ -4,10 +4,8 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/eknkc/basex v1.0.1
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/vmware/govmomi v0.53.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
@@ -18,8 +16,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
|
||||
@@ -7,8 +7,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY=
|
||||
github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -35,12 +33,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/vmware/govmomi v0.53.0 h1:e1bZCotAq7wm4xy95ePN2uoWwz28pNp/ewZZhpBY7/4=
|
||||
github.com/vmware/govmomi v0.53.0/go.mod h1:EWfuzPfxT5NV+aS2we02SLFdhvJkgeY7t7+TszgBSMY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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
|
||||
// sit in the harmless group at the foot of the action menu.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
|
||||
return strings.TrimSpace(g.HostName)
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
return ip
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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 = "ssh %h"
|
||||
}
|
||||
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.
|
||||
//
|
||||
// Deliberately not pbcopy or xclip: gvm is run over ssh as often as not, and a
|
||||
// local pbcopy would then copy an address into the clipboard of a machine
|
||||
// nobody is sitting at. This asks the terminal itself, which is the one program
|
||||
// in the chain that knows where the person is.
|
||||
func osc52(text string) string {
|
||||
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
err := cmd.Run()
|
||||
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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", "web01"}},
|
||||
{"ssh %h", []string{"ssh", "web01"}},
|
||||
{"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 menu says why an entry cannot be used rather than leaving it out, and the
|
||||
// two entries that need somewhere to connect to say exactly that.
|
||||
func TestTheMenuGreysOutWhatItCannotDo(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest = nil
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
|
||||
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
menu := b.buildMenu(r, nil)
|
||||
|
||||
for _, key := range []rune{'h', 'y'} {
|
||||
found := false
|
||||
for _, m := range menu {
|
||||
if m.key != key {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if m.available() {
|
||||
t.Errorf("%q is offered on a machine with no address", string(key))
|
||||
}
|
||||
if m.hint == "" || m.why == "" {
|
||||
t.Errorf("%q is greyed out without saying why", string(key))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the menu has no %q entry", string(key))
|
||||
}
|
||||
}
|
||||
|
||||
// With an address they are there to be used.
|
||||
r = testRow("web01", true, "10.0.0.5")
|
||||
for _, m := range b.buildMenu(r, nil) {
|
||||
if (m.key == 'h' || m.key == 'y') && !m.available() {
|
||||
t.Errorf("%q is greyed out on a machine with an address: %s", string(m.key), m.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every letter in the menu reaches exactly one entry, or one of them is
|
||||
// unreachable — and the harmless group must not have taken a letter the
|
||||
// dangerous half already uses.
|
||||
func TestMenuLettersAreDistinct(t *testing.T) {
|
||||
b := &browser{}
|
||||
seen := map[rune]string{}
|
||||
for _, m := range b.buildMenu(testRow("web01", true, "10.0.0.5"), nil) {
|
||||
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.1.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
|
||||
@@ -81,11 +94,19 @@ func run() error {
|
||||
subVM.Bool(&vmList, "l", "list", "Print the machines instead of browsing them")
|
||||
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, 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>")
|
||||
@@ -94,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")
|
||||
@@ -110,6 +137,11 @@ func run() error {
|
||||
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")
|
||||
@@ -118,16 +150,35 @@ func run() error {
|
||||
subLog.Bool(&logMail, "m", "mail", "Mail the log as well")
|
||||
subLog.Int(&logMinutes, "t", "time", "How many minutes back to look")
|
||||
|
||||
var cfgPassword string
|
||||
subConfig := flaggy.NewSubcommand("config")
|
||||
subConfig.Description = "Show the effective configuration"
|
||||
subConfig.String(&cfgPassword, "p", "password", "Set the password of <vcenter>, asked for and stored sealed")
|
||||
|
||||
flaggy.AttachSubcommand(subVM, 1)
|
||||
flaggy.AttachSubcommand(subSnap, 1)
|
||||
flaggy.AttachSubcommand(subPower, 1)
|
||||
flaggy.AttachSubcommand(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()
|
||||
@@ -138,12 +189,26 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if vmList { // -l prints; anything else browses
|
||||
return lsvm(targets, vmMatch)
|
||||
// --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
|
||||
@@ -208,15 +273,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("")
|
||||
@@ -231,6 +304,9 @@ func run() error {
|
||||
return vmlog(cfg, vc, logMinutes, logMail)
|
||||
|
||||
case subConfig.Used:
|
||||
if cfgPassword != "" {
|
||||
return setPassword(cfg, cfgPassword)
|
||||
}
|
||||
return showConfig(cfg)
|
||||
}
|
||||
|
||||
@@ -245,7 +321,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
|
||||
@@ -264,7 +340,7 @@ func showConfig(cfg Config) error {
|
||||
mark = Cgb(" (default)") + mark
|
||||
}
|
||||
PF("%-6s %s%s\n", Cwb(v.Name), v.URL, mark)
|
||||
PF(" user %s, datacenter %s, password set\n", v.User, v.Datacenter)
|
||||
PF(" user %s, datacenter %s, %s\n", v.User, v.Datacenter, passwordState(v))
|
||||
}
|
||||
for _, bad := range incomplete {
|
||||
PF("%s %s\n", Crb("unusable"), bad)
|
||||
@@ -276,10 +352,25 @@ 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
|
||||
}
|
||||
|
||||
// passwordState says what the password is without saying what it is. A sealed one
|
||||
// is opened and thrown away, because this is the command run after setting things
|
||||
// up and "it is sealed" is worth nothing if it does not open.
|
||||
func passwordState(v VCenter) string {
|
||||
if !sealed(v.Password) {
|
||||
return Cyb("password in the clear — it is sealed on the next run")
|
||||
}
|
||||
if _, err := v.password(); err != nil {
|
||||
return Crb("password sealed but it does not open — set it again with 'gvm config -p " + v.Name + "'")
|
||||
}
|
||||
return "password sealed"
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
|
||||
+17
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-8
@@ -9,9 +9,23 @@
|
||||
#
|
||||
# The file holds vCenter passwords, so it wants to be mode 0600 — gvm creates it
|
||||
# that way and complains when it finds it readable by others.
|
||||
#
|
||||
# A password written here in the clear is sealed on the next run of gvm and
|
||||
# replaced by a "gvmenc1:..." word, so it does not stand in this file where a
|
||||
# backup, a synced home directory or an editor's swap file would pick it up.
|
||||
# "gvm config -p v308" asks for one instead and writes it sealed straight away,
|
||||
# which is the way to set one without it ever being on disk in the clear.
|
||||
#
|
||||
# What that is: the password is not in plain sight. What it is not: a vault.
|
||||
# The key is compiled into gvm and is the same in every copy, so whoever holds
|
||||
# this file *and* a gvm binary can open the value. The 0600 is what keeps other
|
||||
# users out.
|
||||
|
||||
# The vCenter used when -v is not given. `gvm vm -l` ignores it and asks every
|
||||
# configured server; every other command works on exactly one.
|
||||
# The vCenter used when -v is not given. `gvm` and `gvm vm -l` ignore it and ask
|
||||
# every configured server; the rest work on exactly one.
|
||||
#
|
||||
# -v takes a list for the commands that sweep — `-v v308,v108` — and refuses one
|
||||
# for the commands that act on a single machine.
|
||||
default = v308
|
||||
|
||||
# --- one 'vcenter.<name>.*' block per server ---
|
||||
@@ -27,7 +41,7 @@ default = v308
|
||||
|
||||
vcenter.v308.url = https://v308.fhi.mpg.de/
|
||||
vcenter.v308.user = administrator@v308.fhi.mpg.de
|
||||
vcenter.v308.password = <password>
|
||||
vcenter.v308.password = <password> # sealed on the next run
|
||||
vcenter.v308.datacenter = PPB
|
||||
vcenter.v308.insecure = true
|
||||
|
||||
@@ -43,22 +57,32 @@ 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 action menu's 'h' ---
|
||||
# The command that logs in to a machine's guest from its sheet. "%h" is where
|
||||
# the guest's own hostname — or its address, when it reports no name — is put;
|
||||
# it is appended when %h is not written anywhere. Unset means plain "ssh <machine>".
|
||||
#
|
||||
# The target is always one argument and never goes through a shell: it is a name
|
||||
# the guest chose for itself, and gvm does not run it as a command.
|
||||
# ssh = ssh -l root %h
|
||||
|
||||
# --- 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,
|
||||
|
||||
@@ -35,40 +35,36 @@ func hoststat(vc VCenter, telemetry string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
P()
|
||||
P("Name CPU Used Mem Total Mem VM ON Status Connected")
|
||||
|
||||
printRow(hostColumns, "", nil) // the heading, from the same widths as the rows
|
||||
for _, host := range hosts {
|
||||
hn := shortHost(host.Name)
|
||||
total := len(host.Vm)
|
||||
on := countOn(host.Vm, running)
|
||||
|
||||
var usedMem, totalMem int64
|
||||
usedMem = int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
cpustr := " -"
|
||||
usedMem := int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
totalMem := int64(0)
|
||||
cpu, cpuKnown := 0.0, false
|
||||
if hw := host.Summary.Hardware; hw != nil {
|
||||
totalMem = hw.MemorySize
|
||||
if cpu, ok := cpuPercent(hw, host.Summary.QuickStats); ok {
|
||||
cpustr = colorPercent(cpu)
|
||||
}
|
||||
cpu, cpuKnown = cpuPercent(hw, host.Summary.QuickStats)
|
||||
}
|
||||
cpuText, cpuCol := "-", colOff
|
||||
if cpuKnown {
|
||||
cpuText, cpuCol = SF("%.2f", cpu), loadColor(cpu, true)
|
||||
}
|
||||
|
||||
PF("%4s %s %8s %8s %3d %3d %6s %s\n",
|
||||
Cwb(hn),
|
||||
cpustr,
|
||||
units.ByteSize(usedMem),
|
||||
units.ByteSize(totalMem),
|
||||
total,
|
||||
on,
|
||||
colorStatus(host.OverallStatus),
|
||||
host.Runtime.ConnectionState,
|
||||
)
|
||||
printRow(hostColumns, "", []cell{
|
||||
{hn, cWhite.fg()},
|
||||
{cpuText, cpuCol},
|
||||
{units.ByteSize(usedMem).String(), colSize},
|
||||
{units.ByteSize(totalMem).String(), colSize},
|
||||
{Itoa(total), colSize},
|
||||
{Itoa(on), colSize},
|
||||
{string(host.OverallStatus), statusColor(host.OverallStatus)},
|
||||
{string(host.Runtime.ConnectionState), colAside},
|
||||
})
|
||||
|
||||
if telemetry != "" {
|
||||
cpu := 0.0
|
||||
if hw := host.Summary.Hardware; hw != nil {
|
||||
cpu, _ = cpuPercent(hw, host.Summary.QuickStats)
|
||||
}
|
||||
post(telemetry, SF("vm,%s,%.2f,%d,%d,%d,%d,%s,%s",
|
||||
hn, cpu, usedMem, totalMem, total, on,
|
||||
host.OverallStatus, host.Runtime.ConnectionState))
|
||||
@@ -95,10 +91,13 @@ func vmstat(vc VCenter) error {
|
||||
return err
|
||||
}
|
||||
|
||||
PF("%-25s %-10s %-10s\n", "Host", "Total VMs", "PoweredOn")
|
||||
P(SR("-", 47))
|
||||
printRow(countColumns, "", nil)
|
||||
for _, host := range hosts {
|
||||
PF("%-25s %-10d %-10d\n", host.Name, len(host.Vm), countOn(host.Vm, running))
|
||||
printRow(countColumns, "", []cell{
|
||||
{host.Name, cWhite.fg()},
|
||||
{Itoa(len(host.Vm)), colSize},
|
||||
{Itoa(countOn(host.Vm, running)), colSize},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -143,30 +142,17 @@ func cpuPercent(hw *types.HostHardwareSummary, qs types.HostListSummaryQuickStat
|
||||
return 100.0 / float64(totalMHz) * float64(qs.OverallCpuUsage), true
|
||||
}
|
||||
|
||||
func colorPercent(p float64) string {
|
||||
s := SF("%6.2f", p)
|
||||
switch {
|
||||
case p > 90:
|
||||
return Crb(s)
|
||||
case p > 50:
|
||||
return Cyb(s)
|
||||
case p > 2:
|
||||
return Cwb(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func colorStatus(st types.ManagedEntityStatus) string {
|
||||
s := string(st)
|
||||
switch s {
|
||||
// statusColor: vSphere's own words for how a host is doing, in the palette's.
|
||||
func statusColor(st types.ManagedEntityStatus) string {
|
||||
switch string(st) {
|
||||
case "green":
|
||||
return Cgb(s)
|
||||
return colOK
|
||||
case "yellow":
|
||||
return Cyb(s)
|
||||
return colBusy
|
||||
case "red":
|
||||
return Crb(s)
|
||||
return colFull
|
||||
}
|
||||
return s
|
||||
return colAside
|
||||
}
|
||||
|
||||
// shortHost is the hostname without its domain, which is all the first column
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,9 @@ func vmlog(cfg Config, vc VCenter, minutes int, mail bool) error {
|
||||
return fmt.Errorf("%s: cannot read the event log: %w", vc.Name, err)
|
||||
}
|
||||
|
||||
PF("%-20s | %-15s | %s\n", "Time", "Severity", "Message")
|
||||
P(SR("-", 80))
|
||||
// No heading and no rule: the lines below are comma-separated on purpose —
|
||||
// they are what the mail carries and what a script would cut up — and a
|
||||
// pipe-separated heading above them promised a table that never came.
|
||||
|
||||
msg := ""
|
||||
for _, e := range events {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// print.go — columns for the commands that print rather than draw.
|
||||
//
|
||||
// The one rule: pad first, colour afterwards. A colour is a handful of escape
|
||||
// bytes that occupy no columns on screen, but %8s counts them all the same — so a
|
||||
// coloured value handed to a width verb is never padded, and any value longer
|
||||
// than its column shoves everything after it to the right. `gvm host` did exactly
|
||||
// that: a host called DC0_C0_H0 shifted its whole line three columns against the
|
||||
// one above it, and the heading, being written by hand, lined up with neither.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// printColumn is one column of a printed table: what it is called, how wide it
|
||||
// is, and whether its values are numbers, which read better against the right.
|
||||
type printColumn struct {
|
||||
header string
|
||||
width int
|
||||
right bool
|
||||
}
|
||||
|
||||
// cell is one value with the colour it is shown in.
|
||||
type cell struct {
|
||||
text string
|
||||
col string
|
||||
}
|
||||
|
||||
var hostColumns = []printColumn{
|
||||
{header: "HOST", width: 10},
|
||||
{header: "CPU%", width: 6, right: true},
|
||||
{header: "USED MEM", width: 9, right: true},
|
||||
{header: "TOTAL MEM", width: 9, right: true},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "ON", width: 4, right: true},
|
||||
{header: "STATUS", width: 7},
|
||||
{header: "CONNECTED", width: 12},
|
||||
}
|
||||
|
||||
var countColumns = []printColumn{
|
||||
{header: "HOST", width: 24},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "ON", width: 4, right: true},
|
||||
}
|
||||
|
||||
// printRow writes one row, or the heading when cells is nil. Every value is
|
||||
// padded to its column as plain text and only then coloured, so a long value is
|
||||
// cut instead of pushing its neighbours along.
|
||||
func printRow(cols []printColumn, indent string, cells []cell) {
|
||||
out := make([]string, 0, len(cols))
|
||||
for i, c := range cols {
|
||||
text, col := c.header, colHeader
|
||||
if cells != nil {
|
||||
if i >= len(cells) {
|
||||
break
|
||||
}
|
||||
text, col = cells[i].text, cells[i].col
|
||||
}
|
||||
out = append(out, paint(pad(text, c.width, c.right), col))
|
||||
}
|
||||
PF("%s%s\n", indent, strings.TrimRight(strings.Join(out, " "), " "))
|
||||
}
|
||||
|
||||
// widen grows every column to the longest thing it holds, header included. For
|
||||
// output that is going somewhere without a width of its own, where cutting a
|
||||
// value would lose it rather than merely hide it.
|
||||
func widen(pcs []printColumn, body [][]cell) {
|
||||
for i := range pcs {
|
||||
w := len([]rune(pcs[i].header))
|
||||
for _, cells := range body {
|
||||
if i >= len(cells) {
|
||||
continue
|
||||
}
|
||||
if n := len([]rune(cells[i].text)); n > w {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
pcs[i].width = w
|
||||
}
|
||||
}
|
||||
|
||||
// pad fits text to width — truncating what is too long — against the left or,
|
||||
// for numbers, the right.
|
||||
func pad(text string, width int, right bool) string {
|
||||
t := truncate(text, width)
|
||||
gap := width - len([]rune(t))
|
||||
if gap <= 0 {
|
||||
return t
|
||||
}
|
||||
if right {
|
||||
return SR(" ", gap) + t
|
||||
}
|
||||
return t + SR(" ", gap)
|
||||
}
|
||||
|
||||
// paint colours text, but only where colour belongs. The escapes the interactive
|
||||
// screen uses are written straight out and would otherwise end up in a pipe;
|
||||
// fatih/color already works out whether that is the case, so its answer is the
|
||||
// one used here rather than a second opinion.
|
||||
func paint(text, col string) string {
|
||||
if col == "" || color.NoColor {
|
||||
return text
|
||||
}
|
||||
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,126 @@
|
||||
// seal.go — the passwords in ~/.gvmrc, not in plain sight.
|
||||
//
|
||||
// A sealed value looks like this, and the rest of the file stays as it was:
|
||||
//
|
||||
// vcenter.v308.password = gvmenc1:Lb2h…
|
||||
//
|
||||
// Only the value is sealed, never the file: urls, users, datacenters and the mail
|
||||
// settings stay readable and the file stays editable by hand, comments and all.
|
||||
// AES-256-GCM, the key derived per value with HKDF from a random salt, all of it
|
||||
// packed into one base64 word.
|
||||
//
|
||||
// What this is and is not, plainly. FILEKEY is compiled into gvm and is the same
|
||||
// in every copy of it, so whoever holds ~/.gvmrc *and* a gvm binary can open the
|
||||
// value; prising the key out is an afternoon's work, not a cluster's. This is not
|
||||
// a vault and it is not meant to be one. What it buys is that the password no
|
||||
// longer stands in the clear in a backup, in a home directory that syncs
|
||||
// somewhere, in an editor's swap file, or on a screen someone else is looking
|
||||
// at — which is what was asked for. The file stays 0600 for the rest.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FILEKEY is what the values in ~/.gvmrc are sealed under: thirty-two random
|
||||
// bytes, the same in every build so that a file written by one gvm opens in the
|
||||
// next. There is nothing to guess here and so no reason to slow a guesser down —
|
||||
// HKDF, not argon2, and a value opens in microseconds.
|
||||
//
|
||||
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
|
||||
// Values written by earlier builds then no longer open, and gvm says so and names
|
||||
// the vCenter whose password has to be entered again.
|
||||
var FILEKEY = "8Vb0MUm04VP/aOZTTSGcqdN9NbNC6CETAhSXu1hwbIk="
|
||||
|
||||
const (
|
||||
// sealTag marks a sealed value and leaves room to tell it apart from whatever
|
||||
// a later version writes, should the scheme ever have to change.
|
||||
sealTag = "gvmenc1:"
|
||||
saltLen = 16
|
||||
keyLen = 32
|
||||
sealInfo = "gvmrc password"
|
||||
)
|
||||
|
||||
// sealed reports whether a value is one, which is how gvm knows a password in the
|
||||
// file still stands in the clear and wants sealing.
|
||||
func sealed(value string) bool { return strings.HasPrefix(value, sealTag) }
|
||||
|
||||
// seal turns a password into the word that goes in the file.
|
||||
func seal(secret string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", errf("cannot seal the password: %w", err)
|
||||
}
|
||||
gcm, err := sealGCM(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", errf("cannot seal the password: %w", err)
|
||||
}
|
||||
|
||||
// salt, nonce and the sealed bytes travel together: opening it needs all
|
||||
// three and nothing else, so one word in the file is the whole story.
|
||||
blob := append(salt, nonce...)
|
||||
blob = gcm.Seal(blob, nonce, []byte(secret), nil)
|
||||
return sealTag + base64.StdEncoding.EncodeToString(blob), nil
|
||||
}
|
||||
|
||||
// unseal turns it back. A value that is not sealed comes back unchanged: that is
|
||||
// how a password typed straight into the file, or handed over in the environment,
|
||||
// keeps working.
|
||||
func unseal(value string) (string, error) {
|
||||
if !sealed(value) {
|
||||
return value, nil
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, sealTag))
|
||||
if err != nil {
|
||||
return "", errf("the sealed password is not readable: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := sealGCM(nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(blob) < saltLen+gcm.NonceSize() {
|
||||
return "", errf("the sealed password is too short to be one")
|
||||
}
|
||||
salt, rest := blob[:saltLen], blob[saltLen:]
|
||||
nonce, box := rest[:gcm.NonceSize()], rest[gcm.NonceSize():]
|
||||
|
||||
gcm, err = sealGCM(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, err := gcm.Open(nil, nonce, box, nil)
|
||||
if err != nil {
|
||||
return "", errf("the sealed password does not open — it was sealed by a gvm " +
|
||||
"built with another key, or it has been altered; enter it again")
|
||||
}
|
||||
return string(secret), nil
|
||||
}
|
||||
|
||||
// sealGCM derives the key for one value and wraps it. A nil salt is allowed so a
|
||||
// caller may ask for the nonce size before it knows the salt.
|
||||
func sealGCM(salt []byte) (cipher.AEAD, error) {
|
||||
root, err := base64.StdEncoding.DecodeString(FILEKEY)
|
||||
if err != nil || len(root) == 0 {
|
||||
return nil, errf("this gvm was built without a usable key for sealing passwords")
|
||||
}
|
||||
key, err := hkdf.Key(sha256.New, root, salt, sealInfo, keyLen)
|
||||
if err != nil {
|
||||
return nil, errf("cannot derive the key: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, errf("cannot derive the key: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealRoundTrip(t *testing.T) {
|
||||
for _, secret := range []string{
|
||||
"hunter2",
|
||||
"",
|
||||
"mit Leerzeichen und Ümläuten",
|
||||
"a/b+c=d", // the characters base64 uses, to be sure nothing is confused
|
||||
"gvmenc1:nearly", // a password that looks like a sealed value
|
||||
strings.Repeat("x", 500),
|
||||
} {
|
||||
word, err := seal(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", secret, err)
|
||||
}
|
||||
if !sealed(word) {
|
||||
t.Errorf("%q sealed to something unmarked: %q", secret, word)
|
||||
}
|
||||
if secret != "" && strings.Contains(word, secret) {
|
||||
t.Errorf("%q is readable in its own sealed form: %q", secret, word)
|
||||
}
|
||||
back, err := unseal(word)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", secret, err)
|
||||
}
|
||||
if back != secret {
|
||||
t.Errorf("came back as %q, want %q", back, secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh salt and nonce each time, so two machines with the same password do not
|
||||
// show the same word in the file — which would say they share one.
|
||||
func TestSealIsDifferentEveryTime(t *testing.T) {
|
||||
a, _ := seal("gleich")
|
||||
b, _ := seal("gleich")
|
||||
if a == b {
|
||||
t.Error("the same password sealed twice gave the same word")
|
||||
}
|
||||
}
|
||||
|
||||
// A value that is not sealed is handed back as it is: that is how a password
|
||||
// typed straight into the file, or given in the environment, keeps working.
|
||||
func TestUnsealLeavesPlainValuesAlone(t *testing.T) {
|
||||
for _, plain := range []string{"hunter2", "", "gvmenc", "gvmenc1"} {
|
||||
got, err := unseal(plain)
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", plain, err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Errorf("%q came back as %q", plain, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Something that says it is sealed and is not must be an error, never an empty
|
||||
// password — that would reach vCenter and look like the wrong one.
|
||||
func TestBrokenSealIsAnError(t *testing.T) {
|
||||
good, _ := seal("hunter2")
|
||||
|
||||
for _, c := range []struct{ value, note string }{
|
||||
{sealTag + "not base64 at all!!", "not base64"},
|
||||
{sealTag, "nothing after the tag"},
|
||||
{sealTag + "c2hvcnQ=", "too short to hold a salt"},
|
||||
{good[:len(good)-4] + "AAAA", "altered"},
|
||||
} {
|
||||
got, err := unseal(c.value)
|
||||
if err == nil {
|
||||
t.Errorf("%s: opened to %q instead of failing", c.note, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("%s: gave back %q as well as an error", c.note, got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the message says what to do about it.
|
||||
if _, err := unseal(good[:len(good)-4] + "AAAA"); err == nil ||
|
||||
!strings.Contains(err.Error(), "enter it again") {
|
||||
t.Errorf("the message does not say what to do: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The vCenter's own accessor names itself in the error, so a file with three
|
||||
// servers says which one is the trouble.
|
||||
func TestVCenterPasswordNamesItself(t *testing.T) {
|
||||
word, _ := seal("hunter2")
|
||||
v := VCenter{Name: "v308", Password: word}
|
||||
if got, err := v.password(); err != nil || got != "hunter2" {
|
||||
t.Errorf("password() gave %q, %v", got, err)
|
||||
}
|
||||
|
||||
broken := VCenter{Name: "v308", Password: sealTag + "rubbish"}
|
||||
_, err := broken.password()
|
||||
if err == nil {
|
||||
t.Fatal("a broken seal came back without an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "v308") {
|
||||
t.Errorf("the error does not name the vCenter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The file rewriting. Everything but the password itself has to survive.
|
||||
func TestSealPasswordsRewritesOnlyTheSecret(t *testing.T) {
|
||||
const before = `# my configuration
|
||||
default = v308
|
||||
|
||||
vcenter.v308.url = https://v308.example/
|
||||
vcenter.v308.user = administrator@v308
|
||||
vcenter.v308.password = hunter2 # the password
|
||||
vcenter.v308.datacenter = PPB
|
||||
|
||||
vcenter.v108.password = "with spaces"
|
||||
vcenter.v108.user = admin
|
||||
|
||||
# vcenter.old.password = leave-me-alone
|
||||
mailto = me@example.org
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".gvmrc")
|
||||
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
quiet(t)
|
||||
sealPasswords(path, before)
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(after)
|
||||
|
||||
// The secrets are gone and the rest is untouched, line for line.
|
||||
for _, gone := range []string{"= hunter2", "with spaces"} {
|
||||
if strings.Contains(got, gone) {
|
||||
t.Errorf("%q still stands in the clear:\n%s", gone, got)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{
|
||||
"# my configuration", "default = v308",
|
||||
"vcenter.v308.url = https://v308.example/",
|
||||
"vcenter.v308.user = administrator@v308",
|
||||
"vcenter.v308.datacenter = PPB",
|
||||
"# the password", // the note beside it is the user's
|
||||
"# vcenter.old.password = leave-me-alone", // a commented-out line is not a setting
|
||||
"vcenter.v108.user = admin",
|
||||
"mailto = me@example.org",
|
||||
} {
|
||||
if !strings.Contains(got, kept) {
|
||||
t.Errorf("the rewrite lost %q:\n%s", kept, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, "\n") != strings.Count(before, "\n") {
|
||||
t.Errorf("the number of lines changed:\n%s", got)
|
||||
}
|
||||
|
||||
// Both passwords open again, and to what they were.
|
||||
cfg := Config{}
|
||||
applyConfig(&cfg, parseConfig(got))
|
||||
want := map[string]string{"v308": "hunter2", "v108": "with spaces"}
|
||||
for _, v := range cfg.VCenters {
|
||||
if !sealed(v.Password) {
|
||||
t.Errorf("%s was not sealed", v.Name)
|
||||
continue
|
||||
}
|
||||
if secret, err := v.password(); err != nil || secret != want[v.Name] {
|
||||
t.Errorf("%s opens to %q, %v — want %q", v.Name, secret, err, want[v.Name])
|
||||
}
|
||||
}
|
||||
|
||||
// And a second pass changes nothing at all.
|
||||
sealPasswords(path, got)
|
||||
again, _ := os.ReadFile(path)
|
||||
if string(again) != got {
|
||||
t.Errorf("sealing twice changed the file the second time:\n%s", string(again))
|
||||
}
|
||||
|
||||
// The file it writes is still readable by nobody else.
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the rewritten file is mode %04o", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSettingReplacesOrAppends(t *testing.T) {
|
||||
const before = `# top
|
||||
vcenter.v308.user = admin
|
||||
vcenter.v308.password = old
|
||||
mailto = me@example.org
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".gvmrc")
|
||||
if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeSetting(path, "vcenter.v308.password", "new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readFile(t, path)
|
||||
if !strings.Contains(got, "vcenter.v308.password = new") {
|
||||
t.Errorf("the setting was not replaced:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "= old") {
|
||||
t.Errorf("the old value is still there:\n%s", got)
|
||||
}
|
||||
for _, kept := range []string{"# top", "vcenter.v308.user = admin", "mailto = me@example.org"} {
|
||||
if !strings.Contains(got, kept) {
|
||||
t.Errorf("writing lost %q:\n%s", kept, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A setting that is not there yet is added rather than lost.
|
||||
if err := writeSetting(path, "vcenter.v108.password", "brandnew"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, path); !strings.Contains(got, "vcenter.v108.password = brandnew") {
|
||||
t.Errorf("a new setting was not added:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// What `gvm config` says about a password, without saying the password.
|
||||
func TestPasswordState(t *testing.T) {
|
||||
word, _ := seal("hunter2")
|
||||
|
||||
for _, c := range []struct {
|
||||
v VCenter
|
||||
want string
|
||||
note string
|
||||
}{
|
||||
{VCenter{Name: "a", Password: word}, "password sealed", "a sealed one"},
|
||||
{VCenter{Name: "b", Password: "hunter2"}, "in the clear", "one still in the clear"},
|
||||
{VCenter{Name: "c", Password: sealTag + "rubbish"}, "does not open", "one that will not open"},
|
||||
} {
|
||||
got := stripEscapes(passwordState(c.v))
|
||||
if !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: %q does not say %q", c.note, got, c.want)
|
||||
}
|
||||
if strings.Contains(got, "hunter2") {
|
||||
t.Errorf("%s: the password itself is in the output: %q", c.note, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The claim of this whole file: what goes on the wire is the *opened* password,
|
||||
// never the sealed word out of ~/.gvmrc.
|
||||
//
|
||||
// Checked at loginURL rather than against a server, because govmomi's simulator
|
||||
// accepts any non-empty password by default — a login that succeeds there proves
|
||||
// nothing at all about which password was sent.
|
||||
func TestLoginURLCarriesTheOpenedPassword(t *testing.T) {
|
||||
const secret = "the-real-one"
|
||||
word, err := seal(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(word, secret) {
|
||||
t.Fatal("the sealed word contains the password, so this would prove nothing")
|
||||
}
|
||||
|
||||
v := VCenter{
|
||||
Name: "v308", URL: "https://v308.example/",
|
||||
User: "administrator@v308", Password: word,
|
||||
}
|
||||
u, err := loginURL(v)
|
||||
if err != nil {
|
||||
t.Fatalf("loginURL: %v", err)
|
||||
}
|
||||
|
||||
got, ok := u.User.Password()
|
||||
if !ok {
|
||||
t.Fatal("the url carries no password at all")
|
||||
}
|
||||
if got != secret {
|
||||
t.Errorf("the url carries %q, want the opened password", got)
|
||||
}
|
||||
if got == word {
|
||||
t.Error("the sealed word itself was put in the url")
|
||||
}
|
||||
if u.User.Username() != "administrator@v308" {
|
||||
t.Errorf("the user is %q", u.User.Username())
|
||||
}
|
||||
if u.String() == "" || !strings.HasSuffix(u.Path, "/sdk") {
|
||||
t.Errorf("the endpoint is %q", u.Path)
|
||||
}
|
||||
|
||||
// A password still in the clear goes through untouched, so a file nobody has
|
||||
// let gvm rewrite yet keeps working.
|
||||
plain := v
|
||||
plain.Password = "still-plain"
|
||||
u, err = loginURL(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := u.User.Password(); got != "still-plain" {
|
||||
t.Errorf("a plain password came through as %q", got)
|
||||
}
|
||||
|
||||
// And one that will not open never gets as far as a url.
|
||||
broken := v
|
||||
broken.Password = sealTag + "rubbish"
|
||||
if u, err := loginURL(broken); err == nil {
|
||||
got, _ := u.User.Password()
|
||||
t.Errorf("a broken seal produced a url carrying %q", got)
|
||||
} else if !strings.Contains(err.Error(), "v308") {
|
||||
t.Errorf("the error does not name the vCenter: %v", err)
|
||||
}
|
||||
}
|
||||
+491
-12
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -30,6 +31,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
|
||||
@@ -85,7 +92,8 @@ func powerOf(t *testing.T, s *session, ref types.ManagedObjectReference) types.V
|
||||
// oneRow connects and returns the machine of that name as the list would hold it.
|
||||
func oneRow(t *testing.T, vc VCenter, name string) (*session, vmRow) {
|
||||
t.Helper()
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
@@ -116,7 +124,8 @@ func TestSimGatherVMs(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -160,7 +169,8 @@ func TestSimBrowseAndOpenDetail(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -220,7 +230,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
@@ -235,7 +246,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// The sheet has to show it now.
|
||||
rows, sessions, err = gatherVMs([]VCenter{vc})
|
||||
found, err = gatherVMs([]VCenter{vc})
|
||||
rows, sessions = found.rows, found.sessions
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
@@ -264,10 +276,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
|
||||
vc := simVCenter(t)
|
||||
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
|
||||
|
||||
if err := lsvm([]VCenter{vc}, ""); err != nil {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{}); err != nil {
|
||||
t.Errorf("lsvm: %v", err)
|
||||
}
|
||||
if err := lsvm([]VCenter{vc}, "DC0"); 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 {
|
||||
@@ -281,7 +293,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("); err == nil {
|
||||
if err := lsvm([]VCenter{vc}, lsOptions{match: "web("}); err == nil {
|
||||
t.Error("lsvm accepted a broken pattern")
|
||||
}
|
||||
}
|
||||
@@ -321,7 +333,8 @@ func TestSimInteractiveSnapshot(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -642,7 +655,8 @@ func TestSimBrowserDestructiveNeedsTheName(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -734,7 +748,8 @@ func TestSimBrowserPowerOffNeedsTheName(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -800,7 +815,8 @@ func TestSimMenuRereadsTheRow(t *testing.T) {
|
||||
quiet(t)
|
||||
vc, model := simVCenterModel(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -882,3 +898,466 @@ func TestBrowserSaysWhenTheRowCannotBeReread(t *testing.T) {
|
||||
t.Error("a stale row is not flagged as a warning")
|
||||
}
|
||||
}
|
||||
|
||||
// A vCenter that does not answer has to be visible in the list that is meant to
|
||||
// show every vCenter. It used to be printed and then wiped by the first screen,
|
||||
// while the title went on naming the server as though its machines were there.
|
||||
func TestSimUnreachableVCenterIsNamed(t *testing.T) {
|
||||
quiet(t)
|
||||
live := simVCenter(t)
|
||||
dead := VCenter{
|
||||
Name: "dead", URL: "http://127.0.0.1:9/",
|
||||
User: "user", Password: "pass", Datacenter: "DC0",
|
||||
}
|
||||
|
||||
found, err := gatherVMs([]VCenter{live, dead})
|
||||
defer closeSessions(found.sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("one server answering should not be a failure: %v", err)
|
||||
}
|
||||
if len(found.rows) == 0 {
|
||||
t.Fatal("the machines of the server that answered were thrown away")
|
||||
}
|
||||
if strings.Join(found.answered, ",") != "sim" {
|
||||
t.Errorf("answered: %v, want only sim", found.answered)
|
||||
}
|
||||
if strings.Join(found.lost, ",") != "dead" {
|
||||
t.Errorf("lost: %v, want only dead", found.lost)
|
||||
}
|
||||
if len(found.failed) != 1 || !strings.Contains(found.failed[0], "dead") {
|
||||
t.Errorf("the reason does not name the server: %v", found.failed)
|
||||
}
|
||||
|
||||
// The title names one as holding the machines and the other as missing, and
|
||||
// does not claim the dead one holds anything.
|
||||
b := &browser{targets: []VCenter{live, dead}, rows: found.rows,
|
||||
answered: found.answered, lost: found.lost}
|
||||
b.applySort()
|
||||
|
||||
t.Setenv("COLUMNS", "120")
|
||||
t.Setenv("LINES", "14")
|
||||
frame := stripEscapes(renderToPipe(t, b, b.renderList))
|
||||
title := strings.SplitN(frame, "\r\n", 2)[0]
|
||||
|
||||
if !strings.Contains(title, "on sim") {
|
||||
t.Errorf("the title does not say whose machines these are: %q", title)
|
||||
}
|
||||
if !strings.Contains(title, "dead unreachable") {
|
||||
t.Errorf("the title does not say that dead is missing: %q", title)
|
||||
}
|
||||
if strings.Contains(title, "on dead") || strings.Contains(title, "sim, dead") {
|
||||
t.Errorf("the title still claims the list covers dead: %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
// Every vCenter failing is a failure of the command; one failing is not.
|
||||
func TestSimAllVCentersUnreachable(t *testing.T) {
|
||||
quiet(t)
|
||||
dead := func(name, port string) VCenter {
|
||||
return VCenter{Name: name, URL: "http://127.0.0.1:" + port + "/",
|
||||
User: "u", Password: "p", Datacenter: "DC0"}
|
||||
}
|
||||
found, err := gatherVMs([]VCenter{dead("a", "9"), dead("b", "9")})
|
||||
closeSessions(found.sessions)
|
||||
if err == nil {
|
||||
t.Fatal("no server answered and that was not an error")
|
||||
}
|
||||
if len(found.failed) != 2 {
|
||||
t.Errorf("%d reasons for two failures", len(found.failed))
|
||||
}
|
||||
if len(found.answered) != 0 {
|
||||
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 the action menu's 'e' 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
@@ -85,10 +86,23 @@ func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
|
||||
}
|
||||
if err := task.Wait(s.ctx); err != nil {
|
||||
return fmt.Errorf("%s: the snapshot %s failed: %w", s.vc.Name, name, err)
|
||||
// Bounded, like every other task gvm waits for. This one was not: it waited
|
||||
// on the session's own context, which has no deadline, so a snapshot that
|
||||
// vCenter never finished froze the interactive list with the screen mid-draw
|
||||
// and no key being read — the one place where waiting for ever is worst.
|
||||
return waitTask(s.ctx, task, snapshotWait, SF("snapshot %s of %s", name, vmName(s, ref)))
|
||||
}
|
||||
|
||||
// vmName is the machine's name for a message, from its reference alone. Cheap:
|
||||
// one property, and only asked for when something has gone wrong enough to be
|
||||
// worth naming.
|
||||
func vmName(s *session, ref types.ManagedObjectReference) string {
|
||||
var mvm mo.VirtualMachine
|
||||
if err := object.NewVirtualMachine(s.client.Client, ref).
|
||||
Properties(s.ctx, ref, []string{"name"}, &mvm); err != nil {
|
||||
return ref.Value
|
||||
}
|
||||
return nil
|
||||
return mvm.Name
|
||||
}
|
||||
|
||||
// snapRemove removes one snapshot, with its children left where they are, and
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
// sort.go — the order the table is in.
|
||||
//
|
||||
// One key opens a legend on the status line and the next key picks the order, so
|
||||
// the list stays on screen while it rearranges itself in front of you. Ordinary
|
||||
// letters cannot be used on their own: the list's filter swallows those.
|
||||
//
|
||||
// Every order has a natural direction, because that is what asking for it means.
|
||||
// Sorting by name means a to z; sorting by processor load means the busiest
|
||||
// first, and having to reverse it every time would be a nuisance dressed up as
|
||||
// consistency. `r` reverses whatever is current.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sortOrder is one way of arranging the table.
|
||||
type sortOrder struct {
|
||||
key rune // the letter that picks it
|
||||
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, 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,
|
||||
cmp: func(a, b vmRow) int { return cmpInt(powerRank(a), powerRank(b)) }},
|
||||
{key: 'c', name: "cpu load", natural: true,
|
||||
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) }},
|
||||
// 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 is not a letter because every letter with a mnemonic in it is
|
||||
// taken, and a hash is what a count is written with anyway. `--sort
|
||||
// snapshots` on the command line needs no shell quoting.
|
||||
{key: '#', name: "snapshots", natural: true,
|
||||
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))
|
||||
}},
|
||||
{key: 'u', name: "processors", natural: true,
|
||||
cmp: func(a, b vmRow) int {
|
||||
return cmpInt(int(a.vm.Summary.Config.NumCpu), int(b.vm.Summary.Config.NumCpu))
|
||||
}},
|
||||
{key: 'v', name: "vcenter", cmp: func(a, b vmRow) int { return cmpText(a.vc.Name, b.vc.Name) }},
|
||||
{key: 'h', name: "host", cmp: func(a, b vmRow) int { return cmpText(a.host, b.host) }},
|
||||
{key: 'a', name: "address", cmp: func(a, b vmRow) int { return cmpAddress(a, b) }},
|
||||
}
|
||||
|
||||
// sortReverse is the one legend entry that is not an order of its own.
|
||||
const sortReverse = 'r'
|
||||
|
||||
// powerRank puts a running machine above a suspended one above a stopped one, so
|
||||
// that "by power" means what an operator means by it.
|
||||
func powerRank(r vmRow) int {
|
||||
switch r.powerShort() {
|
||||
case "on":
|
||||
return 3
|
||||
case "susp":
|
||||
return 2
|
||||
case "off":
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmpText(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }
|
||||
|
||||
func cmpInt(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// A value that is not there is not a small value. Wherever one can be missing —
|
||||
// a load figure on a stopped machine, an address on a machine whose guest is not
|
||||
// talking — the machine belongs at the *bottom* of the list, and which end of the
|
||||
// comparison that is depends on which way the order naturally runs.
|
||||
//
|
||||
// Load runs downwards by nature (busiest first), so an unknown load has to
|
||||
// compare as the smallest. An address runs upwards (a to z), so a missing address
|
||||
// has to compare as the largest. Same rule, opposite polarity; the two functions
|
||||
// below say so where it can be checked.
|
||||
|
||||
// cmpLoad orders two machines by a load figure, unknown lowest — which puts it
|
||||
// last under the busiest-first direction this order is asked for with.
|
||||
func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
|
||||
x, xok := load(a)
|
||||
y, yok := load(b)
|
||||
switch {
|
||||
case !xok && !yok:
|
||||
return 0
|
||||
case !xok:
|
||||
return -1
|
||||
case !yok:
|
||||
return 1
|
||||
case x < y:
|
||||
return -1
|
||||
case x > y:
|
||||
return 1
|
||||
}
|
||||
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 {
|
||||
x, y := a.ip(), b.ip()
|
||||
switch {
|
||||
case x == "-" && y == "-":
|
||||
return 0
|
||||
case x == "-":
|
||||
return 1
|
||||
case y == "-":
|
||||
return -1
|
||||
}
|
||||
return cmpText(x, y)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the sorting
|
||||
|
||||
func (b *browser) order() sortOrder { return sortOrders[b.sortBy] }
|
||||
|
||||
// applySort rearranges the rows and rebuilds what is on screen. The selection
|
||||
// follows the machine it was on, which refilter already sees to.
|
||||
//
|
||||
// Machines that compare equal are left in name order, always ascending, whichever
|
||||
// way the sort itself runs: a screen full of machines all at 0 % that reshuffles
|
||||
// when the direction is flipped would look like the numbers had changed.
|
||||
func (b *browser) applySort() {
|
||||
sortRows(b.rows, b.sortBy, b.sortDesc)
|
||||
b.refilter()
|
||||
}
|
||||
|
||||
// sortRows is the sorting itself, without a browser: `gvm vm -l` orders the same
|
||||
// rows the same way.
|
||||
func sortRows(rows []vmRow, by int, desc bool) {
|
||||
o := sortOrders[by]
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
x, y := rows[i], rows[j]
|
||||
n := o.cmp(x, y)
|
||||
if desc {
|
||||
n = -n
|
||||
}
|
||||
if n != 0 {
|
||||
return n < 0
|
||||
}
|
||||
return cmpText(x.name, y.name) < 0
|
||||
})
|
||||
}
|
||||
|
||||
// findOrder resolves what was asked for on the command line — a letter or a name,
|
||||
// as the legend spells them — to one of the orders. Empty means the first, which
|
||||
// is by name.
|
||||
func findOrder(s string) (int, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
for i, o := range sortOrders {
|
||||
if s == string(o.key) || s == o.name || s == shortName(o.name) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
names := make([]string, len(sortOrders))
|
||||
for i, o := range sortOrders {
|
||||
names[i] = shortName(o.name)
|
||||
}
|
||||
return 0, errf("cannot sort by %q — try one of: %s", s, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// sortLabel is the order as the title shows it: which way, and by what.
|
||||
func (b *browser) sortLabel() string {
|
||||
arrow := "↑"
|
||||
if b.sortDesc {
|
||||
arrow = "↓"
|
||||
}
|
||||
return arrow + " " + b.order().name
|
||||
}
|
||||
|
||||
// sortLegend is the choices, on the two lines they are grouped into: what the
|
||||
// machine is doing and wants doing to it, then what it is made of and where it
|
||||
// lives. Terse by necessity — it shares the bottom of the screen with nothing
|
||||
// but itself — and the title says what the order is anyway, so nobody who
|
||||
// misses it is lost.
|
||||
//
|
||||
// The second line is indented under the first one's entries rather than under
|
||||
// its label, so the two read as one list and not as a sentence continued.
|
||||
func sortLegend() []string {
|
||||
const label = "sort: "
|
||||
|
||||
lines := []string{label}
|
||||
for _, o := range sortOrders {
|
||||
entry := string(o.key) + "·" + shortName(o.name)
|
||||
if o.legendBreak && len(lines) == 1 {
|
||||
lines = append(lines, SR(" ", len(label)))
|
||||
}
|
||||
at := len(lines) - 1
|
||||
if !strings.HasSuffix(lines[at], " ") {
|
||||
lines[at] += " "
|
||||
}
|
||||
lines[at] += entry
|
||||
}
|
||||
// Reverse is not an order of its own and goes at the end of the last line.
|
||||
lines[len(lines)-1] += " " + string(sortReverse) + "·reverse"
|
||||
return lines
|
||||
}
|
||||
|
||||
// shortName is the legend's spelling: the title has room for the whole name, one
|
||||
// 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":
|
||||
return "mem%"
|
||||
case "memory size":
|
||||
return "size"
|
||||
case "processors":
|
||||
return "cpus"
|
||||
case "vcenter":
|
||||
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
|
||||
}
|
||||
|
||||
// sortPrompt puts the legend up and waits for one key. Anything that is not a
|
||||
// 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() {
|
||||
// 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.
|
||||
lines := sortLegend()
|
||||
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
|
||||
|
||||
if k.special != keyRune {
|
||||
return
|
||||
}
|
||||
if k.r == sortReverse {
|
||||
b.sortDesc = !b.sortDesc
|
||||
b.applySort()
|
||||
b.setStatus(colInfo, "sorted "+b.sortLabel())
|
||||
return
|
||||
}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == k.r {
|
||||
b.sortBy, b.sortDesc = i, o.natural
|
||||
b.applySort()
|
||||
b.setStatus(colInfo, "sorted "+b.sortLabel())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sortedColumn reports whether this column is the one the table is ordered by, so
|
||||
// its heading can be lit up. Matching by name rather than by index keeps the two
|
||||
// tables — the columns and the orders — free to be listed in different orders.
|
||||
func (b *browser) sortedColumn(header string) bool {
|
||||
switch b.order().name {
|
||||
case "name":
|
||||
return header == "NAME"
|
||||
case "power":
|
||||
return header == "PWR"
|
||||
case "cpu load":
|
||||
return header == "CPU%"
|
||||
case "memory in use":
|
||||
return header == "MEM%"
|
||||
case "memory size":
|
||||
return header == "MEM"
|
||||
case "processors":
|
||||
return header == "CPU"
|
||||
case "vcenter":
|
||||
return header == "VC"
|
||||
case "host":
|
||||
return header == "HOST"
|
||||
case "address":
|
||||
return header == "IP"
|
||||
case "snapshot age", "snapshots":
|
||||
return header == "SNAP"
|
||||
case "issues":
|
||||
return header == "WHY"
|
||||
}
|
||||
return false
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// sortRow builds a machine with the few figures the orders compare.
|
||||
func sortRow(name, vc, host, ip string, cpu, memMB int32, mhz, memUse int32, on bool) vmRow {
|
||||
state := types.VirtualMachinePowerStatePoweredOff
|
||||
if on {
|
||||
state = types.VirtualMachinePowerStatePoweredOn
|
||||
}
|
||||
r := vmRow{
|
||||
vc: VCenter{Name: vc},
|
||||
ref: types.ManagedObjectReference{Value: "vm-" + name},
|
||||
name: name,
|
||||
host: host,
|
||||
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
|
||||
Config: types.VirtualMachineConfigSummary{Name: name, NumCpu: cpu, MemorySizeMB: memMB},
|
||||
Runtime: types.VirtualMachineRuntimeInfo{
|
||||
PowerState: state, MaxCpuUsage: 1000,
|
||||
},
|
||||
QuickStats: types.VirtualMachineQuickStats{
|
||||
OverallCpuUsage: mhz, GuestMemoryUsage: memUse,
|
||||
},
|
||||
}},
|
||||
}
|
||||
if ip != "" {
|
||||
r.vm.Guest = &types.GuestInfo{IpAddress: ip}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// sortFixture: four machines chosen so that every order puts them in a different
|
||||
// sequence, and one of them is stopped so the "unknown load" rule is exercised.
|
||||
func sortFixture() []vmRow {
|
||||
return []vmRow{
|
||||
// name vc host ip cpu mem mhz memuse on
|
||||
sortRow("delta", "v108", "esx2", "10.0.0.4", 2, 4096, 100, 2048, true), // cpu 10%, mem 50%
|
||||
sortRow("alpha", "v308", "esx1", "10.0.0.1", 8, 16384, 900, 1638, true), // cpu 90%, mem 10%
|
||||
sortRow("charlie", "v38", "esx3", "", 1, 2048, 500, 1536, true), // cpu 50%, mem 75%
|
||||
sortRow("bravo", "v308", "esx1", "10.0.0.2", 4, 8192, 0, 0, false), // stopped
|
||||
}
|
||||
}
|
||||
|
||||
func order(t *testing.T, key rune, desc bool) []string {
|
||||
t.Helper()
|
||||
b := &browser{rows: sortFixture()}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == key {
|
||||
b.sortBy, b.sortDesc = i, desc
|
||||
}
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
out := make([]string, len(b.rows))
|
||||
for i, r := range b.rows {
|
||||
out[i] = r.name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSortOrders(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
key rune
|
||||
desc bool
|
||||
want string
|
||||
note string
|
||||
}{
|
||||
{'n', false, "alpha bravo charlie delta", "by name, a to z"},
|
||||
{'n', true, "delta charlie bravo alpha", "by name, reversed"},
|
||||
|
||||
// The busiest first, and the machine that is not running has no load at
|
||||
// all, so it goes last however loud the others are.
|
||||
{'c', true, "alpha charlie delta bravo", "by cpu load, busiest first"},
|
||||
{'c', false, "bravo delta charlie alpha", "by cpu load, quietest first"},
|
||||
{'m', true, "charlie delta alpha bravo", "by memory in use, fullest first"},
|
||||
|
||||
{'s', true, "alpha bravo delta charlie", "by memory size: 16G 8G 4G 2G"},
|
||||
{'u', true, "alpha bravo delta charlie", "by processors, most first"},
|
||||
|
||||
// Running before stopped, and equal machines stay in name order.
|
||||
{'p', true, "alpha charlie delta bravo", "by power, running first"},
|
||||
|
||||
{'v', false, "delta alpha bravo charlie", "by vcenter: v108 v308 v308 v38, as text"},
|
||||
{'h', false, "alpha bravo delta charlie", "by host"},
|
||||
|
||||
// A machine with no address has not got a small one: it goes last, in the
|
||||
// a-to-z direction this order is asked for with.
|
||||
{'a', false, "alpha bravo delta charlie", "by address, the addressless last"},
|
||||
} {
|
||||
if got := strings.Join(order(t, c.key, c.desc), " "); got != c.want {
|
||||
t.Errorf("%s:\n got %s\n want %s", c.note, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Machines that compare equal keep name order whichever way the sort runs. A
|
||||
// screen full of machines all at 0 % that reshuffled when the direction flipped
|
||||
// would look as though the numbers had changed.
|
||||
func TestEqualMachinesKeepNameOrder(t *testing.T) {
|
||||
rows := []vmRow{
|
||||
sortRow("zulu", "v308", "esx1", "", 1, 1024, 0, 0, false),
|
||||
sortRow("mike", "v308", "esx1", "", 1, 1024, 0, 0, false),
|
||||
sortRow("alpha", "v308", "esx1", "", 1, 1024, 0, 0, false),
|
||||
}
|
||||
for _, desc := range []bool{false, true} {
|
||||
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == 'c' {
|
||||
b.sortBy = i
|
||||
}
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
var got []string
|
||||
for _, r := range b.rows {
|
||||
got = append(got, r.name)
|
||||
}
|
||||
if strings.Join(got, " ") != "alpha mike zulu" {
|
||||
t.Errorf("reversed=%v: equal machines came out %v", desc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picking an order takes its own direction with it: nobody asking for the
|
||||
// processor load wants the idle machines first.
|
||||
func TestEachOrderHasItsOwnDirection(t *testing.T) {
|
||||
for _, o := range sortOrders {
|
||||
switch o.name {
|
||||
case "name", "vcenter", "host", "address":
|
||||
if o.natural {
|
||||
t.Errorf("%s reads downwards by default", o.name)
|
||||
}
|
||||
default:
|
||||
if !o.natural {
|
||||
t.Errorf("%s does not put the largest first by default", o.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt: a letter picks an order, r reverses, and anything else leaves the
|
||||
// table as it was — it is the one prompt in the list reached by accident.
|
||||
func TestSortPrompt(t *testing.T) {
|
||||
newBrowser := func() (*browser, func(string)) {
|
||||
b := &browser{rows: sortFixture()}
|
||||
b.applySort()
|
||||
screen, keys, _ := headlessBrowser(t)
|
||||
b.tty, b.keys = screen.tty, screen.keys
|
||||
return b, func(s string) { keys.WriteString(s); b.sortPrompt() }
|
||||
}
|
||||
|
||||
b, press := newBrowser()
|
||||
press("c")
|
||||
if b.order().name != "cpu load" || !b.sortDesc {
|
||||
t.Errorf("c gave %q desc=%v, want cpu load busiest first", b.order().name, b.sortDesc)
|
||||
}
|
||||
if b.rows[0].name != "alpha" {
|
||||
t.Errorf("the busiest machine is not first: %s", b.rows[0].name)
|
||||
}
|
||||
|
||||
press("r")
|
||||
if !strings.Contains(b.sortLabel(), "↑") || b.rows[0].name != "bravo" {
|
||||
t.Errorf("r did not reverse: %q, first %s", b.sortLabel(), b.rows[0].name)
|
||||
}
|
||||
|
||||
before := b.order().name
|
||||
press("\x1b") // Esc
|
||||
if b.order().name != before {
|
||||
t.Errorf("Esc changed the order to %q", b.order().name)
|
||||
}
|
||||
press("Z") // not a choice
|
||||
if b.order().name != before {
|
||||
t.Errorf("a stray letter changed the order to %q", b.order().name)
|
||||
}
|
||||
if b.prompt != nil {
|
||||
t.Error("the legend stayed on screen")
|
||||
}
|
||||
}
|
||||
|
||||
// The title says which order the table is in, the heading of that column is lit,
|
||||
// and exactly one column is.
|
||||
func TestSortIsVisibleInTheTable(t *testing.T) {
|
||||
b := &browser{rows: sortFixture()}
|
||||
for i, o := range sortOrders {
|
||||
b.sortBy, b.sortDesc = i, o.natural
|
||||
b.applySort()
|
||||
|
||||
if !strings.Contains(b.sortLabel(), o.name) {
|
||||
t.Errorf("the title says %q for the %s order", b.sortLabel(), o.name)
|
||||
}
|
||||
arrow := "↑"
|
||||
if o.natural {
|
||||
arrow = "↓"
|
||||
}
|
||||
if !strings.HasPrefix(b.sortLabel(), arrow) {
|
||||
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 table {
|
||||
if b.sortedColumn(c.header) {
|
||||
lit++
|
||||
}
|
||||
}
|
||||
if lit != 1 {
|
||||
t.Errorf("the %s order lights %d column headings, want exactly 1", o.name, lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every order is offered, and the legend fits a terminal of eighty.
|
||||
func TestSortLegend(t *testing.T) {
|
||||
lines := sortLegend()
|
||||
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:\n%s", string(o.key), o.name, legend)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
|
||||
t.Errorf("the legend does not offer the reverse:\n%s", 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)
|
||||
}
|
||||
|
||||
// Distinct letters, or one of them would be unreachable.
|
||||
seen := map[rune]bool{sortReverse: true}
|
||||
for _, o := range sortOrders {
|
||||
if seen[o.key] {
|
||||
t.Errorf("%q is the letter for more than one order", string(o.key))
|
||||
}
|
||||
seen[o.key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// The sort legend is a menu, not a question: it must not be dressed as a warning
|
||||
// and must not have the yes/no hint hung on it, which is what happened while the
|
||||
// two shared one string.
|
||||
func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
|
||||
b := &browser{rows: sortFixture()}
|
||||
b.applySort()
|
||||
screen, keys, cleanup := headlessBrowser(t)
|
||||
defer cleanup()
|
||||
b.tty, b.keys = screen.tty, screen.keys
|
||||
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "16")
|
||||
|
||||
// Render the legend by asking for it and reading the frame the prompt puts up,
|
||||
// with the keystroke that answers it queued behind.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
b.tty = w
|
||||
keys.WriteString("n")
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
out, _ := io.ReadAll(r)
|
||||
done <- string(out)
|
||||
}()
|
||||
b.sortPrompt()
|
||||
w.Close()
|
||||
frame := stripEscapes(<-done)
|
||||
|
||||
if !strings.Contains(frame, "sort:") {
|
||||
t.Fatalf("the legend was not drawn:\n%s", frame)
|
||||
}
|
||||
if strings.Contains(frame, "y = yes") {
|
||||
t.Error("the yes/no hint was hung on the sort legend")
|
||||
}
|
||||
for _, line := range strings.Split(frame, "\r\n") {
|
||||
if strings.Contains(line, "sort:") && len([]rune(line)) > 100 {
|
||||
t.Errorf("the legend line is %d columns wide: %q", len([]rune(line)), line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, '#', true); got != "three one none" {
|
||||
t.Errorf("most snapshots first gave %q", got)
|
||||
}
|
||||
if got := orderOf(t, rows, '#', 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.
|
||||
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
|
||||
t.Helper()
|
||||
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == key {
|
||||
b.sortBy = i
|
||||
}
|
||||
}
|
||||
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, '#', 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)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,26 @@
|
||||
// ======================================================================================= go toolbox (mwx'2026)
|
||||
//
|
||||
// What gvm actually uses. The toolbox this came from carries a good deal more —
|
||||
// base62 encoders, a mysql row reader, viper and gjson wrappers, an ip-range
|
||||
// access check — and none of it was ever called here. Carried along, it was three
|
||||
// dependencies and a page of code that nothing exercised and no test covered,
|
||||
// including one access check that had never been wired up and would have panicked
|
||||
// on the first malformed entry in its list. It is gone; the toolbox it belongs to
|
||||
// still has it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/eknkc/basex"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var tbversion = "0.6.0"
|
||||
|
||||
var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w="
|
||||
var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
// Updating oneself lives in selfupdate.go now: releases of a Gitea instance,
|
||||
// fetched over TLS and probed before they replace the running binary. What used
|
||||
// to be here downloaded a binary over plain http and applied it unverified.
|
||||
|
||||
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
|
||||
addrs, err := net.InterfaceAddrs() // once, not once per configured network
|
||||
if err != nil {
|
||||
PE("Error getting addresses")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
match := 0
|
||||
for _, validnet := range NETS {
|
||||
_, ipNet, err := net.ParseCIDR(validnet)
|
||||
if err != nil { // a typo in the list is a typo, not a nil dereference
|
||||
PE("bad network " + validnet)
|
||||
continue
|
||||
}
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
if ipNet.Contains(ipnet.IP) {
|
||||
match++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match == 0 {
|
||||
PE("access violation, permission denied")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func Enc(str string) string { // ----------------------------------------------------------------- encode string
|
||||
enc, err := basex.NewEncoding(Db64(CHRS))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return enc.Encode([]byte(Rndstr(2) + str))
|
||||
}
|
||||
|
||||
func Dec(str string) string { // ----------------------------------------------------------------- decode string
|
||||
enc, err := basex.NewEncoding(Db64(CHRS))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := enc.Decode(str)
|
||||
if err != nil || len(b) < 2 { // the 2 bytes are the Rndstr(2) prefix Enc puts in front
|
||||
return ""
|
||||
}
|
||||
return string(b[2:])
|
||||
}
|
||||
|
||||
func Db64(txt string) string { // ------------------------------------------------------------- string to base64
|
||||
d, _ := base64.StdEncoding.DecodeString(txt)
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func Rndstr(n int) string { // ------------------------------------------------------- random string with length
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(LR))))
|
||||
b[i] = LR[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func Input(msg string, def string) string { // -------------------------------- AlecAivazis/survey: input string
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Input{Message: msg, Default: def}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no
|
||||
|
||||
if overwrite {
|
||||
@@ -149,14 +49,16 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
|
||||
}
|
||||
}
|
||||
|
||||
func Getid(n int) string { // --------------------------------------------------------- get base62 random string
|
||||
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ret := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
ret[i] = letters[num.Int64()]
|
||||
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return string(ret)
|
||||
return tmp
|
||||
}
|
||||
|
||||
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
|
||||
@@ -169,70 +71,6 @@ func GETid(n int) string { // --------------------------------------------------
|
||||
return string(ret)
|
||||
}
|
||||
|
||||
func Mytable(rows *sql.Rows) []map[string]interface{} { // ----------------------------- load mysql result table
|
||||
defer rows.Close()
|
||||
columns, _ := rows.Columns()
|
||||
count := len(columns)
|
||||
tableData := make([]map[string]interface{}, 0)
|
||||
values := make([]interface{}, count)
|
||||
valuePtrs := make([]interface{}, count)
|
||||
for rows.Next() {
|
||||
for i := 0; i < count; i++ {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
rows.Scan(valuePtrs...)
|
||||
entry := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
var v interface{}
|
||||
val := values[i]
|
||||
b, ok := val.([]byte)
|
||||
if ok {
|
||||
v = string(b)
|
||||
} else {
|
||||
v = val
|
||||
}
|
||||
entry[col] = v
|
||||
}
|
||||
tableData = append(tableData, entry)
|
||||
}
|
||||
return (tableData)
|
||||
}
|
||||
|
||||
func Checkip(network string, ip string) bool { // ---------- check if ip is in range (cidr address or single ip)
|
||||
if net.ParseIP(ip) == nil {
|
||||
return false
|
||||
}
|
||||
_, subnet, err := net.ParseCIDR(network)
|
||||
if err == nil {
|
||||
if subnet.Contains(net.ParseIP(ip)) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if network == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Isflagpassed(name string) bool { // -------------------------------------------------- check if flag is set
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func Body(r *http.Response) string { // ------------------------------------------------------------ http body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err == nil {
|
||||
return string(body)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Atoi(s string) int { // ------------------------------------------------------------------------------ atoi
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
@@ -245,14 +83,6 @@ func Itoa(i int) string { // ---------------------------------------------------
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
func GJA(j string, k string) []string { // -------------------------------------------------- convert gjson array
|
||||
var ret []string
|
||||
for _, c := range gjson.Get(j, k).Array() {
|
||||
ret = append(ret, c.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ print simple
|
||||
|
||||
func P(a ...any) (n int, err error) { return fmt.Fprintln(os.Stdout, a...) }
|
||||
@@ -276,51 +106,8 @@ func PO(msg ...string) (n int, err error) {
|
||||
// errf is fmt.Errorf under a shorter name, to go with the P/PF/SF family.
|
||||
func errf(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
|
||||
// ------------------------------------------------------------------------------------------ regular expression
|
||||
|
||||
func ReplaceFirst(re *regexp.Regexp, str, replace string) string {
|
||||
loc := re.FindStringIndex(str)
|
||||
if loc == nil {
|
||||
return str
|
||||
}
|
||||
return str[:loc[0]] + replace + str[loc[1]:]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------- string functions
|
||||
|
||||
func Shortstr(s string, length int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= length {
|
||||
return s
|
||||
}
|
||||
return string(runes[:length-2]) + ".."
|
||||
}
|
||||
|
||||
func SR(str string, n int) string { return strings.Repeat(str, n) }
|
||||
|
||||
func RemoveAllMatches(slice []string, target string) []string { // remove matching string from array
|
||||
// A new slice, not slice[:0]: filtering in place writes through the caller's
|
||||
// backing array, so the caller's own variable is left holding shifted,
|
||||
// duplicated entries — and it looks like a pure function at the call site.
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if v != target {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------- system functions
|
||||
|
||||
func prgname() string { // program name
|
||||
exepath, err := os.Executable()
|
||||
if err != nil {
|
||||
PE(SF("Error getting executable path: %s", err))
|
||||
return ""
|
||||
}
|
||||
exename := filepath.Base(exepath)
|
||||
return exename
|
||||
}
|
||||
|
||||
// ========================================================================================================= END
|
||||
|
||||
@@ -83,8 +83,10 @@ const (
|
||||
keyShiftTab
|
||||
keyEnter
|
||||
keyCtrlA
|
||||
keyCtrlO
|
||||
keyCtrlR
|
||||
keyCtrlS
|
||||
keyCtrlW
|
||||
keyCtrlC
|
||||
keyEsc
|
||||
)
|
||||
@@ -136,10 +138,17 @@ func (kr *keyReader) next() key {
|
||||
return key{special: keyCtrlC}
|
||||
case 0x01:
|
||||
return key{special: keyCtrlA}
|
||||
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':
|
||||
@@ -183,11 +192,39 @@ func (kr *keyReader) readEscape() key {
|
||||
if b2 != '[' {
|
||||
return key{special: keyEsc}
|
||||
}
|
||||
b3, ok := kr.readByte()
|
||||
if !ok {
|
||||
return key{special: keyEsc}
|
||||
|
||||
// Read the whole sequence before deciding what it was. A control sequence is
|
||||
// ESC [ then parameter bytes then one final byte in 0x40..0x7e, and how many
|
||||
// parameters there are depends on the key *and* on which modifiers were held:
|
||||
// the plain up arrow is "ESC [ A", the same key with control is "ESC [ 1;5A".
|
||||
//
|
||||
// Reading a fixed number of bytes instead — one, and for the digits one more
|
||||
// for the "~" — left the rest of a longer sequence in the stream, where the
|
||||
// next read took it for typing. Ctrl-Up put "5A" into the filter and F5 put a
|
||||
// tilde in it, having first jumped to the top of the list.
|
||||
params := make([]byte, 0, 8)
|
||||
var final byte
|
||||
for {
|
||||
b, ok := kr.readByte()
|
||||
if !ok {
|
||||
return key{special: keyEsc}
|
||||
}
|
||||
if b >= 0x40 && b <= 0x7e {
|
||||
final = b
|
||||
break
|
||||
}
|
||||
if len(params) < cap(params) {
|
||||
params = append(params, b)
|
||||
}
|
||||
}
|
||||
switch b3 {
|
||||
|
||||
// Only the unmodified keys are answered. A sequence with modifiers, or one
|
||||
// this does not know, is swallowed whole and ignored — which is the point:
|
||||
// what must not happen is for half of it to arrive as text.
|
||||
if len(params) > 0 && final != '~' {
|
||||
return key{special: keyNone}
|
||||
}
|
||||
switch final {
|
||||
case 'A':
|
||||
return key{special: keyUp}
|
||||
case 'B':
|
||||
@@ -202,22 +239,21 @@ func (kr *keyReader) readEscape() key {
|
||||
return key{special: keyEnd}
|
||||
case 'Z':
|
||||
return key{special: keyShiftTab}
|
||||
case '1', '3', '4', '5', '6':
|
||||
kr.readByte() // consume trailing '~'
|
||||
switch b3 {
|
||||
case '1':
|
||||
case '~':
|
||||
switch string(params) {
|
||||
case "1", "7":
|
||||
return key{special: keyHome}
|
||||
case '3':
|
||||
case "3":
|
||||
return key{special: keyDelete}
|
||||
case '4':
|
||||
case "4", "8":
|
||||
return key{special: keyEnd}
|
||||
case '5':
|
||||
case "5":
|
||||
return key{special: keyPgUp}
|
||||
case '6':
|
||||
case "6":
|
||||
return key{special: keyPgDn}
|
||||
}
|
||||
}
|
||||
return key{special: keyEsc}
|
||||
return key{special: keyNone}
|
||||
}
|
||||
|
||||
func utf8SeqLen(b byte) int {
|
||||
|
||||
+84
-4
@@ -15,8 +15,10 @@ import (
|
||||
"github.com/vmware/govmomi"
|
||||
"github.com/vmware/govmomi/find"
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/property"
|
||||
"github.com/vmware/govmomi/view"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// dialTimeout bounds a login. Without one a vCenter that accepts the connection
|
||||
@@ -30,6 +32,12 @@ type session struct {
|
||||
ctx context.Context
|
||||
client *govmomi.Client
|
||||
cancel context.CancelFunc
|
||||
|
||||
// The names of the alarm definitions this server has triggered, filled in by
|
||||
// the sweep when anything is actually alarming (browse.go). It belongs to
|
||||
// the connection rather than to a machine: one alarm stands against many
|
||||
// machines, and its name is worth reading once per server, not once per row.
|
||||
alarms map[types.ManagedObjectReference]string
|
||||
}
|
||||
|
||||
// connect logs in to one server. The caller closes what comes back — a session
|
||||
@@ -38,12 +46,11 @@ type session struct {
|
||||
func connect(vc VCenter) (*session, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
u, err := url.Parse(vc.sdkURL())
|
||||
u, err := loginURL(vc)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
|
||||
return nil, err
|
||||
}
|
||||
u.User = url.UserPassword(vc.User, vc.Password)
|
||||
|
||||
dial, dialCancel := context.WithTimeout(ctx, dialTimeout)
|
||||
defer dialCancel()
|
||||
@@ -56,6 +63,24 @@ func connect(vc VCenter) (*session, error) {
|
||||
return &session{vc: vc, ctx: ctx, client: client, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
// loginURL is the endpoint with the credentials in it — the one place where a
|
||||
// password is opened and handed over. Its own function so that what goes on the
|
||||
// wire can be checked without a server: whether the password that leaves here is
|
||||
// the opened one, and not the sealed word out of the file, is the whole claim of
|
||||
// seal.go.
|
||||
func loginURL(vc VCenter) (*url.URL, error) {
|
||||
u, err := url.Parse(vc.sdkURL())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: bad url %q: %w", vc.Name, vc.URL, err)
|
||||
}
|
||||
secret, err := vc.password()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.User = url.UserPassword(vc.User, secret)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// close logs out and drops the context. Logging out is best effort: there is
|
||||
// nothing useful to do about a failure while shutting down.
|
||||
func (s *session) close() {
|
||||
@@ -105,7 +130,8 @@ func (s *session) retrieve(kind string, props []string, dst any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vms and hosts are the two inventory sweeps gvm makes.
|
||||
// vms, hosts and datastores are the inventory sweeps gvm makes: one call each,
|
||||
// for everything of that kind in the whole inventory.
|
||||
func (s *session) vms(props ...string) ([]mo.VirtualMachine, error) {
|
||||
var out []mo.VirtualMachine
|
||||
return out, s.retrieve("VirtualMachine", props, &out)
|
||||
@@ -115,3 +141,57 @@ func (s *session) hosts(props ...string) ([]mo.HostSystem, error) {
|
||||
var out []mo.HostSystem
|
||||
return out, s.retrieve("HostSystem", props, &out)
|
||||
}
|
||||
|
||||
func (s *session) datastores(props ...string) ([]mo.Datastore, error) {
|
||||
var out []mo.Datastore
|
||||
return out, s.retrieve("Datastore", props, &out)
|
||||
}
|
||||
|
||||
// instanceUUID identifies this vCenter to itself: it is the serverGuid the
|
||||
// vSphere client puts in the URLs of the objects it shows, which is the one
|
||||
// thing gvm cannot work out from the configuration alone (see vsphereURL).
|
||||
func (s *session) instanceUUID() string {
|
||||
if s.client == nil {
|
||||
return ""
|
||||
}
|
||||
return s.client.ServiceContent.About.InstanceUuid
|
||||
}
|
||||
|
||||
// objects fills dst with the named properties of exactly the objects given,
|
||||
// rather than of everything of a kind. Alarm definitions and tasks are not in
|
||||
// the inventory container view — they hang off their managers — so the only way
|
||||
// to read them is by reference, and by all of them in one call: one round trip
|
||||
// for a screenful, not one per line.
|
||||
func (s *session) objects(refs []types.ManagedObjectReference, props []string, dst any) error {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := property.DefaultCollector(s.client.Client).
|
||||
Retrieve(s.ctx, refs, props, dst); err != nil {
|
||||
return fmt.Errorf("%s: cannot read %d objects: %w", s.vc.Name, len(refs), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tasks reads what those task references are doing. A task that has finished is
|
||||
// still on a machine's recentTask list for a while afterwards, so the caller
|
||||
// decides what counts as going on; this only reports.
|
||||
func (s *session) tasks(refs []types.ManagedObjectReference) ([]mo.Task, error) {
|
||||
var out []mo.Task
|
||||
return out, s.objects(refs, []string{"info"}, &out)
|
||||
}
|
||||
|
||||
// alarmNames resolves alarm definitions to the names a person gave them. A
|
||||
// triggered alarm carries only the reference of its definition, and "alarm-3 is
|
||||
// red" is not something anyone can act on.
|
||||
func (s *session) alarmNames(refs []types.ManagedObjectReference) map[types.ManagedObjectReference]string {
|
||||
var alarms []mo.Alarm
|
||||
if err := s.objects(refs, []string{"info.name"}, &alarms); err != nil {
|
||||
return nil // the names are a courtesy; the references still say which
|
||||
}
|
||||
out := make(map[types.ManagedObjectReference]string, len(alarms))
|
||||
for _, a := range alarms {
|
||||
out[a.Reference()] = a.Info.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.2
|
||||
|
||||
@@ -1,110 +1,131 @@
|
||||
// vm.go — the virtual machine listing.
|
||||
// vm.go — the printed machine listing.
|
||||
//
|
||||
// The same columns, cells and colours as the interactive list, from the same
|
||||
// table: `gvm vm -l` and `gvm vm` used to disagree about the very same machine,
|
||||
// the printed one showing a bare 32 where the other showed 32.0MB, and lacking
|
||||
// the power state, the host and both load figures entirely. There is one
|
||||
// definition of what a row of machines looks like (browse.go) and both views
|
||||
// render it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// lsvm lists the virtual machines of every server it is given, sorted by name
|
||||
// across all of them. One unreachable vCenter is a warning and not the end: the
|
||||
// old version called log.Fatal inside the loop, which threw away everything the
|
||||
// servers before it had already answered.
|
||||
//
|
||||
// The servers are asked at the same time. Three logins one after another are
|
||||
// three round trips to three machines, and there is nothing to be gained by
|
||||
// waiting for each in turn.
|
||||
func lsvm(targets []VCenter, match string) error {
|
||||
re, err := regexp.Compile("(?i)" + match)
|
||||
// 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, opt lsOptions) error {
|
||||
re, err := regexp.Compile("(?i)" + opt.match)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad pattern %q: %w", match, err)
|
||||
return errf("bad pattern %q: %w", opt.match, err)
|
||||
}
|
||||
by, err := findOrder(opt.orderBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
xfmt = "%-32s %-8s %4s %-15s %3d %8d\n"
|
||||
xfmts = "%-32s %-8s %4s %-15s %3s %8s\n"
|
||||
)
|
||||
PF(xfmts, "VM NAME", "ID", "VM", "IP", "CPU", "MEMORY")
|
||||
PF(xfmts, SR("=", 32), SR("=", 8), SR("=", 4), SR("=", 15), SR("=", 3), SR("=", 8))
|
||||
|
||||
rows := make([][]string, len(targets))
|
||||
errs := make([]error, len(targets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, vc := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, vc VCenter) {
|
||||
defer wg.Done()
|
||||
rows[i], errs[i] = listOne(vc, re, xfmt)
|
||||
}(i, vc)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var all []string
|
||||
failed := 0
|
||||
for i := range targets {
|
||||
if errs[i] != nil {
|
||||
PE(errs[i].Error())
|
||||
failed++
|
||||
continue
|
||||
found, err := gatherVMs(targets)
|
||||
defer closeSessions(found.sessions)
|
||||
// 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)
|
||||
}
|
||||
all = append(all, rows[i]...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Strings(all)
|
||||
for _, line := range all {
|
||||
PN(line)
|
||||
rows := make([]vmRow, 0, len(found.rows))
|
||||
for _, r := range found.rows {
|
||||
if re.MatchString(r.name) {
|
||||
rows = append(rows, r)
|
||||
}
|
||||
}
|
||||
if opt.issues {
|
||||
rows = withIssues(rows)
|
||||
}
|
||||
sortRows(rows, by, opt.reverse != sortOrders[by].natural)
|
||||
|
||||
// Nothing at all came back: that is a failure of the command, not a listing
|
||||
// that happens to be empty, and the exit status should say so.
|
||||
if failed == len(targets) {
|
||||
return fmt.Errorf("no vCenter answered")
|
||||
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
|
||||
}
|
||||
|
||||
// listOne collects the formatted lines of a single server.
|
||||
func listOne(vc VCenter, re *regexp.Regexp, xfmt string) ([]string, error) {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.close()
|
||||
// printList writes the table. On a terminal it is fitted to the width, the same
|
||||
// as the interactive one; into a pipe every column is written and every column is
|
||||
// made as wide as the longest thing in it.
|
||||
//
|
||||
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
|
||||
// machine names off at twenty-two characters — silently, with an ellipsis, into
|
||||
// output whose whole purpose is to be read by something else.
|
||||
func printList(rows []vmRow, table []browseColumn) {
|
||||
cols := fitColumnsOf(table, printWidth(table))
|
||||
|
||||
vms, err := s.vms("summary", "guest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
pcs := make([]printColumn, len(cols))
|
||||
for i, c := range cols {
|
||||
pcs[i] = printColumn{header: c.header, width: c.width}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, vm := range vms {
|
||||
name := vm.Summary.Config.Name
|
||||
if name == "" || !re.MatchString(name) {
|
||||
continue
|
||||
body := make([][]cell, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cells := make([]cell, len(cols))
|
||||
for i, c := range cols {
|
||||
col := ""
|
||||
if c.color != nil {
|
||||
col = c.color(r)
|
||||
}
|
||||
cells[i] = cell{text: c.cell(r), col: col}
|
||||
}
|
||||
|
||||
// Both of these are optional in vSphere and absent for a machine that
|
||||
// is being created or deleted while the listing runs — reading them
|
||||
// unguarded is a nil dereference at exactly the wrong moment.
|
||||
ip := "-"
|
||||
if vm.Guest != nil && vm.Guest.IpAddress != "" {
|
||||
ip = vm.Guest.IpAddress
|
||||
}
|
||||
id := "-"
|
||||
if vm.Summary.Vm != nil {
|
||||
id = vm.Summary.Vm.Value
|
||||
}
|
||||
|
||||
out = append(out, SF(xfmt,
|
||||
Shortstr(name, 32),
|
||||
id,
|
||||
vc.Name,
|
||||
ip,
|
||||
vm.Summary.Config.NumCpu,
|
||||
vm.Summary.Config.MemorySizeMB))
|
||||
body = append(body, cells)
|
||||
}
|
||||
if color.NoColor { // no terminal, so nothing to fit into and nothing to cut for
|
||||
widen(pcs, body)
|
||||
}
|
||||
|
||||
printRow(pcs, "", nil)
|
||||
for _, cells := range body {
|
||||
printRow(pcs, "", cells)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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(table []browseColumn) int {
|
||||
if color.NoColor {
|
||||
return tableWidth(table)
|
||||
}
|
||||
cols, _ := termSize()
|
||||
return max(cols, 20)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user